Skip to content

Commit 13e890c

Browse files
committed
inbox: Fix collapsing a section from its sticky header
Before this fix, when scrolled down through the inbox page, collapsing a section (either the all-DMs section or a stream section) through its sticky header would work but cause an abnormal behavior, pushing the current section header and some of the following sections off the screen by the amount of space the current section occupied. After this fix, collapsing a section through a sticky header would work as expected, without pushing the header and the following sections off the screen. Fixes: #391
1 parent 9044a9a commit 13e890c

File tree

3 files changed

+141
-4
lines changed

3 files changed

+141
-4
lines changed

lib/widgets/inbox.dart

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,11 +209,19 @@ abstract class _HeaderItem extends StatelessWidget {
209209
final int count;
210210
final bool hasMention;
211211

212+
/// A build context within the [_StreamSection] or [_AllDmsSection].
213+
///
214+
/// Used to ensure the [_StreamSection] or [_AllDmsSection] that encloses the
215+
/// current [_HeaderItem] is visible after being collapsed through this
216+
/// [_HeaderItem].
217+
final BuildContext sectionContext;
218+
212219
const _HeaderItem({
213220
required this.collapsed,
214221
required this.pageState,
215222
required this.count,
216223
required this.hasMention,
224+
required this.sectionContext,
217225
});
218226

219227
String get title;
@@ -223,7 +231,15 @@ abstract class _HeaderItem extends StatelessWidget {
223231
Color get uncollapsedBackgroundColor;
224232
Color? get unreadCountBadgeBackgroundColor;
225233

226-
void Function() get onCollapseButtonTap;
234+
Future<void> Function() get onCollapseButtonTap => () async {
235+
if (!collapsed) {
236+
await Scrollable.ensureVisible(
237+
sectionContext,
238+
alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart,
239+
);
240+
}
241+
};
242+
227243
void Function() get onRowTap;
228244

229245
@override
@@ -271,6 +287,7 @@ class _AllDmsHeaderItem extends _HeaderItem {
271287
required super.pageState,
272288
required super.count,
273289
required super.hasMention,
290+
required super.sectionContext,
274291
});
275292

276293
@override get title => 'Direct messages'; // TODO(i18n)
@@ -280,7 +297,8 @@ class _AllDmsHeaderItem extends _HeaderItem {
280297
@override get uncollapsedBackgroundColor => const Color(0xFFF3F0E7);
281298
@override get unreadCountBadgeBackgroundColor => null;
282299

283-
@override get onCollapseButtonTap => () {
300+
@override get onCollapseButtonTap => () async {
301+
await super.onCollapseButtonTap();
284302
pageState.allDmsCollapsed = !collapsed;
285303
};
286304
@override get onRowTap => onCollapseButtonTap; // TODO open all-DMs narrow?
@@ -304,6 +322,7 @@ class _AllDmsSection extends StatelessWidget {
304322
hasMention: data.hasMention,
305323
collapsed: collapsed,
306324
pageState: pageState,
325+
sectionContext: context,
307326
);
308327
return StickyHeaderItem(
309328
header: header,
@@ -386,6 +405,7 @@ class _StreamHeaderItem extends _HeaderItem {
386405
required super.pageState,
387406
required super.count,
388407
required super.hasMention,
408+
required super.sectionContext,
389409
});
390410

391411
@override get title => subscription.name;
@@ -397,7 +417,8 @@ class _StreamHeaderItem extends _HeaderItem {
397417
@override get unreadCountBadgeBackgroundColor =>
398418
subscription.colorSwatch().unreadCountBadgeBackground;
399419

400-
@override get onCollapseButtonTap => () {
420+
@override get onCollapseButtonTap => () async {
421+
await super.onCollapseButtonTap();
401422
if (collapsed) {
402423
pageState.uncollapseStream(subscription.streamId);
403424
} else {
@@ -427,6 +448,7 @@ class _StreamSection extends StatelessWidget {
427448
hasMention: data.hasMention,
428449
collapsed: collapsed,
429450
pageState: pageState,
451+
sectionContext: context,
430452
);
431453
return StickyHeaderItem(
432454
header: header,

test/example_data.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ final Account otherAccount = account(
119119

120120
final User thirdUser = user(fullName: 'Third User', email: 'third@example');
121121

122+
final User fourthUser = user(fullName: 'Fourth User', email: 'fourth@example');
123+
122124
////////////////////////////////////////////////////////////////
123125
// Streams and subscriptions.
124126
//

test/widgets/inbox_test.dart

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,38 @@ import '../flutter_checks.dart';
1313
import '../model/binding.dart';
1414
import '../model/test_store.dart';
1515

16+
/// Repeatedly drags `view` by `moveStep` until `finder` is invisible.
17+
///
18+
/// Between each drag, advances the clock by `duration`.
19+
///
20+
/// Throws a [StateError] if `finder` is still visible after `maxIteration`
21+
/// drags.
22+
///
23+
/// See also:
24+
/// * [WidgetController.dragUntilVisible], which does the inverse.
25+
Future<void> dragUntilInvisible(
26+
WidgetTester tester,
27+
FinderBase<Element> finder,
28+
FinderBase<Element> view,
29+
Offset moveStep, {
30+
int maxIteration = 50,
31+
Duration duration = const Duration(milliseconds: 50),
32+
}) {
33+
return TestAsyncUtils.guard<void>(() async {
34+
final iteration = maxIteration;
35+
while (maxIteration > 0 && finder.evaluate().isNotEmpty) {
36+
await tester.drag(view, moveStep);
37+
await tester.pump(duration);
38+
maxIteration -= 1;
39+
}
40+
if (maxIteration <= 0 && finder.evaluate().isNotEmpty) {
41+
throw StateError(
42+
'Finder is still visible after $iteration iterations.'
43+
' Consider increasing the number of iterations.');
44+
}
45+
});
46+
}
47+
1648
void main() {
1749
TestZulipBinding.ensureInitialized();
1850

@@ -51,6 +83,15 @@ void main() {
5183
await tester.pumpAndSettle();
5284
}
5385

86+
List<StreamMessage> generateStreamMessages({
87+
required ZulipStream stream,
88+
required int count,
89+
required List<MessageFlag> flags,
90+
}) {
91+
return List.generate(count, (index) => eg.streamMessage(
92+
stream: stream, topic: '${stream.name} topic $index', flags: flags));
93+
}
94+
5495
/// Set up an inbox view with lots of interesting content.
5596
Future<void> setupVarious(WidgetTester tester) async {
5697
final stream1 = eg.stream(streamId: 1, name: 'stream 1');
@@ -61,12 +102,16 @@ void main() {
61102
await setupPage(tester,
62103
streams: [stream1, stream2],
63104
subscriptions: [sub1, sub2],
64-
users: [eg.selfUser, eg.otherUser, eg.thirdUser],
105+
users: [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser],
65106
unreadMessages: [
66107
eg.streamMessage(stream: stream1, topic: 'specific topic', flags: []),
108+
...generateStreamMessages(stream: stream1, count: 10, flags: []),
67109
eg.streamMessage(stream: stream2, flags: []),
110+
...generateStreamMessages(stream: stream2, count: 40, flags: []),
68111
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []),
69112
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: []),
113+
eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []),
114+
eg.dmMessage(from: eg.fourthUser, to: [eg.selfUser], flags: []),
70115
]);
71116
}
72117

@@ -310,6 +355,28 @@ void main() {
310355
checkAppearsUncollapsed(tester, findSectionContent);
311356
});
312357

358+
testWidgets('collapse all-DMs section when partially offscreen: '
359+
'header remains sticky at top', (tester) async {
360+
await setupVarious(tester);
361+
362+
final listFinder = find.byType(Scrollable);
363+
final dmFinder = find.text(eg.otherUser.fullName).hitTestable();
364+
365+
// Scroll part of [_AllDmsSection] offscreen.
366+
await dragUntilInvisible(
367+
tester, dmFinder, listFinder, const Offset(0, -50));
368+
369+
// Check that the header is present (which must therefore
370+
// be as a sticky header).
371+
check(findAllDmsHeaderRow(tester)).isNotNull();
372+
373+
await tapCollapseIcon(tester);
374+
375+
// Check that the header is still visible even after
376+
// collapsing the section.
377+
check(findAllDmsHeaderRow(tester)).isNotNull();
378+
});
379+
313380
// TODO check it remains collapsed even if you scroll far away and back
314381

315382
// TODO check that it's always uncollapsed when it appears after being
@@ -385,6 +452,52 @@ void main() {
385452
checkAppearsUncollapsed(tester, 1, findSectionContent);
386453
});
387454

455+
testWidgets('collapse stream section when partially offscreen: '
456+
'header remains sticky at top', (tester) async {
457+
await setupVarious(tester);
458+
459+
final topicFinder = find.text('stream 1 topic 4').hitTestable();
460+
final listFinder = find.byType(Scrollable);
461+
462+
// Scroll part of [_StreamSection] offscreen.
463+
await dragUntilInvisible(
464+
tester, topicFinder, listFinder, const Offset(0, -50));
465+
466+
// Check that the header is present (which must therefore
467+
// be as a sticky header).
468+
check(findStreamHeaderRow(tester, 1)).isNotNull();
469+
470+
await tapCollapseIcon(tester, 1);
471+
472+
// Check that the header is still visible even after
473+
// collapsing the section.
474+
check(findStreamHeaderRow(tester, 1)).isNotNull();
475+
});
476+
477+
testWidgets('collapse stream section in middle of screen: '
478+
'header stays fixed', (tester) async {
479+
await setupVarious(tester);
480+
481+
final headerRow = findStreamHeaderRow(tester, 1);
482+
// Check that the header is present.
483+
check(headerRow).isNotNull();
484+
485+
final rectBeforeTap = tester.getRect(find.byWidget(headerRow!));
486+
final scrollableTop = tester.getRect(find.byType(Scrollable)).top;
487+
// Check that the header is somewhere in the middle of the screen.
488+
check(rectBeforeTap.top).isGreaterThan(scrollableTop);
489+
490+
await tapCollapseIcon(tester, 1);
491+
492+
final headerRowAfterTap = findStreamHeaderRow(tester, 1);
493+
final rectAfterTap =
494+
tester.getRect(find.byWidget(headerRowAfterTap!));
495+
496+
// Check that the position of the header before and after
497+
// collapsing is the same.
498+
check(rectAfterTap).equals(rectBeforeTap);
499+
});
500+
388501
// TODO check it remains collapsed even if you scroll far away and back
389502

390503
// TODO check that it's always uncollapsed when it appears after being

0 commit comments

Comments
 (0)