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

View file

@ -74,8 +74,7 @@ final class const RoomChat({
final scroll = ChatScroll.use( final scroll = ChatScroll.use(
controllerData: controllerData, controllerData: controllerData,
paginate: notifier.paginate, paginate: notifier.paginate,
contextualEvent: contextualEvent.value, contextualEvent: contextualEvent,
setContextualEvent: (id) => contextualEvent.value = id,
markRead: () async { markRead: () async {
final room = ref.read( final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]), 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( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,