Skip to content

Commit 5abeb88

Browse files
sirpengignprice
authored andcommitted
msglist: Add a scroll-to-bottom button
1 parent 38ed6c8 commit 5abeb88

File tree

2 files changed

+200
-3
lines changed

2 files changed

+200
-3
lines changed

lib/widgets/message_list.dart

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:math';
2+
13
import 'package:flutter/material.dart';
24
import 'package:intl/intl.dart';
35

@@ -58,7 +60,6 @@ class _MessageListPageState extends State<MessageListPage> {
5860

5961
child: Expanded(
6062
child: MessageList(narrow: widget.narrow))),
61-
6263
ComposeBox(controllerKey: _composeBoxKey, narrow: widget.narrow),
6364
]))));
6465
}
@@ -97,7 +98,6 @@ class MessageListAppBarTitle extends StatelessWidget {
9798
}
9899
}
99100

100-
101101
class MessageList extends StatefulWidget {
102102
const MessageList({super.key, required this.narrow});
103103

@@ -109,6 +109,14 @@ class MessageList extends StatefulWidget {
109109

110110
class _MessageListState extends State<MessageList> {
111111
MessageListView? model;
112+
final ScrollController scrollController = ScrollController();
113+
final ValueNotifier<bool> _scrollToBottomVisibleValue = ValueNotifier<bool>(false);
114+
115+
@override
116+
void initState() {
117+
super.initState();
118+
scrollController.addListener(_scrollChanged);
119+
}
112120

113121
@override
114122
void didChangeDependencies() {
@@ -126,6 +134,8 @@ class _MessageListState extends State<MessageList> {
126134
@override
127135
void dispose() {
128136
model?.dispose();
137+
scrollController.dispose();
138+
_scrollToBottomVisibleValue.dispose();
129139
super.dispose();
130140
}
131141

@@ -142,6 +152,23 @@ class _MessageListState extends State<MessageList> {
142152
});
143153
}
144154

155+
void _adjustButtonVisibility(ScrollMetrics scrollMetrics) {
156+
if (scrollMetrics.extentBefore == 0) {
157+
_scrollToBottomVisibleValue.value = false;
158+
} else {
159+
_scrollToBottomVisibleValue.value = true;
160+
}
161+
}
162+
163+
void _scrollChanged() {
164+
_adjustButtonVisibility(scrollController.position);
165+
}
166+
167+
bool _metricsChanged(ScrollMetricsNotification scrollMetricsNotification) {
168+
_adjustButtonVisibility(scrollMetricsNotification.metrics);
169+
return true;
170+
}
171+
145172
@override
146173
Widget build(BuildContext context) {
147174
assert(model != null);
@@ -161,7 +188,18 @@ class _MessageListState extends State<MessageList> {
161188
child: Center(
162189
child: ConstrainedBox(
163190
constraints: const BoxConstraints(maxWidth: 760),
164-
child: _buildListView(context))))));
191+
child: NotificationListener<ScrollMetricsNotification>(
192+
onNotification: _metricsChanged,
193+
child: Stack(
194+
children: <Widget>[
195+
_buildListView(context),
196+
Positioned(
197+
bottom: 0,
198+
right: 0,
199+
child: ScrollToBottomButton(
200+
scrollController: scrollController,
201+
visibleValue: _scrollToBottomVisibleValue)),
202+
])))))));
165203
}
166204

167205
Widget _buildListView(context) {
@@ -179,6 +217,7 @@ class _MessageListState extends State<MessageList> {
179217
_ => ScrollViewKeyboardDismissBehavior.manual,
180218
},
181219

220+
controller: scrollController,
182221
itemCount: length,
183222
// Setting reverse: true means the scroll starts at the bottom.
184223
// Flipping the indexes (in itemBuilder) means the start/bottom
@@ -194,6 +233,39 @@ class _MessageListState extends State<MessageList> {
194233
}
195234
}
196235

236+
class ScrollToBottomButton extends StatelessWidget {
237+
const ScrollToBottomButton({super.key, required this.scrollController, required this.visibleValue});
238+
239+
final ValueNotifier<bool> visibleValue;
240+
final ScrollController scrollController;
241+
242+
Future<void> _navigateToBottom() async {
243+
final distance = scrollController.position.pixels;
244+
final durationMsAtSpeedLimit = (1000 * distance / 8000).ceil();
245+
final durationMs = max(300, durationMsAtSpeedLimit);
246+
scrollController.animateTo(
247+
0,
248+
duration: Duration(milliseconds: durationMs),
249+
curve: Curves.ease);
250+
}
251+
252+
@override
253+
Widget build(BuildContext context) {
254+
return ValueListenableBuilder<bool>(
255+
valueListenable: visibleValue,
256+
builder: (BuildContext context, bool value, Widget? child) {
257+
return (value && child != null) ? child : const SizedBox.shrink();
258+
},
259+
// TODO: fix hardcoded values for size and style here
260+
child: IconButton(
261+
tooltip: "Scroll to bottom",
262+
icon: const Icon(Icons.expand_circle_down_rounded),
263+
iconSize: 40,
264+
color: const HSLColor.fromAHSL(0.5,240,0.96,0.68).toColor(),
265+
onPressed: _navigateToBottom));
266+
}
267+
}
268+
197269
class MessageItem extends StatelessWidget {
198270
const MessageItem({
199271
super.key,

test/widgets/message_list_test.dart

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:zulip/api/model/model.dart';
5+
import 'package:zulip/api/route/messages.dart';
6+
import 'package:zulip/model/narrow.dart';
7+
import 'package:zulip/widgets/message_list.dart';
8+
import 'package:zulip/widgets/sticky_header.dart';
9+
import 'package:zulip/widgets/store.dart';
10+
11+
import '../api/fake_api.dart';
12+
import '../example_data.dart' as eg;
13+
import '../model/binding.dart';
14+
15+
Future<void> setupMessageListPage(WidgetTester tester, {
16+
required Narrow narrow,
17+
}) async {
18+
addTearDown(TestZulipBinding.instance.reset);
19+
addTearDown(tester.view.resetPhysicalSize);
20+
21+
tester.view.physicalSize = const Size(600, 800);
22+
23+
await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot());
24+
final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id);
25+
final connection = store.connection as FakeApiConnection;
26+
27+
// prepare message list data
28+
final List<StreamMessage> messages = List.generate(10, (index) {
29+
return eg.streamMessage(id: index);
30+
});
31+
connection.prepare(json: GetMessagesResult(
32+
anchor: messages[0].id,
33+
foundNewest: true,
34+
foundOldest: true,
35+
foundAnchor: true,
36+
historyLimited: false,
37+
messages: messages,
38+
).toJson());
39+
40+
await tester.pumpWidget(
41+
MaterialApp(
42+
home: GlobalStoreWidget(
43+
child: PerAccountStoreWidget(
44+
accountId: eg.selfAccount.id,
45+
child: MessageListPage(narrow: narrow)))));
46+
47+
// global store, per-account store, and message list get loaded
48+
await tester.pumpAndSettle();
49+
}
50+
51+
void main() {
52+
TestZulipBinding.ensureInitialized();
53+
54+
group('ScrollToBottomButton interactions', () {
55+
ScrollController? findMessageListScrollController(WidgetTester tester) {
56+
final stickyHeaderListView = tester.widget<StickyHeaderListView>(find.byType(StickyHeaderListView));
57+
return stickyHeaderListView.controller;
58+
}
59+
60+
bool isButtonVisible(WidgetTester tester) {
61+
return tester.any(find.descendant(
62+
of: find.byType(ScrollToBottomButton),
63+
matching: find.byTooltip("Scroll to bottom")));
64+
}
65+
66+
testWidgets('scrolling changes visibility', (WidgetTester tester) async {
67+
final stream = eg.stream();
68+
await setupMessageListPage(tester, narrow: StreamNarrow(stream.streamId));
69+
70+
final scrollController = findMessageListScrollController(tester)!;
71+
72+
// Initial state should be not visible, as the message list renders with latest message in view
73+
check(isButtonVisible(tester)).equals(false);
74+
75+
scrollController.jumpTo(600);
76+
await tester.pump();
77+
check(isButtonVisible(tester)).equals(true);
78+
79+
scrollController.jumpTo(0);
80+
await tester.pump();
81+
check(isButtonVisible(tester)).equals(false);
82+
});
83+
84+
testWidgets('dimension updates changes visibility', (WidgetTester tester) async {
85+
final stream = eg.stream();
86+
await setupMessageListPage(tester, narrow: StreamNarrow(stream.streamId));
87+
88+
final scrollController = findMessageListScrollController(tester)!;
89+
90+
// Initial state should be not visible, as the message list renders with latest message in view
91+
check(isButtonVisible(tester)).equals(false);
92+
93+
scrollController.jumpTo(600);
94+
await tester.pump();
95+
check(isButtonVisible(tester)).equals(true);
96+
97+
tester.view.physicalSize = const Size(2000, 40000);
98+
await tester.pump();
99+
// Dimension changes use NotificationListener<ScrollMetricsNotification
100+
// which has a one frame lag. If that ever gets resolved this extra pump
101+
// would ideally be removed
102+
await tester.pump();
103+
check(isButtonVisible(tester)).equals(false);
104+
});
105+
106+
testWidgets('button functionality', (WidgetTester tester) async {
107+
final stream = eg.stream();
108+
await setupMessageListPage(tester, narrow: StreamNarrow(stream.streamId));
109+
110+
final scrollController = findMessageListScrollController(tester)!;
111+
112+
// Initial state should be not visible, as the message list renders with latest message in view
113+
check(isButtonVisible(tester)).equals(false);
114+
115+
scrollController.jumpTo(600);
116+
await tester.pump();
117+
check(isButtonVisible(tester)).equals(true);
118+
119+
await tester.tap(find.byType(ScrollToBottomButton));
120+
await tester.pumpAndSettle();
121+
check(isButtonVisible(tester)).equals(false);
122+
check(scrollController.position.pixels).equals(0);
123+
});
124+
});
125+
}

0 commit comments

Comments
 (0)