tweak scroll behavior

This commit is contained in:
Henry Hiles 2026-09-26 14:52:24 -04:00
commit d712bb3193
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
2 changed files with 53 additions and 57 deletions

View file

@ -12,11 +12,11 @@ final class ChatScroll({
required final IList<Event> historyItems, required final IList<Event> historyItems,
required final IList<Event> liveItems, required final IList<Event> liveItems,
required final GlobalKey centerKey, required final GlobalKey centerKey,
required final GlobalKey anchorItemKey,
required final ScrollController scrollController, required final ScrollController scrollController,
required final bool atBottom, required final bool atBottom,
required final Future<void> Function(String id) jumpToId, required final Future<void> Function(String id) jumpToId,
required final Future<void> Function() jumpToBottom, required final Future<void> Function() jumpToBottom,
required final GlobalKey Function(String eventId) keyFor,
}) { }) {
factory use({ factory use({
required AsyncValue<RoomChat?> controllerData, required AsyncValue<RoomChat?> controllerData,
@ -26,7 +26,9 @@ final class ChatScroll({
}) { }) {
final anchorId = useState<String?>(null); final anchorId = useState<String?>(null);
final anchorItemKey = useMemoized(GlobalKey.new, [anchorId.value]); final itemKeys = useMemoized(() => <String, GlobalKey>{}, []);
GlobalKey keyFor(String eventId) =>
itemKeys.putIfAbsent(eventId, GlobalKey.new);
final scrollController = useScrollController(); final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new); final centerKey = useMemoized(GlobalKey.new);
@ -83,7 +85,7 @@ final class ChatScroll({
if (completer == null) return null; if (completer == null) return null;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final context = anchorItemKey.currentContext; final context = keyFor(anchorId.value!).currentContext;
if (context != null && context.mounted) { if (context != null && context.mounted) {
anchorMountedCompleter.value?.complete(context); anchorMountedCompleter.value?.complete(context);
anchorMountedCompleter.value = null; anchorMountedCompleter.value = null;
@ -151,62 +153,58 @@ final class ChatScroll({
], ],
); );
Future<void> jumpToId(String itemId) async {
if (!scrollController.hasClients) return;
if (anchorId.value != itemId) {
final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = itemId;
contextualEvent.value = itemId;
final context = await completer.future;
if (!context.mounted) return;
await Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
return;
}
final context = anchorItemKey.currentContext;
if (context != null && context.mounted) {
await Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
}
}
Future<void> jumpToBottom() async {
if (contextualEvent.value != null) {
anchorId.value = null;
contextualEvent.value = null;
}
if (!scrollController.hasClients) return;
await scrollController.animateTo(
scrollController.position.minScrollExtent,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
}
return .new( return .new(
historyItems: split.history, historyItems: split.history,
liveItems: split.live, liveItems: split.live,
centerKey: centerKey, centerKey: centerKey,
anchorItemKey: anchorItemKey,
scrollController: scrollController, scrollController: scrollController,
atBottom: atBottom.value, atBottom: atBottom.value,
jumpToId: jumpToId, jumpToId: (String itemId) async {
jumpToBottom: jumpToBottom, if (!scrollController.hasClients) return;
final existing = keyFor(itemId).currentContext;
if (existing != null && existing.mounted) {
// 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 = itemId;
contextualEvent.value = itemId;
final context = await completer.future;
if (!context.mounted) return;
await Scrollable.ensureVisible(context, alignment: 10);
if (!context.mounted) return;
await Scrollable.ensureVisible(
context,
alignment: 0.5,
duration: const .new(milliseconds: 700),
curve: Curves.easeOutCirc,
);
}
},
jumpToBottom: () async {
if (contextualEvent.value != null) {
anchorId.value = null;
contextualEvent.value = null;
}
if (!scrollController.hasClients) return;
await scrollController.animateTo(
scrollController.position.minScrollExtent,
duration: const .new(milliseconds: 700),
curve: Curves.easeInOut,
);
},
keyFor: keyFor,
); );
} }
} }

View file

@ -59,9 +59,7 @@ class const ChatTimeline({
jumpToId: jumpToId, jumpToId: jumpToId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent, highlightedEvent: highlightedEvent,
key: index == 0 key: scroll.keyFor(scroll.liveItems[index].eventId),
? scroll.anchorItemKey
: ValueKey(scroll.liveItems[index].eventId),
), ),
), ),
@ -74,7 +72,7 @@ class const ChatTimeline({
jumpToId: jumpToId, jumpToId: jumpToId,
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent, highlightedEvent: highlightedEvent,
key: ValueKey(scroll.historyItems[index].eventId), key: scroll.keyFor(scroll.historyItems[index].eventId),
), ),
), ),
], ],