Skip to content

Commit cac4c5a

Browse files
committed
msglist: Maintain _UnreadMarker animation state when MessageListView.items changes size
1 parent 9e173cc commit cac4c5a

File tree

2 files changed

+72
-0
lines changed

2 files changed

+72
-0
lines changed

lib/widgets/message_list.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,30 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
244244
Widget _buildListView(context) {
245245
final length = model!.items.length;
246246
return StickyHeaderListView.builder(
247+
findChildIndexCallback: (Key key) {
248+
// To preserve state across rebuilds for individual [MessageItem]
249+
// widgets as the size of [MessageListView.items] changes we need
250+
// to match old widgets by their key to their new position in
251+
// the list.
252+
//
253+
// The keys are of type [ValueKey] with a value of [Message.id]
254+
// and here we use a O(log n) binary search method. This could
255+
// be improved but for now it only triggers for materialized
256+
// widgets. As a simple test, flinging through All Messages in
257+
// CZO on a Pixel 5, this only runs about 10 times per rebuild
258+
// and the timing for each call is <100 microseconds.
259+
//
260+
// Non-message items (e.g., start and end markers) that do not
261+
// have state that needs to be preserved have not been given keys
262+
// and will not trigger this callback.
263+
final valueKey = key as ValueKey;
264+
final index = model!.findItemWithMessageId(valueKey.value);
265+
if (index == -1) {
266+
return null;
267+
}
268+
// Note `itemBuilder` pulls from `model.items` in reverse order.
269+
return length - 1 - index;
270+
},
247271
// TODO: Offer `ScrollViewKeyboardDismissBehavior.interactive` (or
248272
// similar) if that is ever offered:
249273
// https://github.com/flutter/flutter/issues/57609#issuecomment-1355340849
@@ -283,6 +307,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat
283307
header: header, child: header);
284308
case MessageListMessageItem():
285309
return MessageItem(
310+
key: ValueKey(data.message.id),
286311
trailing: i == 0 ? const SizedBox(height: 8) : const SizedBox(height: 11),
287312
item: data);
288313
}

test/widgets/message_list_test.dart

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,5 +344,52 @@ void main() {
344344

345345
check(getAnimation(tester).value).equals(0);
346346
});
347+
348+
testWidgets('animation state persistence', (WidgetTester tester) async {
349+
// Check that _UnreadMarker maintains its in-progress animation
350+
// as the number of items change in MessageList.
351+
352+
// TODO: pull this out into group
353+
Animation getAnimation(WidgetTester tester, int messageId) {
354+
final widget = tester.widget<FadeTransition>(find.descendant(
355+
of: find.byKey(ValueKey(messageId)),
356+
matching: find.byType(FadeTransition)));
357+
return widget.opacity;
358+
}
359+
360+
final message = eg.streamMessage(id: 1, flags: []);
361+
await setupMessageListPage(tester, messages: [message]);
362+
363+
// initial state: marker is visible
364+
check(getAnimation(tester, message.id).value).equals(1.0);
365+
366+
store.handleEvent(UpdateMessageFlagsAddEvent(
367+
id: 0,
368+
flag: MessageFlag.read,
369+
messages: [message.id],
370+
all: false,
371+
));
372+
await tester.pump(); // process handleEvent
373+
await tester.pump(const Duration(milliseconds: 30)); // run animation partially
374+
375+
final newMessage = eg.streamMessage(id: 2, flags:[MessageFlag.read]);
376+
store.handleEvent(MessageEvent(id: 0, message: newMessage));
377+
await tester.pump(); // process handleEvent
378+
379+
// in-progress state: original marker is still
380+
// animating but new marker is not
381+
check(find.byType(MessageItem).evaluate()).length.equals(2);
382+
check(getAnimation(tester, message.id).status)
383+
.equals(AnimationStatus.forward);
384+
check(getAnimation(tester, newMessage.id).status)
385+
.equals(AnimationStatus.dismissed);
386+
387+
// If we introduce an animation bug (remove findChildIndexCallback
388+
// on StickyHeaderListView.builder) this returns 1.
389+
final frames = await tester.pumpAndSettle();
390+
check(frames).isGreaterThan(1);
391+
check(getAnimation(tester, message.id).status)
392+
.equals(AnimationStatus.completed);
393+
});
347394
});
348395
}

0 commit comments

Comments
 (0)