-
Notifications
You must be signed in to change notification settings - Fork 291
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
base: main
Are you sure you want to change the base?
Conversation
1fad781
to
bf1d607
Compare
Thanks! Chat thread here: |
bf1d607
to
decb997
Compare
decb997
to
78e44cd
Compare
Hi @PIG208, PR is ready for a review now, PTAL, Thanks! |
There was a problem hiding this 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.
lib/widgets/lightbox.dart
Outdated
@@ -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}); |
There was a problem hiding this comment.
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.
lib/widgets/lightbox.dart
Outdated
|
||
@override | ||
Widget build(BuildContext context) { | ||
return Hero( | ||
tag: _LightboxHeroTag(messageId: message.id, src: src), | ||
tag: _LightboxHeroTag(messageId: message.id, src: src, pageContext: pageContext), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: extra empty line
test/widgets/lightbox_test.dart
Outdated
// 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), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
|
||
group('LightboxHero', () { | ||
testWidgets('no hero animation occurs between different message list pages for same image', (tester) async { | ||
final channel = eg.stream(streamId: eg.defaultStreamMessageStreamId); |
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
await tester.pumpAndSettle(); | ||
}); | ||
}); | ||
} |
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
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(); |
There was a problem hiding this comment.
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:
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); |
There was a problem hiding this comment.
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).
test/widgets/lightbox_test.dart
Outdated
late PerAccountStore store; | ||
late FakeApiConnection connection; | ||
|
||
Future<void> setupMessageListPage(WidgetTester tester, { |
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
List<Message>? messages, | ||
List<ZulipStream>? streams, |
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
connection.prepare(json: | ||
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson()); |
There was a problem hiding this comment.
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
78e44cd
to
992c875
Compare
test/widgets/lightbox_test.dart
Outdated
await tester.pump(const Duration(milliseconds: 150)); | ||
|
||
final imageInTransition = tester.getRect(imageFinder); | ||
check(imageInTransition.top).not((it) => it.equals(initialImageRect.top)); |
There was a problem hiding this comment.
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));
992c875
to
df965ae
Compare
Pushed the revision, PTAL @PIG208. Thanks! |
There was a problem hiding this 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!
test/widgets/lightbox_test.dart
Outdated
final message = eg.streamMessage(stream: channel, | ||
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic'); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: indentation
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'); |
test/widgets/lightbox_test.dart
Outdated
|
||
await store.addUser(eg.selfUser); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
await store.addUser(eg.selfUser); | |
await store.addUser(eg.selfUser); |
because store.addUser
is a part of the setup code stanza
lib/widgets/content.dart
Outdated
@@ -663,12 +665,14 @@ class MessageImage extends StatelessWidget { | |||
src: resolvedSrcUrl, | |||
thumbnailUrl: resolvedThumbnailUrl, | |||
originalWidth: node.originalWidth, | |||
originalHeight: node.originalHeight)); | |||
originalHeight: node.originalHeight, | |||
pageContext: pageContext)); |
There was a problem hiding this comment.
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.
Pushed the revision @PIG208, please have a look. Thanks! |
Thanks! I think because we are not using the context of |
There was a problem hiding this 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.
lib/widgets/lightbox.dart
Outdated
required Uri? thumbnailUrl, | ||
required double? originalWidth, | ||
required double? originalHeight, | ||
required BuildContext pageContext, |
There was a problem hiding this comment.
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?
lib/widgets/lightbox.dart
Outdated
|
||
final int messageId; | ||
final Uri src; | ||
final BuildContext pageContext; |
There was a problem hiding this comment.
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.
// fly to an image preview with a different URL, following a message edit | ||
// while the lightbox was open. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
test/widgets/lightbox_test.dart
Outdated
@@ -558,4 +566,76 @@ void main() { | |||
check(platform.position).equals(position); | |||
}); | |||
}); | |||
|
|||
group('LightboxHero', () { |
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
final imageInTransition = tester.getRect(imageFinder); | ||
check(imageInTransition).top.equals(initialImageRect.top); | ||
check(imageInTransition).left.equals(initialImageRect.left); |
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
final imageInTransition = tester.getRect(imageFinder); | ||
check(imageInTransition).top.not((it) => it.equals(initialImageRect.top)); | ||
check(imageInTransition).left.not((it) => it.equals(initialImageRect.left)); |
There was a problem hiding this comment.
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.
96d038a
to
7640e48
Compare
There was a problem hiding this 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.
lib/widgets/lightbox.dart
Outdated
/// 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, |
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
@@ -300,6 +308,100 @@ void main() { | |||
// https://github.com/zulip/zulip-flutter/pull/833#issuecomment-2251782337 | |||
}); | |||
|
|||
group('LightboxHero', () { |
There was a problem hiding this comment.
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.
lib/widgets/lightbox.dart
Outdated
final int messageId; | ||
final Uri src; | ||
final BuildContext messageImageContext; |
There was a problem hiding this comment.
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
test/widgets/lightbox_test.dart
Outdated
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(); |
There was a problem hiding this comment.
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.
test/widgets/lightbox_test.dart
Outdated
final imageInTransition = tester.getRect(imageFinder); | ||
check(imageInTransition).top.equals(initialImageRect.top); | ||
check(imageInTransition).left.equals(initialImageRect.left); |
There was a problem hiding this comment.
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?
Hi @gnprice, I have pushed the revision, please take a look. Thanks! |
Thanks! Chat thread here about the tests: #mobile-dev-help > Hero animation @ 💬 |
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