Skip to content

Commit f60ef5b

Browse files
committed
msglist: Maintain _UnreadMarker animation state when MessageListView.items changes size
1 parent c565387 commit f60ef5b

File tree

2 files changed

+73
-0
lines changed

2 files changed

+73
-0
lines changed

lib/widgets/message_list.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,27 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
255255
_ => ScrollViewKeyboardDismissBehavior.manual,
256256
},
257257

258+
// To preserve state across rebuilds for individual [MessageItem]
259+
// widgets as the size of [MessageListView.items] changes we need
260+
// to match old widgets by their key to their new position in
261+
// the list.
262+
//
263+
// The keys are of type [ValueKey] with a value of [Message.id]
264+
// and here we use a O(log n) binary search method. This could
265+
// be improved but for now it only triggers for materialized
266+
// widgets. As a simple test, flinging through All Messages in
267+
// CZO on a Pixel 5, this only runs about 10 times per rebuild
268+
// and the timing for each call is <100 microseconds.
269+
//
270+
// Non-message items (e.g., start and end markers) that do not
271+
// have state that needs to be preserved have not been given keys
272+
// and will not trigger this callback.
273+
findChildIndexCallback: (Key key) {
274+
final valueKey = key as ValueKey;
275+
final index = model!.findItemWithMessageId(valueKey.value);
276+
if (index == -1) return null;
277+
return length - 1 - index;
278+
},
258279
controller: scrollController,
259280
itemCount: length,
260281
// Setting reverse: true means the scroll starts at the bottom.

test/widgets/message_list_test.dart

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,5 +356,57 @@ void main() {
356356
..value.equals(0.0)
357357
..status.equals(AnimationStatus.completed);
358358
});
359+
360+
testWidgets('animation state persistence', (WidgetTester tester) async {
361+
// Check that _UnreadMarker maintains its in-progress animation
362+
// as the number of items changes in MessageList. See
363+
// `findChildIndexCallback` passed into [StickyHeaderListView.builder]
364+
// at [_MessageListState._buildListView].
365+
final message = eg.streamMessage(flags: []);
366+
await setupMessageListPage(tester, messages: [message]);
367+
check(getAnimation(tester, message.id))
368+
..value.equals(1.0)
369+
..status.equals(AnimationStatus.dismissed);
370+
371+
store.handleEvent(UpdateMessageFlagsAddEvent(
372+
id: 0,
373+
flag: MessageFlag.read,
374+
messages: [message.id],
375+
all: false,
376+
));
377+
await tester.pump(); // process handleEvent
378+
check(getAnimation(tester, message.id))
379+
..value.equals(1.0)
380+
..status.equals(AnimationStatus.forward);
381+
382+
// run animation partially
383+
await tester.pump(const Duration(milliseconds: 30));
384+
check(getAnimation(tester, message.id))
385+
..value.isGreaterThan(0.0)
386+
..value.isLessThan(1.0)
387+
..status.equals(AnimationStatus.forward);
388+
389+
// introduce new message
390+
final newMessage = eg.streamMessage(flags:[MessageFlag.read]);
391+
store.handleEvent(MessageEvent(id: 0, message: newMessage));
392+
await tester.pump(); // process handleEvent
393+
check(find.byType(MessageItem).evaluate()).length.equals(2);
394+
check(getAnimation(tester, message.id))
395+
..value.isGreaterThan(0.0)
396+
..value.isLessThan(1.0)
397+
..status.equals(AnimationStatus.forward);
398+
check(getAnimation(tester, newMessage.id))
399+
..value.equals(0.0)
400+
..status.equals(AnimationStatus.dismissed);
401+
402+
final frames = await tester.pumpAndSettle();
403+
check(frames).isGreaterThan(1);
404+
check(getAnimation(tester, message.id))
405+
..value.equals(0.0)
406+
..status.equals(AnimationStatus.completed);
407+
check(getAnimation(tester, newMessage.id))
408+
..value.equals(0.0)
409+
..status.equals(AnimationStatus.dismissed);
410+
});
359411
});
360412
}

0 commit comments

Comments
 (0)