diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index ee157847b3..ed077b9e77 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -58,7 +60,6 @@ class _MessageListPageState extends State { child: Expanded( child: MessageList(narrow: widget.narrow))), - ComposeBox(controllerKey: _composeBoxKey, narrow: widget.narrow), ])))); } @@ -97,7 +98,6 @@ class MessageListAppBarTitle extends StatelessWidget { } } - class MessageList extends StatefulWidget { const MessageList({super.key, required this.narrow}); @@ -109,6 +109,14 @@ class MessageList extends StatefulWidget { class _MessageListState extends State { MessageListView? model; + final ScrollController scrollController = ScrollController(); + final ValueNotifier _scrollToBottomVisibleValue = ValueNotifier(false); + + @override + void initState() { + super.initState(); + scrollController.addListener(_scrollChanged); + } @override void didChangeDependencies() { @@ -126,6 +134,8 @@ class _MessageListState extends State { @override void dispose() { model?.dispose(); + scrollController.dispose(); + _scrollToBottomVisibleValue.dispose(); super.dispose(); } @@ -142,6 +152,23 @@ class _MessageListState extends State { }); } + void _adjustButtonVisibility(ScrollMetrics scrollMetrics) { + if (scrollMetrics.extentBefore == 0) { + _scrollToBottomVisibleValue.value = false; + } else { + _scrollToBottomVisibleValue.value = true; + } + } + + void _scrollChanged() { + _adjustButtonVisibility(scrollController.position); + } + + bool _metricsChanged(ScrollMetricsNotification scrollMetricsNotification) { + _adjustButtonVisibility(scrollMetricsNotification.metrics); + return true; + } + @override Widget build(BuildContext context) { assert(model != null); @@ -161,7 +188,18 @@ class _MessageListState extends State { child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), - child: _buildListView(context)))))); + child: NotificationListener( + onNotification: _metricsChanged, + child: Stack( + children: [ + _buildListView(context), + Positioned( + bottom: 0, + right: 0, + child: ScrollToBottomButton( + scrollController: scrollController, + visibleValue: _scrollToBottomVisibleValue)), + ]))))))); } Widget _buildListView(context) { @@ -179,6 +217,7 @@ class _MessageListState extends State { _ => ScrollViewKeyboardDismissBehavior.manual, }, + controller: scrollController, itemCount: length, // Setting reverse: true means the scroll starts at the bottom. // Flipping the indexes (in itemBuilder) means the start/bottom @@ -194,6 +233,39 @@ class _MessageListState extends State { } } +class ScrollToBottomButton extends StatelessWidget { + const ScrollToBottomButton({super.key, required this.scrollController, required this.visibleValue}); + + final ValueNotifier visibleValue; + final ScrollController scrollController; + + Future _navigateToBottom() async { + final distance = scrollController.position.pixels; + final durationMsAtSpeedLimit = (1000 * distance / 8000).ceil(); + final durationMs = max(300, durationMsAtSpeedLimit); + scrollController.animateTo( + 0, + duration: Duration(milliseconds: durationMs), + curve: Curves.ease); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: visibleValue, + builder: (BuildContext context, bool value, Widget? child) { + return (value && child != null) ? child : const SizedBox.shrink(); + }, + // TODO: fix hardcoded values for size and style here + child: IconButton( + tooltip: "Scroll to bottom", + icon: const Icon(Icons.expand_circle_down_rounded), + iconSize: 40, + color: const HSLColor.fromAHSL(0.5,240,0.96,0.68).toColor(), + onPressed: _navigateToBottom)); + } +} + class MessageItem extends StatelessWidget { const MessageItem({ super.key, diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart new file mode 100644 index 0000000000..e0a77369a6 --- /dev/null +++ b/test/widgets/message_list_test.dart @@ -0,0 +1,125 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/widgets/message_list.dart'; +import 'package:zulip/widgets/sticky_header.dart'; +import 'package:zulip/widgets/store.dart'; + +import '../api/fake_api.dart'; +import '../example_data.dart' as eg; +import '../model/binding.dart'; + +Future setupMessageListPage(WidgetTester tester, { + required Narrow narrow, +}) async { + addTearDown(TestZulipBinding.instance.reset); + addTearDown(tester.view.resetPhysicalSize); + + tester.view.physicalSize = const Size(600, 800); + + await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); + final connection = store.connection as FakeApiConnection; + + // prepare message list data + final List messages = List.generate(10, (index) { + return eg.streamMessage(id: index); + }); + connection.prepare(json: GetMessagesResult( + anchor: messages[0].id, + foundNewest: true, + foundOldest: true, + foundAnchor: true, + historyLimited: false, + messages: messages, + ).toJson()); + + await tester.pumpWidget( + MaterialApp( + home: GlobalStoreWidget( + child: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: MessageListPage(narrow: narrow))))); + + // global store, per-account store, and message list get loaded + await tester.pumpAndSettle(); +} + +void main() { + TestZulipBinding.ensureInitialized(); + + group('ScrollToBottomButton interactions', () { + ScrollController? findMessageListScrollController(WidgetTester tester) { + final stickyHeaderListView = tester.widget(find.byType(StickyHeaderListView)); + return stickyHeaderListView.controller; + } + + bool isButtonVisible(WidgetTester tester) { + return tester.any(find.descendant( + of: find.byType(ScrollToBottomButton), + matching: find.byTooltip("Scroll to bottom"))); + } + + testWidgets('scrolling changes visibility', (WidgetTester tester) async { + final stream = eg.stream(); + await setupMessageListPage(tester, narrow: StreamNarrow(stream.streamId)); + + final scrollController = findMessageListScrollController(tester)!; + + // Initial state should be not visible, as the message list renders with latest message in view + check(isButtonVisible(tester)).equals(false); + + scrollController.jumpTo(600); + await tester.pump(); + check(isButtonVisible(tester)).equals(true); + + scrollController.jumpTo(0); + await tester.pump(); + check(isButtonVisible(tester)).equals(false); + }); + + testWidgets('dimension updates changes visibility', (WidgetTester tester) async { + final stream = eg.stream(); + await setupMessageListPage(tester, narrow: StreamNarrow(stream.streamId)); + + final scrollController = findMessageListScrollController(tester)!; + + // Initial state should be not visible, as the message list renders with latest message in view + check(isButtonVisible(tester)).equals(false); + + scrollController.jumpTo(600); + await tester.pump(); + check(isButtonVisible(tester)).equals(true); + + tester.view.physicalSize = const Size(2000, 40000); + await tester.pump(); + // Dimension changes use NotificationListener