clean up and fix up jumping

This commit is contained in:
Henry Hiles 2026-09-29 19:24:16 -04:00
commit 1e4aab08ad
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs

View file

@ -1,5 +1,3 @@
import "dart:async";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
@ -26,28 +24,20 @@ final class ChatScroll({
required ValueNotifier<int?> contextualEvent, required ValueNotifier<int?> contextualEvent,
}) { }) {
final anchorId = useState<int?>(null); final anchorId = useState<int?>(null);
final atBottom = useState(true);
final pendingJump = useState<int?>(null);
final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new);
final itemKeys = useMemoized(() => <int, GlobalKey>{}, []); final itemKeys = useMemoized(() => <int, GlobalKey>{}, []);
GlobalKey keyFor(int eventRowId) => GlobalKey keyFor(int eventRowId) =>
itemKeys.putIfAbsent(eventRowId, GlobalKey.new); itemKeys.putIfAbsent(eventRowId, GlobalKey.new);
final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new);
final atBottom = useState(true);
final pendingAnchorTarget = useState<int?>(null);
final anchorMountedCompleter = useRef<Completer<BuildContext>?>(null);
useEffect(() { useEffect(() {
if (anchorId.value == null) { if (anchorId.value == null) {
if (controllerData case AsyncData(:final value?) if (controllerData case AsyncData(:final value?)
when value.timeline.isNotEmpty) { when value.timeline.isNotEmpty) {
final hasContextualEvent = value.timeline.any( anchorId.value = value.timeline.contains(contextualEvent.value)
(event) => event == contextualEvent.value,
);
anchorId.value = hasContextualEvent
? contextualEvent.value ? contextualEvent.value
: value.timeline.last; : value.timeline.last;
} }
@ -57,35 +47,34 @@ final class ChatScroll({
}, [controllerData, contextualEvent.value]); }, [controllerData, contextualEvent.value]);
useEffect(() { useEffect(() {
final target = pendingAnchorTarget.value; final rowId = pendingJump.value;
if (target == null) return null; if (rowId == null || anchorId.value != rowId) return null;
pendingJump.value = null;
final found = WidgetsBinding.instance.addPostFrameCallback((_) async {
controllerData.value?.timeline.any((event) => event == target) ?? if (!scrollController.hasClients) return;
false;
if (found || controllerData is AsyncError) { final position = scrollController.position;
if (found) { scrollController.jumpTo(
anchorId.value = target; (-position.viewportDimension)
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble(),
);
await WidgetsBinding.instance.endOfFrame;
WidgetsBinding.instance.addPostFrameCallback((_) { final context = keyFor(rowId).currentContext;
final context = keyFor(target).currentContext; if (context?.mounted == true) {
if (context != null && context.mounted) { await Scrollable.ensureVisible(
anchorMountedCompleter.value?.complete(context); context!,
anchorMountedCompleter.value = null; alignment: 0.5,
} duration: const .new(milliseconds: 700),
}); curve: Curves.easeInOut,
} else {
anchorMountedCompleter.value?.completeError(
StateError("Failed to load context for $target"),
); );
anchorMountedCompleter.value = null;
} }
pendingAnchorTarget.value = null; });
}
return null; return null;
}, [controllerData, pendingAnchorTarget.value]); }, [anchorId.value, pendingJump.value]);
final ({IList<int> history, IList<int> live}) split = useMemoized(() { final ({IList<int> history, IList<int> live}) split = useMemoized(() {
final items = controllerData.value?.timeline; final items = controllerData.value?.timeline;
@ -97,9 +86,7 @@ final class ChatScroll({
final anchorIndex = items.indexOf(anchor); final anchorIndex = items.indexOf(anchor);
if (anchorIndex == -1) { if (anchorIndex == -1) return (history: const .empty(), live: items);
return (history: const .empty(), live: items);
}
return ( return (
history: items.take(anchorIndex).toIList().reversed.toIList(), history: items.take(anchorIndex).toIList().reversed.toIList(),
@ -138,7 +125,6 @@ final class ChatScroll({
} }
scrollController.addListener(checkPosition); scrollController.addListener(checkPosition);
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition()); WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
return () => scrollController.removeListener(checkPosition); return () => scrollController.removeListener(checkPosition);
@ -158,35 +144,27 @@ final class ChatScroll({
centerKey: centerKey, centerKey: centerKey,
scrollController: scrollController, scrollController: scrollController,
atBottom: atBottom.value, atBottom: atBottom.value,
keyFor: keyFor,
jumpToEvent: (int rowId) async { jumpToEvent: (int rowId) async {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
final existing = keyFor(rowId).currentContext; final context = keyFor(rowId).currentContext;
if (existing != null && existing.mounted) { if (context != null) {
// Already mounted, just scroll
await Scrollable.ensureVisible(
existing,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
} else {
final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = rowId;
contextualEvent.value = rowId;
final context = await completer.future;
if (!context.mounted) return;
await Scrollable.ensureVisible(context, alignment: 10);
if (!context.mounted) return;
await Scrollable.ensureVisible( await Scrollable.ensureVisible(
context, context,
alignment: 0.5, alignment: 0.5,
duration: const .new(milliseconds: 700), duration: const .new(milliseconds: 700),
curve: Curves.easeOutCirc, curve: Curves.easeInOut,
); );
return;
}
pendingJump.value = rowId;
if (controllerData.value?.timeline.contains(rowId) ?? false) {
anchorId.value = rowId;
} else {
anchorId.value = null;
contextualEvent.value = rowId;
} }
}, },
jumpToBottom: () async { jumpToBottom: () async {
@ -203,7 +181,6 @@ final class ChatScroll({
curve: Curves.easeInOut, curve: Curves.easeInOut,
); );
}, },
keyFor: keyFor,
); );
} }
} }