Skip to content

Commit 187a022

Browse files
lakshya1goelgnprice
authored andcommitted
lightbox: Prevent hero animation between message lists
Fixes #930. Fixes #44.
1 parent 52d6909 commit 187a022

File tree

3 files changed

+144
-14
lines changed

3 files changed

+144
-14
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: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,45 +15,55 @@ import 'dialog.dart';
1515
import 'page.dart';
1616
import 'store.dart';
1717

18-
// TODO(#44): Add index of the image preview in the message, to not break if
19-
// there are multiple image previews with the same URL in the same
20-
// message. Maybe keep `src`, so that on exit the lightbox image doesn't
21-
// fly to an image preview with a different URL, following a message edit
22-
// while the lightbox was open.
2318
class _LightboxHeroTag {
24-
_LightboxHeroTag({required this.messageId, required this.src});
19+
_LightboxHeroTag({
20+
required this.messageImageContext,
21+
required this.src,
22+
});
2523

26-
final int messageId;
24+
/// The [BuildContext] of the image in the message list that's being expanded
25+
/// into the lightbox. Used to coordinate the Hero animation between this specific
26+
/// image and the lightbox view.
27+
///
28+
/// This helps ensure the animation only happens between the correct image instances,
29+
/// preventing unwanted animations between different message lists or between
30+
/// different images that happen to have the same URL.
31+
// TODO: write a regression test for #44, duplicate images within a message
32+
final BuildContext messageImageContext;
33+
34+
/// The image source URL. Used to match the source and destination images
35+
/// during the Hero animation, ensuring the animation only occurs between
36+
/// images showing the same content.
2737
final Uri src;
2838

2939
@override
3040
bool operator ==(Object other) {
3141
return other is _LightboxHeroTag &&
32-
other.messageId == messageId &&
42+
other.messageImageContext == messageImageContext &&
3343
other.src == src;
3444
}
3545

3646
@override
37-
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src);
47+
int get hashCode => Object.hash('_LightboxHeroTag', messageImageContext, src);
3848
}
3949

4050
/// Builds a [Hero] from an image in the message list to the lightbox page.
4151
class LightboxHero extends StatelessWidget {
4252
const LightboxHero({
4353
super.key,
44-
required this.message,
54+
required this.messageImageContext,
4555
required this.src,
4656
required this.child,
4757
});
4858

49-
final Message message;
59+
final BuildContext messageImageContext;
5060
final Uri src;
5161
final Widget child;
5262

5363
@override
5464
Widget build(BuildContext context) {
5565
return Hero(
56-
tag: _LightboxHeroTag(messageId: message.id, src: src),
66+
tag: _LightboxHeroTag(messageImageContext: messageImageContext, src: src),
5767
flightShuttleBuilder: (
5868
BuildContext flightContext,
5969
Animation<double> animation,
@@ -226,6 +236,7 @@ class _ImageLightboxPage extends StatefulWidget {
226236
const _ImageLightboxPage({
227237
required this.routeEntranceAnimation,
228238
required this.message,
239+
required this.messageImageContext,
229240
required this.src,
230241
required this.thumbnailUrl,
231242
required this.originalWidth,
@@ -234,6 +245,7 @@ class _ImageLightboxPage extends StatefulWidget {
234245

235246
final Animation<double> routeEntranceAnimation;
236247
final Message message;
248+
final BuildContext messageImageContext;
237249
final Uri src;
238250
final Uri? thumbnailUrl;
239251
final double? originalWidth;
@@ -317,7 +329,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
317329
child: InteractiveViewer(
318330
child: SafeArea(
319331
child: LightboxHero(
320-
message: widget.message,
332+
messageImageContext: widget.messageImageContext,
321333
src: widget.src,
322334
child: RealmContentNetworkImage(widget.src,
323335
filterQuality: FilterQuality.medium,
@@ -599,6 +611,7 @@ Route<void> getImageLightboxRoute({
599611
int? accountId,
600612
BuildContext? context,
601613
required Message message,
614+
required BuildContext messageImageContext,
602615
required Uri src,
603616
required Uri? thumbnailUrl,
604617
required double? originalWidth,
@@ -611,6 +624,7 @@ Route<void> getImageLightboxRoute({
611624
return _ImageLightboxPage(
612625
routeEntranceAnimation: animation,
613626
message: message,
627+
messageImageContext: messageImageContext,
614628
src: src,
615629
thumbnailUrl: thumbnailUrl,
616630
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))
305+
.unorderedEquals([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)