Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lightbox: Prevent hero animation between message lists #1348

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

lakshya1goel
Copy link
Contributor

Fixes: #930

Videos

Before

WhatsApp.Video.2025-02-12.at.5.18.39.PM.mp4

After

WhatsApp.Video.2025-02-12.at.5.18.35.PM.mp4

@lakshya1goel lakshya1goel marked this pull request as draft February 12, 2025 13:36
@lakshya1goel lakshya1goel marked this pull request as ready for review February 15, 2025 16:01
@lakshya1goel lakshya1goel changed the title msglist: Prevent hero animation between message lists lightbox: Prevent hero animation between message lists Feb 15, 2025
@gnprice
Copy link
Member

gnprice commented Feb 15, 2025

Thanks! Chat thread here:
https://chat.zulip.org/#narrow/channel/516-mobile-dev-help/topic/Hero.20animation/near/2092791
with comments particularly on how to write a good test for this fix.

@PIG208 PIG208 self-assigned this Feb 18, 2025
@PIG208 PIG208 added the maintainer review PR ready for review by Zulip maintainers label Feb 18, 2025
@PIG208 PIG208 self-requested a review February 21, 2025 21:55
@lakshya1goel
Copy link
Contributor Author

lakshya1goel commented Feb 22, 2025

Hi @PIG208, PR is ready for a review now, PTAL, Thanks!
Related CZO Discussion

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for fixing this! The approach looks fine to me. Left a comment on the implementation and some more on the tests.

@@ -21,20 +21,22 @@ import 'store.dart';
// fly to an image preview with a different URL, following a message edit
// while the lightbox was open.
class _LightboxHeroTag {
_LightboxHeroTag({required this.messageId, required this.src});
_LightboxHeroTag({required this.messageId, required this.src, required this.pageContext});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Wrap this because the parameter list gets a bit too wide.


@override
Widget build(BuildContext context) {
return Hero(
tag: _LightboxHeroTag(messageId: message.id, src: src),
tag: _LightboxHeroTag(messageId: message.id, src: src, pageContext: pageContext),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do things go wrong if we get the page context from PageRoot.contextOf(context) here? If that works we don't need to pass it around.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Things would go wrong since using PageRoot.contextOf(context) here would break the hero animation because the widget exists in two different page contexts simultaneously in the MessageListPage and the lightbox. By explicitly passing the source page's context (pageContext), we ensure both the source and destination heroes use the same context value in their tags, allowing Flutter to properly match them for the animation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. Thanks for the explanation.

Narrow narrow = const CombinedFeedNarrow(),
List<Message>? messages,
List<ZulipStream>? streams,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: extra empty line

Comment on lines 248 to 230
// ZulipApp instead of TestZulipApp because we need:
// 1. The navigator to push the lightbox route. The lightbox page works
// together with the route; it takes the route's entrance animation.
// 2. The PageRoot widget to provide context for Hero animations between
// the message list and lightbox.
await tester.pumpWidget(PageRoot(
child: const ZulipApp()
));
await tester.pump();
final navigator = await ZulipApp.navigator;
unawaited(navigator.push(getImageLightboxRoute(
accountId: eg.selfAccount.id,
pageContext: PageRoot.contextOf(navigator.context),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be able to skip these changes if not having pageContext as a parameter works.

Copy link
Contributor Author

@lakshya1goel lakshya1goel Feb 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can't skip passing pageContext because during the hero animation, the transitioning widget lives in Flutter's overlay layer outside our PageRoot hierarchy. The hero animation needs the same tag value at both ends (source and destination) to know which widgets to animate between, and since we can't get the PageRoot context during the transition, we must pass it explicitly.


group('LightboxHero', () {
testWidgets('no hero animation occurs between different message list pages for same image', (tester) async {
final channel = eg.stream(streamId: eg.defaultStreamMessageStreamId);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the value of streamId is not used later, we can skip specifying. Using the default value should help keep the setup boring and bring focus to what's more interesting.

await tester.pumpAndSettle();
});
});
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need a newline at the end of the file.

Comment on lines 623 to 617
await tester.pump(const Duration(milliseconds: 150));

final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition.top).equals(initialImageRect.top);
check(imageInTransition.left).equals(initialImageRect.left);
await tester.pumpAndSettle();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this supposed to be mid transition time? While this works, I feel that it can miss bugs if the duration somehow changes.

We can have a while-loop that checks tester.hasRunningAnimations, which pumps repeatedly with a fixed duration, and repeats the check:

Suggested change
await tester.pump(const Duration(milliseconds: 150));
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition.top).equals(initialImageRect.top);
check(imageInTransition.left).equals(initialImageRect.left);
await tester.pumpAndSettle();
int timeElapsed = 0;
const interval = 50;
while (timeElapsed < interval || tester.hasRunningAnimations) {
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition.top).equals(initialImageRect.top);
check(imageInTransition.left).equals(initialImageRect.left);
await tester.pump(const Duration(milliseconds: interval));
timeElapsed += interval;
}
check(timeElapsed).isGreaterOrEqual(interval);

Copy link
Member

@PIG208 PIG208 Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just saw the CZO comment:

In order to have that benefit, it's important to keep the two test cases similar to each other as far as possible. It should be easy for the reader to convince themself that the two tests are checking the same thing, and just expecting opposite outcomes.

I agree that having these two tests similar to each other does mitigate the concern of one of them not working properly. So it's probably also fine to leave them as-is. Either way, we should comment that the duration is specifically chosen such that we are in the middle of a hero animation (if there is one).

late PerAccountStore store;
late FakeApiConnection connection;

Future<void> setupMessageListPage(WidgetTester tester, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep this helper close to where it is used in the 'LightBoxHero' group.

Comment on lines 212 to 213
List<Message>? messages,
List<ZulipStream>? streams,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because none of the two callers actually rely on having different messages/channels, it looks like we can remove these parameters. An advantage of keeping the helper close to where it's used is that we can more easily spot what properties the tests rely on.

For things that are constant to the tests (like the topic name, finders), we can have a shared local variable within the group.

Comment on lines 613 to 588
connection.prepare(json:
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: move this to the line just before tester.tap, because it prepares an API response for that

await tester.pump(const Duration(milliseconds: 150));

final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition.top).not((it) => it.equals(initialImageRect.top));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I missed this in the previous review — we can further change this to

check(imageInTransition).top.not((it) => it.equals(initialImageRect.top));

@lakshya1goel
Copy link
Contributor Author

Pushed the revision, PTAL @PIG208. Thanks!

Copy link
Member

@PIG208 PIG208 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update! I found a place where we can potentially shake off the dependency on PageRoot. Let me know what you think!

Comment on lines 580 to 581
final message = eg.streamMessage(stream: channel,
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: indentation

Suggested change
final message = eg.streamMessage(stream: channel,
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');
final message = eg.streamMessage(stream: channel,
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');

Comment on lines 590 to 585

await store.addUser(eg.selfUser);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
await store.addUser(eg.selfUser);
await store.addUser(eg.selfUser);

because store.addUser is a part of the setup code stanza

@@ -663,12 +665,14 @@ class MessageImage extends StatelessWidget {
src: resolvedSrcUrl,
thumbnailUrl: resolvedThumbnailUrl,
originalWidth: node.originalWidth,
originalHeight: node.originalHeight));
originalHeight: node.originalHeight,
pageContext: pageContext));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While testing this, I tried passing context to pageContext (and below), and it appears to work. Let's try this so that we don't need to rely on having a PageRoot.

@lakshya1goel
Copy link
Contributor Author

Pushed the revision @PIG208, please have a look. Thanks!

@PIG208
Copy link
Member

PIG208 commented Mar 3, 2025

Thanks! I think because we are not using the context of PageRoot, there is probably a better name than pageConext. (Maybe messageImageContext?) Marking this for Greg's review.

@PIG208 PIG208 added integration review Added by maintainers when PR may be ready for integration and removed maintainer review PR ready for review by Zulip maintainers labels Mar 3, 2025
@PIG208 PIG208 assigned gnprice and unassigned PIG208 Mar 3, 2025
@PIG208 PIG208 requested review from gnprice and removed request for PIG208 March 3, 2025 18:34
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @lakshya1goel for taking this on, and @PIG208 for the previous reviews! Comments below.

Comment on lines 614 to 617
required Uri? thumbnailUrl,
required double? originalWidth,
required double? originalHeight,
required BuildContext pageContext,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a function has a bunch of parameters like this (or a class has a bunch of fields, etc.), it's important to keep them organized logically — that makes a real difference both for people trying to understand and make changes to the implementation of the function (or class etc.) itself, and for people trying to use it.

So that means new parameters should go in the position that makes the structure of the list make sense, not necessarily at the end of the list.

What are existing parameters on this function that this new one does a similar job to?


final int messageId;
final Uri src;
final BuildContext pageContext;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to Zixuan's point above at #1348 (comment) about the name of this field, let's also give it a bit of dartdoc explaining what it's expected to be. I think that will help with thinking through the behavior.

Comment on lines 21 to 22
// fly to an image preview with a different URL, following a message edit
// while the lightbox was open.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment points out one of the scenarios that can make it complicated to get these tags right — a message can be edited while the user has one of its images open in the lightbox. How does this version behave if that happens?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current implementation handles this scenario well. When a message is edited while its image is open in the lightbox, the behavior depends on whether the image URL changes:

  • If the edit changes the image URL:
    The Hero animation won't find a matching tag (because src is part of the tag and has changed)
    It will gracefully fall back to a fade transition instead of attempting to animate to the wrong image
  • If the edit doesn't change the image URL:
    All three tag components (messageId, src, and messageImageContext) still match
    The Hero animation will work normally

@@ -558,4 +566,76 @@ void main() {
check(platform.position).equals(position);
});
});

group('LightboxHero', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: keep tests in an order matching the order of the code they're testing

This is really another example of the same point as the comment above about function parameters — when adding something new, take a moment to think about the most logical place for it to appear, rather than just putting it at the end.

Comment on lines 614 to 616
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition).top.equals(initialImageRect.top);
check(imageInTransition).left.equals(initialImageRect.left);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given imageFinder = find.byType(RealmContentNetworkImage).first, this doesn't make an entirely convincing check that there isn't a hero animation going on. This effectively says that the original image is still the first RealmContentNetworkImage in the tree — but if there were a hero animation going on, there's no reason that necessarily has to be the first image in the tree. Maybe it comes later in the tree than the original image.

Comment on lines 634 to 636
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition).top.not((it) => it.equals(initialImageRect.top));
check(imageInTransition).left.not((it) => it.equals(initialImageRect.left));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, this says that the first RealmContentNetworkImage in the tree isn't at the place the original image was. But

  • there's the new route, the lightbox, which has a version of the image — maybe that's the first one in the tree now
  • there's the old route, the message list, which may have a version of the image — and although right now that won't have moved, maybe in the future we'll use a different navigation transition here so that the old route moves away while the new one is moving in. Again maybe that's the first one in the tree now.

So this should get more specific about our expectations in order to be convincing that it wouldn't end up passing even in some future where we've broken the hero animation entirely.

@lakshya1goel lakshya1goel force-pushed the issue930 branch 2 times, most recently from 96d038a to 7640e48 Compare March 9, 2025 15:56
@lakshya1goel lakshya1goel requested a review from gnprice March 9, 2025 15:59
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision — comments below.

For detailed how-to discussion, let's use #mobile-dev-help > Hero animation, since Zulip will be better for discussion than GitHub is.

Comment on lines 613 to 627
/// The [BuildContext] of the image in the message list that's being expanded
/// into the lightbox. Used to coordinate the Hero animation between this specific
/// image and the lightbox view.
///
/// This helps ensure the animation only happens between the correct image instances,
/// preventing unwanted animations between different message lists or between
/// different images that happen to have the same URL.
required BuildContext messageImageContext,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't function as dartdoc — try going to a call site of this function and hovering on either the function name, or this parameter, and you'll see that this text doesn't appear.

For documenting a function parameter, use dartdoc on the function.

For this particular data, I think the best home for the details of what it means is on the field on _LightboxHeroTag, as I suggested at #1348 (comment) . Then on this function one can point there for details.

@@ -300,6 +308,100 @@ void main() {
// https://github.com/zulip/zulip-flutter/pull/833#issuecomment-2251782337
});

group('LightboxHero', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per #1348 (comment) , if the idea of these tests is to test LightboxHero, then they should go before the tests for _ImageLightboxPage, to match the fact that LightboxHero's definition comes before the definition of _ImageLightboxPage.

Comment on lines 30 to 44
final int messageId;
final Uri src;
final BuildContext messageImageContext;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: have these three fields appear in the same order on this class as they do in the other places they appear together

Comment on lines 359 to 368
final imageRects = tester.widgetList(allImages).map((widget) {
final finder = find.byWidget(widget);
return tester.getRect(finder);
}).toList();

check(imageRects).isNotEmpty();
check(imageRects.any((rect) =>
rect.top != initialImageRect.top ||
rect.left != initialImageRect.left
)).isTrue();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you describe what condition this verifies is true, in terms that reflect what the user sees on the screen?

I believe this is less specific than the previous revision discussed at #1348 (comment), not more. That is, I believe this will pass in all the same situations where the previous revision would have passed, plus some additional situations.

Comment on lines 397 to 399
final imageInTransition = tester.getRect(imageFinder);
check(imageInTransition).top.equals(initialImageRect.top);
check(imageInTransition).left.equals(initialImageRect.left);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is also less specific than the previous revision. In fact this one no longer tests anything about the hero animation at all: even if you edit the code so that the tags are completely defeated and the hero animation happens all the time:

   bool operator ==(Object other) {
-    return other is _LightboxHeroTag &&
-      other.messageId == messageId &&
-      other.src == src &&
-      other.messageImageContext == messageImageContext;
+    return true;
   }
 
   @override
-  int get hashCode => Object.hash('_LightboxHeroTag', messageId, src, messageImageContext);
+  int get hashCode => '_LightboxHeroTag'.hashCode;

this test still passes.

Like with the other test case, can you describe what condition this verifies is true, in terms that reflect what the user sees on the screen?

@lakshya1goel
Copy link
Contributor Author

Hi @gnprice, I have pushed the revision, please take a look. Thanks!

@gnprice
Copy link
Member

gnprice commented Mar 21, 2025

Thanks! Chat thread here about the tests: #mobile-dev-help > Hero animation @ 💬

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Hero animation for images should run only to/from lightbox, not between message lists
3 participants