Skip to content

Commit df965ae

Browse files
committed
lightbox: Prevent hero animation between message lists
Fixes: #930
1 parent e0df0ed commit df965ae

File tree

4 files changed

+115
-10
lines changed

4 files changed

+115
-10
lines changed

lib/widgets/content.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import 'icons.dart';
2222
import 'inset_shadow.dart';
2323
import 'lightbox.dart';
2424
import 'message_list.dart';
25+
import 'page.dart';
2526
import 'poll.dart';
2627
import 'store.dart';
2728
import 'text.dart';
@@ -648,6 +649,7 @@ class MessageImage extends StatelessWidget {
648649
// TODO image hover animation
649650
final srcUrl = node.srcUrl;
650651
final thumbnailUrl = node.thumbnailUrl;
652+
final pageContext = PageRoot.contextOf(context);
651653
final store = PerAccountStoreWidget.of(context);
652654
final resolvedSrcUrl = store.tryResolveUrl(srcUrl);
653655
final resolvedThumbnailUrl = thumbnailUrl == null
@@ -663,12 +665,14 @@ class MessageImage extends StatelessWidget {
663665
src: resolvedSrcUrl,
664666
thumbnailUrl: resolvedThumbnailUrl,
665667
originalWidth: node.originalWidth,
666-
originalHeight: node.originalHeight));
668+
originalHeight: node.originalHeight,
669+
pageContext: pageContext));
667670
},
668671
child: node.loading
669672
? const CupertinoActivityIndicator()
670673
: resolvedSrcUrl == null ? null : LightboxHero(
671674
message: message,
675+
pageContext: pageContext,
672676
src: resolvedSrcUrl,
673677
child: RealmContentNetworkImage(
674678
resolvedThumbnailUrl ?? resolvedSrcUrl,

lib/widgets/lightbox.dart

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,26 @@ import 'store.dart';
2121
// fly to an image preview with a different URL, following a message edit
2222
// while the lightbox was open.
2323
class _LightboxHeroTag {
24-
_LightboxHeroTag({required this.messageId, required this.src});
24+
_LightboxHeroTag({
25+
required this.messageId,
26+
required this.src,
27+
required this.pageContext,
28+
});
2529

2630
final int messageId;
2731
final Uri src;
32+
final BuildContext pageContext;
2833

2934
@override
3035
bool operator ==(Object other) {
3136
return other is _LightboxHeroTag &&
3237
other.messageId == messageId &&
33-
other.src == src;
38+
other.src == src &&
39+
other.pageContext == pageContext;
3440
}
3541

3642
@override
37-
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src);
43+
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src, pageContext);
3844
}
3945

4046
/// Builds a [Hero] from an image in the message list to the lightbox page.
@@ -44,16 +50,18 @@ class LightboxHero extends StatelessWidget {
4450
required this.message,
4551
required this.src,
4652
required this.child,
53+
required this.pageContext,
4754
});
4855

4956
final Message message;
5057
final Uri src;
5158
final Widget child;
59+
final BuildContext pageContext;
5260

5361
@override
5462
Widget build(BuildContext context) {
5563
return Hero(
56-
tag: _LightboxHeroTag(messageId: message.id, src: src),
64+
tag: _LightboxHeroTag(messageId: message.id, src: src, pageContext: pageContext),
5765
flightShuttleBuilder: (
5866
BuildContext flightContext,
5967
Animation<double> animation,
@@ -230,6 +238,7 @@ class _ImageLightboxPage extends StatefulWidget {
230238
required this.thumbnailUrl,
231239
required this.originalWidth,
232240
required this.originalHeight,
241+
required this.pageContext,
233242
});
234243

235244
final Animation<double> routeEntranceAnimation;
@@ -238,6 +247,7 @@ class _ImageLightboxPage extends StatefulWidget {
238247
final Uri? thumbnailUrl;
239248
final double? originalWidth;
240249
final double? originalHeight;
250+
final BuildContext pageContext;
241251

242252
@override
243253
State<_ImageLightboxPage> createState() => _ImageLightboxPageState();
@@ -319,6 +329,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
319329
child: LightboxHero(
320330
message: widget.message,
321331
src: widget.src,
332+
pageContext: widget.pageContext,
322333
child: RealmContentNetworkImage(widget.src,
323334
filterQuality: FilterQuality.medium,
324335
frameBuilder: _frameBuilder,
@@ -603,6 +614,7 @@ Route<void> getImageLightboxRoute({
603614
required Uri? thumbnailUrl,
604615
required double? originalWidth,
605616
required double? originalHeight,
617+
required BuildContext pageContext,
606618
}) {
607619
return _getLightboxRoute(
608620
accountId: accountId,
@@ -614,7 +626,8 @@ Route<void> getImageLightboxRoute({
614626
src: src,
615627
thumbnailUrl: thumbnailUrl,
616628
originalWidth: originalWidth,
617-
originalHeight: originalHeight);
629+
originalHeight: originalHeight,
630+
pageContext: pageContext);
618631
});
619632
}
620633

test/flutter_checks.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ extension PaintChecks on Subject<Paint> {
1313
extension RectChecks on Subject<Rect> {
1414
Subject<double> get top => has((d) => d.top, 'top');
1515
Subject<double> get bottom => has((d) => d.bottom, 'bottom');
16+
Subject<double> get left => has((d) => d.left, 'left');
17+
Subject<double> get right => has((d) => d.right, 'right');
1618

1719
// TODO others
1820
}

test/widgets/lightbox_test.dart

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@ import 'package:video_player_platform_interface/video_player_platform_interface.
1010
import 'package:video_player/video_player.dart';
1111
import 'package:zulip/api/model/model.dart';
1212
import 'package:zulip/model/localizations.dart';
13+
import 'package:zulip/model/narrow.dart';
14+
import 'package:zulip/model/store.dart';
1315
import 'package:zulip/widgets/app.dart';
1416
import 'package:zulip/widgets/content.dart';
1517
import 'package:zulip/widgets/lightbox.dart';
18+
import 'package:zulip/widgets/message_list.dart';
19+
import 'package:zulip/widgets/page.dart';
1620

21+
import '../api/fake_api.dart';
1722
import '../example_data.dart' as eg;
23+
import '../flutter_checks.dart';
1824
import '../model/binding.dart';
25+
import '../model/content_test.dart';
26+
import '../model/test_store.dart';
1927
import '../test_images.dart';
2028
import 'dialog_checks.dart';
2129
import 'test_app.dart';
@@ -207,14 +215,19 @@ void main() {
207215
addTearDown(testBinding.reset);
208216
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
209217

210-
// ZulipApp instead of TestZulipApp because we need the navigator to push
211-
// the lightbox route. The lightbox page works together with the route;
212-
// it takes the route's entrance animation.
213-
await tester.pumpWidget(const ZulipApp());
218+
// ZulipApp instead of TestZulipApp because we need:
219+
// 1. The navigator to push the lightbox route. The lightbox page works
220+
// together with the route; it takes the route's entrance animation.
221+
// 2. The PageRoot widget to provide context for Hero animations between
222+
// the message list and lightbox.
223+
await tester.pumpWidget(PageRoot(
224+
child: const ZulipApp()
225+
));
214226
await tester.pump();
215227
final navigator = await ZulipApp.navigator;
216228
unawaited(navigator.push(getImageLightboxRoute(
217229
accountId: eg.selfAccount.id,
230+
pageContext: PageRoot.contextOf(navigator.context),
218231
message: message ?? eg.streamMessage(),
219232
src: src,
220233
thumbnailUrl: thumbnailUrl,
@@ -558,4 +571,77 @@ void main() {
558571
check(platform.position).equals(position);
559572
});
560573
});
574+
575+
group('LightboxHero', () {
576+
late PerAccountStore store;
577+
late FakeApiConnection connection;
578+
579+
final channel = eg.stream();
580+
final message = eg.streamMessage(stream: channel,
581+
contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');
582+
583+
Future<void> setupMessageListPage(WidgetTester tester) async {
584+
addTearDown(testBinding.reset);
585+
final subscription = eg.subscription(channel);
586+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
587+
streams: [channel], subscriptions: [subscription]));
588+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
589+
connection = store.connection as FakeApiConnection;
590+
591+
await store.addUser(eg.selfUser);
592+
593+
connection.prepare(json:
594+
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
595+
596+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
597+
child: MessageListPage(initNarrow: const CombinedFeedNarrow())));
598+
599+
await tester.pumpAndSettle();
600+
}
601+
602+
testWidgets('no hero animation occurs between different message list pages for same image', (tester) async {
603+
prepareBoringImageHttpClient();
604+
605+
await setupMessageListPage(tester);
606+
607+
final imageFinder = find.byType(RealmContentNetworkImage).first;
608+
final initialImageRect = tester.getRect(imageFinder);
609+
610+
connection.prepare(json:
611+
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
612+
613+
await tester.tap(find.descendant(
614+
of: find.byType(StreamMessageRecipientHeader),
615+
matching: find.text('test topic')));
616+
await tester.pump();
617+
// Pump halfway through the hero animation duration (300ms)
618+
await tester.pump(const Duration(milliseconds: 150));
619+
620+
final imageInTransition = tester.getRect(imageFinder);
621+
check(imageInTransition).top.equals(initialImageRect.top);
622+
check(imageInTransition).left.equals(initialImageRect.left);
623+
await tester.pumpAndSettle();
624+
debugNetworkImageHttpClientProvider = null;
625+
});
626+
627+
testWidgets('hero animation occurs when opening lightbox from message list', (tester) async {
628+
prepareBoringImageHttpClient();
629+
630+
await setupMessageListPage(tester);
631+
632+
final imageFinder = find.byType(RealmContentNetworkImage).first;
633+
final initialImageRect = tester.getRect(imageFinder);
634+
635+
await tester.tap(imageFinder);
636+
await tester.pump();
637+
// Pump halfway through the hero animation duration (300ms)
638+
await tester.pump(const Duration(milliseconds: 150));
639+
640+
final imageInTransition = tester.getRect(imageFinder);
641+
check(imageInTransition).top.not((it) => it.equals(initialImageRect.top));
642+
check(imageInTransition).left.not((it) => it.equals(initialImageRect.left));
643+
await tester.pumpAndSettle();
644+
debugNetworkImageHttpClientProvider = null;
645+
});
646+
});
561647
}

0 commit comments

Comments
 (0)