add jump to bottom button

This commit is contained in:
Henry Hiles 2026-09-26 13:03:00 -04:00
commit 0ee0256383
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
2 changed files with 72 additions and 26 deletions

View file

@ -17,14 +17,15 @@ final class ChatScroll({
required final ListController historyListController,
required final ListController liveListController,
required final ScrollController scrollController,
required final bool atBottom,
required final Future<void> Function(String id) jumpToId,
required final Future<void> Function() jumpToBottom,
}) {
factory use({
required AsyncValue<RoomChat?> controllerData,
required Future<void> Function(Direction direction) paginate,
required Future<void> Function() markRead,
required String? contextualEvent,
required void Function(String id) setContextualEvent,
required ValueNotifier<String?> contextualEvent,
}) {
final anchorId = useState<String?>(null);
@ -35,6 +36,8 @@ final class ChatScroll({
final scrollController = useScrollController();
final centerKey = useMemoized(GlobalKey.new);
final atBottom = useState(true);
final pendingAnchorTarget = useState<String?>(null);
final anchorMountedCompleter = useRef<Completer<BuildContext>?>(null);
@ -43,17 +46,17 @@ final class ChatScroll({
if (controllerData case AsyncData(:final value?)
when value.timeline.isNotEmpty) {
final hasContextualEvent = value.timeline.any(
(event) => event.eventId == contextualEvent,
(event) => event.eventId == contextualEvent.value,
);
anchorId.value = hasContextualEvent
? contextualEvent
? contextualEvent.value
: value.timeline.last.eventId;
}
}
return null;
}, [controllerData, contextualEvent]);
}, [controllerData, contextualEvent.value]);
useEffect(() {
final target = pendingAnchorTarget.value;
@ -115,7 +118,8 @@ final class ChatScroll({
);
}, [controllerData, anchorId.value]);
useEffect(() {
useEffect(
() {
const loadThreshold = 500.0;
const readThreshold = 50.0;
@ -124,9 +128,12 @@ final class ChatScroll({
final position = scrollController.position;
final isAtBottom = position.extentBefore <= readThreshold;
if (isAtBottom != atBottom.value) atBottom.value = isAtBottom;
if (position.extentAfter <= loadThreshold) {
await paginate(.backward);
} else if (contextualEvent != null &&
} else if (contextualEvent.value != null &&
position.extentBefore <= loadThreshold) {
await paginate(.forward);
} else if (position.extentBefore <= readThreshold) {
@ -139,7 +146,15 @@ final class ChatScroll({
WidgetsBinding.instance.addPostFrameCallback((_) => checkPosition());
return () => scrollController.removeListener(checkPosition);
}, [scrollController, controllerData, paginate, markRead, contextualEvent]);
},
[
scrollController,
controllerData,
paginate,
markRead,
contextualEvent.value,
],
);
double? resolveOffset(String itemId) {
final historyIndex = split.history.indexWhere(
@ -177,7 +192,7 @@ final class ChatScroll({
final completer = Completer<BuildContext>();
anchorMountedCompleter.value = completer;
pendingAnchorTarget.value = itemId;
setContextualEvent(itemId);
contextualEvent.value = itemId;
final context = await completer.future;
@ -191,6 +206,21 @@ final class ChatScroll({
}
}
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(
historyItems: split.history,
liveItems: split.live,
@ -199,7 +229,9 @@ final class ChatScroll({
historyListController: historyListController,
liveListController: liveListController,
scrollController: scrollController,
atBottom: atBottom.value,
jumpToId: jumpToId,
jumpToBottom: jumpToBottom,
);
}
}

View file

@ -74,8 +74,7 @@ final class const RoomChat({
final scroll = ChatScroll.use(
controllerData: controllerData,
paginate: notifier.paginate,
contextualEvent: contextualEvent.value,
setContextualEvent: (id) => contextualEvent.value = id,
contextualEvent: contextualEvent,
markRead: () async {
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]),
@ -159,6 +158,21 @@ final class const RoomChat({
),
),
),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: const Duration(milliseconds: 200),
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
),
),
),
Positioned(
bottom: 0,
left: 0,