Skip to content

Commit b1ba656

Browse files
committed
msglist: Maintain _UnreadMarker animation state when MessageListView.items changes size
1 parent 210ba26 commit b1ba656

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

0 commit comments

Comments
 (0)