Skip to content

Commit 78e44cd

Browse files
committed
lightbox: Prevent hero animation between message lists
Fixes: #930
1 parent a198f72 commit 78e44cd

File tree

3 files changed

+114
-11
lines changed

3 files changed

+114
-11
lines changed

lib/widgets/content.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import 'dialog.dart';
2020
import 'icons.dart';
2121
import 'lightbox.dart';
2222
import 'message_list.dart';
23+
import 'page.dart';
2324
import 'poll.dart';
2425
import 'store.dart';
2526
import 'text.dart';
@@ -638,6 +639,7 @@ class MessageImage extends StatelessWidget {
638639
// TODO image hover animation
639640
final srcUrl = node.srcUrl;
640641
final thumbnailUrl = node.thumbnailUrl;
642+
final pageContext = PageRoot.contextOf(context);
641643
final store = PerAccountStoreWidget.of(context);
642644
final resolvedSrcUrl = store.tryResolveUrl(srcUrl);
643645
final resolvedThumbnailUrl = thumbnailUrl == null
@@ -653,12 +655,14 @@ class MessageImage extends StatelessWidget {
653655
src: resolvedSrcUrl,
654656
thumbnailUrl: resolvedThumbnailUrl,
655657
originalWidth: node.originalWidth,
656-
originalHeight: node.originalHeight));
658+
originalHeight: node.originalHeight,
659+
pageContext: pageContext));
657660
},
658661
child: node.loading
659662
? const CupertinoActivityIndicator()
660663
: resolvedSrcUrl == null ? null : LightboxHero(
661664
message: message,
665+
pageContext: pageContext,
662666
src: resolvedSrcUrl,
663667
child: RealmContentNetworkImage(
664668
resolvedThumbnailUrl ?? resolvedSrcUrl,

lib/widgets/lightbox.dart

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,22 @@ 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({required this.messageId, required this.src, required this.pageContext});
2525

2626
final int messageId;
2727
final Uri src;
28+
final BuildContext pageContext;
2829

2930
@override
3031
bool operator ==(Object other) {
3132
return other is _LightboxHeroTag &&
3233
other.messageId == messageId &&
33-
other.src == src;
34+
other.src == src &&
35+
other.pageContext == pageContext;
3436
}
3537

3638
@override
37-
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src);
39+
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src, pageContext);
3840
}
3941

4042
/// Builds a [Hero] from an image in the message list to the lightbox page.
@@ -44,16 +46,18 @@ class LightboxHero extends StatelessWidget {
4446
required this.message,
4547
required this.src,
4648
required this.child,
49+
required this.pageContext,
4750
});
4851

4952
final Message message;
5053
final Uri src;
5154
final Widget child;
55+
final BuildContext pageContext;
5256

5357
@override
5458
Widget build(BuildContext context) {
5559
return Hero(
56-
tag: _LightboxHeroTag(messageId: message.id, src: src),
60+
tag: _LightboxHeroTag(messageId: message.id, src: src, pageContext: pageContext),
5761
flightShuttleBuilder: (
5862
BuildContext flightContext,
5963
Animation<double> animation,
@@ -230,6 +234,7 @@ class _ImageLightboxPage extends StatefulWidget {
230234
required this.thumbnailUrl,
231235
required this.originalWidth,
232236
required this.originalHeight,
237+
required this.pageContext,
233238
});
234239

235240
final Animation<double> routeEntranceAnimation;
@@ -238,6 +243,7 @@ class _ImageLightboxPage extends StatefulWidget {
238243
final Uri? thumbnailUrl;
239244
final double? originalWidth;
240245
final double? originalHeight;
246+
final BuildContext pageContext;
241247

242248
@override
243249
State<_ImageLightboxPage> createState() => _ImageLightboxPageState();
@@ -319,6 +325,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
319325
child: LightboxHero(
320326
message: widget.message,
321327
src: widget.src,
328+
pageContext: widget.pageContext,
322329
child: RealmContentNetworkImage(widget.src,
323330
filterQuality: FilterQuality.medium,
324331
frameBuilder: _frameBuilder,
@@ -603,6 +610,7 @@ Route<void> getImageLightboxRoute({
603610
required Uri? thumbnailUrl,
604611
required double? originalWidth,
605612
required double? originalHeight,
613+
required BuildContext pageContext,
606614
}) {
607615
return _getLightboxRoute(
608616
accountId: accountId,
@@ -614,7 +622,8 @@ Route<void> getImageLightboxRoute({
614622
src: src,
615623
thumbnailUrl: thumbnailUrl,
616624
originalWidth: originalWidth,
617-
originalHeight: originalHeight);
625+
originalHeight: originalHeight,
626+
pageContext: pageContext);
618627
});
619628
}
620629

test/widgets/lightbox_test.dart

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,19 @@ 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;
1823
import '../model/binding.dart';
24+
import '../model/content_test.dart';
25+
import '../model/test_store.dart';
1926
import '../test_images.dart';
2027
import 'dialog_checks.dart';
2128
import 'test_app.dart';
@@ -197,6 +204,37 @@ class FakeVideoPlayerPlatform extends Fake
197204
void main() {
198205
TestZulipBinding.ensureInitialized();
199206

207+
late PerAccountStore store;
208+
late FakeApiConnection connection;
209+
210+
Future<void> setupMessageListPage(WidgetTester tester, {
211+
Narrow narrow = const CombinedFeedNarrow(),
212+
List<Message>? messages,
213+
List<ZulipStream>? streams,
214+
215+
}) async {
216+
addTearDown(testBinding.reset);
217+
final stream = streams?.first ?? eg.stream(streamId: eg.defaultStreamMessageStreamId);
218+
final subscription = eg.subscription(stream);
219+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
220+
streams: streams ?? [stream], subscriptions: [subscription]));
221+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
222+
connection = store.connection as FakeApiConnection;
223+
224+
await store.addUser(eg.selfUser);
225+
226+
prepareBoringImageHttpClient();
227+
228+
connection.prepare(json:
229+
eg.newestGetMessagesResult(foundOldest: true, messages: messages ?? []).toJson());
230+
231+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
232+
child: MessageListPage(initNarrow: narrow)));
233+
234+
await tester.pumpAndSettle();
235+
debugNetworkImageHttpClientProvider = null;
236+
}
237+
200238
group('_ImageLightboxPage', () {
201239
final src = Uri.parse('https://chat.example/lightbox-image.png');
202240

@@ -207,14 +245,19 @@ void main() {
207245
addTearDown(testBinding.reset);
208246
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
209247

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());
248+
// ZulipApp instead of TestZulipApp because we need:
249+
// 1. The navigator to push the lightbox route. The lightbox page works
250+
// together with the route; it takes the route's entrance animation.
251+
// 2. The PageRoot widget to provide context for Hero animations between
252+
// the message list and lightbox.
253+
await tester.pumpWidget(PageRoot(
254+
child: const ZulipApp()
255+
));
214256
await tester.pump();
215257
final navigator = await ZulipApp.navigator;
216258
unawaited(navigator.push(getImageLightboxRoute(
217259
accountId: eg.selfAccount.id,
260+
pageContext: PageRoot.contextOf(navigator.context),
218261
message: message ?? eg.streamMessage(),
219262
src: src,
220263
thumbnailUrl: thumbnailUrl,
@@ -558,4 +601,51 @@ void main() {
558601
check(platform.position).equals(position);
559602
});
560603
});
561-
}
604+
605+
group('LightboxHero', () {
606+
testWidgets('no hero animation occurs between different message list pages for same image', (tester) async {
607+
final channel = eg.stream(streamId: eg.defaultStreamMessageStreamId);
608+
final message = eg.streamMessage(stream: channel,
609+
contentMarkdown: ContentExample.spoilerHeaderHasImage.html, topic: 'test topic');
610+
await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(),
611+
streams: [channel], messages: [message]);
612+
613+
connection.prepare(json:
614+
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
615+
616+
final imageFinder = find.byType(RealmContentNetworkImage).first;
617+
final initialImageRect = tester.getRect(imageFinder);
618+
619+
await tester.tap(find.descendant(
620+
of: find.byType(StreamMessageRecipientHeader),
621+
matching: find.text('test topic')));
622+
await tester.pump();
623+
await tester.pump(const Duration(milliseconds: 150));
624+
625+
final imageInTransition = tester.getRect(imageFinder);
626+
check(imageInTransition.top).equals(initialImageRect.top);
627+
check(imageInTransition.left).equals(initialImageRect.left);
628+
await tester.pumpAndSettle();
629+
});
630+
631+
testWidgets('hero animation occurs when opening lightbox from message list', (tester) async {
632+
final channel = eg.stream(streamId: eg.defaultStreamMessageStreamId);
633+
final message = eg.streamMessage(stream: channel,
634+
contentMarkdown: ContentExample.spoilerHeaderHasImage.html);
635+
await setupMessageListPage(tester, narrow: const CombinedFeedNarrow(),
636+
streams: [channel], messages: [message]);
637+
638+
final imageFinder = find.byType(RealmContentNetworkImage);
639+
final initialImageRect = tester.getRect(imageFinder);
640+
641+
await tester.tap(imageFinder);
642+
await tester.pump();
643+
await tester.pump(const Duration(milliseconds: 150));
644+
645+
final imageInTransition = tester.getRect(imageFinder);
646+
check(imageInTransition.top != initialImageRect.top).isTrue();
647+
check(imageInTransition.left != initialImageRect.left).isTrue();
648+
await tester.pumpAndSettle();
649+
});
650+
});
651+
}

0 commit comments

Comments
 (0)