Skip to content

Commit 253ee0e

Browse files
committed
lightbox: Prevent hero animation between message lists
Fixes: zulip#930
1 parent 29cbf10 commit 253ee0e

File tree

3 files changed

+143
-9
lines changed

3 files changed

+143
-9
lines changed

lib/widgets/content.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,7 @@ class MessageImage extends StatelessWidget {
651651
Navigator.of(context).push(getImageLightboxRoute(
652652
context: context,
653653
message: message,
654+
messageImageContext: context,
654655
src: resolvedSrcUrl,
655656
thumbnailUrl: resolvedThumbnailUrl,
656657
originalWidth: node.originalWidth,
@@ -659,7 +660,7 @@ class MessageImage extends StatelessWidget {
659660
child: node.loading
660661
? const CupertinoActivityIndicator()
661662
: resolvedSrcUrl == null ? null : LightboxHero(
662-
message: message,
663+
messageImageContext: context,
663664
src: resolvedSrcUrl,
664665
child: RealmContentNetworkImage(
665666
resolvedThumbnailUrl ?? resolvedSrcUrl,

lib/widgets/lightbox.dart

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,39 +21,53 @@ 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.messageImageContext,
26+
required this.src,
27+
});
2528

26-
final int messageId;
29+
/// The [BuildContext] of the image in the message list that's being expanded
30+
/// into the lightbox. Used to coordinate the Hero animation between this specific
31+
/// image and the lightbox view.
32+
///
33+
/// This helps ensure the animation only happens between the correct image instances,
34+
/// preventing unwanted animations between different message lists or between
35+
/// different images that happen to have the same URL.
36+
final BuildContext messageImageContext;
37+
38+
/// The image source URL. Used to match the source and destination images
39+
/// during the Hero animation, ensuring the animation only occurs between
40+
/// images showing the same content.
2741
final Uri src;
2842

2943
@override
3044
bool operator ==(Object other) {
3145
return other is _LightboxHeroTag &&
32-
other.messageId == messageId &&
46+
other.messageImageContext == messageImageContext &&
3347
other.src == src;
3448
}
3549

3650
@override
37-
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src);
51+
int get hashCode => Object.hash('_LightboxHeroTag', messageImageContext, src);
3852
}
3953

4054
/// Builds a [Hero] from an image in the message list to the lightbox page.
4155
class LightboxHero extends StatelessWidget {
4256
const LightboxHero({
4357
super.key,
44-
required this.message,
58+
required this.messageImageContext,
4559
required this.src,
4660
required this.child,
4761
});
4862

49-
final Message message;
63+
final BuildContext messageImageContext;
5064
final Uri src;
5165
final Widget child;
5266

5367
@override
5468
Widget build(BuildContext context) {
5569
return Hero(
56-
tag: _LightboxHeroTag(messageId: message.id, src: src),
70+
tag: _LightboxHeroTag(messageImageContext: messageImageContext, src: src),
5771
flightShuttleBuilder: (
5872
BuildContext flightContext,
5973
Animation<double> animation,
@@ -226,6 +240,7 @@ class _ImageLightboxPage extends StatefulWidget {
226240
const _ImageLightboxPage({
227241
required this.routeEntranceAnimation,
228242
required this.message,
243+
required this.messageImageContext,
229244
required this.src,
230245
required this.thumbnailUrl,
231246
required this.originalWidth,
@@ -234,6 +249,7 @@ class _ImageLightboxPage extends StatefulWidget {
234249

235250
final Animation<double> routeEntranceAnimation;
236251
final Message message;
252+
final BuildContext messageImageContext;
237253
final Uri src;
238254
final Uri? thumbnailUrl;
239255
final double? originalWidth;
@@ -317,7 +333,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
317333
child: InteractiveViewer(
318334
child: SafeArea(
319335
child: LightboxHero(
320-
message: widget.message,
336+
messageImageContext: widget.messageImageContext,
321337
src: widget.src,
322338
child: RealmContentNetworkImage(widget.src,
323339
filterQuality: FilterQuality.medium,
@@ -599,6 +615,7 @@ Route<void> getImageLightboxRoute({
599615
int? accountId,
600616
BuildContext? context,
601617
required Message message,
618+
required BuildContext messageImageContext,
602619
required Uri src,
603620
required Uri? thumbnailUrl,
604621
required double? originalWidth,
@@ -611,6 +628,7 @@ Route<void> getImageLightboxRoute({
611628
return _ImageLightboxPage(
612629
routeEntranceAnimation: animation,
613630
message: message,
631+
messageImageContext: messageImageContext,
614632
src: src,
615633
thumbnailUrl: thumbnailUrl,
616634
originalWidth: originalWidth,

test/widgets/lightbox_test.dart

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:async';
2+
import 'dart:math';
23

34
import 'package:checks/checks.dart';
45
import 'package:clock/clock.dart';
@@ -10,12 +11,18 @@ import 'package:video_player_platform_interface/video_player_platform_interface.
1011
import 'package:video_player/video_player.dart';
1112
import 'package:zulip/api/model/model.dart';
1213
import 'package:zulip/model/localizations.dart';
14+
import 'package:zulip/model/narrow.dart';
15+
import 'package:zulip/model/store.dart';
1316
import 'package:zulip/widgets/app.dart';
1417
import 'package:zulip/widgets/content.dart';
1518
import 'package:zulip/widgets/lightbox.dart';
19+
import 'package:zulip/widgets/message_list.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,113 @@ class FakeVideoPlayerPlatform extends Fake
197204
void main() {
198205
TestZulipBinding.ensureInitialized();
199206

207+
group('LightboxHero', () {
208+
late PerAccountStore store;
209+
late FakeApiConnection connection;
210+
211+
final channel = eg.stream();
212+
final message = eg.streamMessage(stream: channel,
213+
topic: 'test topic', contentMarkdown: ContentExample.imageSingle.html);
214+
215+
// From ContentExample.imageSingle.
216+
final imageSrcUrlStr = 'https://chat.example/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp';
217+
final imageSrcUrl = Uri.parse(imageSrcUrlStr);
218+
final imageFinder = find.byWidgetPredicate(
219+
(widget) => widget is RealmContentNetworkImage && widget.src == imageSrcUrl);
220+
221+
Future<void> setupMessageListPage(WidgetTester tester) async {
222+
addTearDown(testBinding.reset);
223+
final subscription = eg.subscription(channel);
224+
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
225+
streams: [channel], subscriptions: [subscription]));
226+
store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
227+
connection = store.connection as FakeApiConnection;
228+
await store.addUser(eg.selfUser);
229+
230+
connection.prepare(json:
231+
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
232+
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
233+
child: MessageListPage(initNarrow: const CombinedFeedNarrow())));
234+
await tester.pumpAndSettle();
235+
}
236+
237+
testWidgets('Hero animation occurs smoothly when opening lightbox from message list', (tester) async {
238+
double dist(Rect a, Rect b) =>
239+
sqrt(pow(a.top - b.top, 2) + pow(a.left - b.left, 2));
240+
241+
prepareBoringImageHttpClient();
242+
243+
await setupMessageListPage(tester);
244+
245+
final initialImagePosition = tester.getRect(imageFinder);
246+
await tester.tap(imageFinder);
247+
await tester.pump();
248+
// pump to start hero animation
249+
await tester.pump();
250+
251+
const heroAnimationDuration = Duration(milliseconds: 300);
252+
const steps = 150;
253+
final stepDuration = heroAnimationDuration ~/ steps;
254+
final animatedPositions = <Rect>[];
255+
for (int i = 1; i <= steps; i++) {
256+
await tester.pump(stepDuration);
257+
animatedPositions.add(tester.getRect(imageFinder));
258+
}
259+
260+
final totalDistance = dist(initialImagePosition, animatedPositions.last);
261+
Rect previousPosition = initialImagePosition;
262+
double maxStepDistance = 0.0;
263+
for (final position in animatedPositions) {
264+
final stepDistance = dist(previousPosition, position);
265+
maxStepDistance = max(maxStepDistance, stepDistance);
266+
check(position).not((pos) => pos.equals(previousPosition));
267+
268+
previousPosition = position;
269+
}
270+
check(maxStepDistance).isLessThan(0.03 * totalDistance);
271+
272+
debugNetworkImageHttpClientProvider = null;
273+
});
274+
275+
testWidgets('no hero animation occurs between different message list pages for same image', (tester) async {
276+
Rect getElementRect(Element element) =>
277+
tester.getRect(find.byElementPredicate((e) => e == element));
278+
279+
prepareBoringImageHttpClient();
280+
281+
await setupMessageListPage(tester);
282+
283+
final firstElement = tester.element(imageFinder);
284+
final firstImagePosition = getElementRect(firstElement);
285+
286+
connection.prepare(json:
287+
eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());
288+
await tester.tap(find.descendant(
289+
of: find.byType(StreamMessageRecipientHeader),
290+
matching: find.text('test topic')));
291+
await tester.pumpAndSettle();
292+
293+
final secondElement = tester.element(imageFinder);
294+
final secondImagePosition = getElementRect(secondElement);
295+
296+
await tester.tap(find.byType(BackButton));
297+
await tester.pump();
298+
299+
const heroAnimationDuration = Duration(milliseconds: 300);
300+
const steps = 150;
301+
final stepDuration = heroAnimationDuration ~/ steps;
302+
for (int i = 0; i < steps; i++) {
303+
await tester.pump(stepDuration);
304+
check(tester.elementList(imageFinder)).unorderedEquals([
305+
firstElement, secondElement]);
306+
check(getElementRect(firstElement)).equals(firstImagePosition);
307+
check(getElementRect(secondElement)).equals(secondImagePosition);
308+
}
309+
310+
debugNetworkImageHttpClientProvider = null;
311+
});
312+
});
313+
200314
group('_ImageLightboxPage', () {
201315
final src = Uri.parse('https://chat.example/lightbox-image.png');
202316

@@ -216,6 +330,7 @@ void main() {
216330
unawaited(navigator.push(getImageLightboxRoute(
217331
accountId: eg.selfAccount.id,
218332
message: message ?? eg.streamMessage(),
333+
messageImageContext: navigator.context,
219334
src: src,
220335
thumbnailUrl: thumbnailUrl,
221336
originalHeight: null,

0 commit comments

Comments
 (0)