Skip to content

Commit c61a99b

Browse files
lightbox: Support thumbnail to original image hero transition
Fixes: #799
1 parent 48cb848 commit c61a99b

File tree

2 files changed

+68
-8
lines changed

2 files changed

+68
-8
lines changed

lib/widgets/content.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -602,11 +602,13 @@ class MessageImage extends StatelessWidget {
602602
context: context,
603603
message: message,
604604
src: resolvedSrcUrl!,
605+
thumbnailUrl: thumbnailUrl == null ? null : resolvedThumbnailUrl,
605606
mediaType: MediaType.image));
606607
},
607608
child: LightboxHero(
608609
message: message,
609610
src: resolvedSrcUrl,
611+
thumbnailUrl: thumbnailUrl == null ? null : resolvedThumbnailUrl,
610612
child: RealmContentNetworkImage(
611613
resolvedThumbnailUrl,
612614
filterQuality: FilterQuality.medium)));
@@ -630,7 +632,8 @@ class MessageInlineVideo extends StatelessWidget {
630632
context: context,
631633
message: message,
632634
src: resolvedSrc,
633-
mediaType: MediaType.video));
635+
mediaType: MediaType.video,
636+
thumbnailUrl: null));
634637
},
635638
child: Container(
636639
color: Colors.black, // Web has the same color in light and dark mode.

lib/widgets/lightbox.dart

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter/scheduler.dart';
23
import 'package:flutter/services.dart';
34
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
45
import 'package:intl/intl.dart';
@@ -19,20 +20,22 @@ import 'store.dart';
1920
// fly to an image preview with a different URL, following a message edit
2021
// while the lightbox was open.
2122
class _LightboxHeroTag {
22-
_LightboxHeroTag({required this.messageId, required this.src});
23+
_LightboxHeroTag({required this.messageId, required this.src, required this.thumbnailUrl});
2324

2425
final int messageId;
2526
final Uri src;
27+
final Uri? thumbnailUrl;
2628

2729
@override
2830
bool operator ==(Object other) {
2931
return other is _LightboxHeroTag &&
3032
other.messageId == messageId &&
31-
other.src == src;
33+
other.src == src &&
34+
other.thumbnailUrl == thumbnailUrl;
3235
}
3336

3437
@override
35-
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src);
38+
int get hashCode => Object.hash('_LightboxHeroTag', messageId, src, thumbnailUrl);
3639
}
3740

3841
/// Builds a [Hero] from an image in the message list to the lightbox page.
@@ -41,17 +44,19 @@ class LightboxHero extends StatelessWidget {
4144
super.key,
4245
required this.message,
4346
required this.src,
47+
required this.thumbnailUrl,
4448
required this.child,
4549
});
4650

4751
final Message message;
4852
final Uri src;
53+
final Uri? thumbnailUrl;
4954
final Widget child;
5055

5156
@override
5257
Widget build(BuildContext context) {
5358
return Hero(
54-
tag: _LightboxHeroTag(messageId: message.id, src: src),
59+
tag: _LightboxHeroTag(messageId: message.id, src: src, thumbnailUrl: thumbnailUrl),
5560
flightShuttleBuilder: (
5661
BuildContext flightContext,
5762
Animation<double> animation,
@@ -91,12 +96,14 @@ class _LightboxPageLayout extends StatefulWidget {
9196
const _LightboxPageLayout({
9297
required this.routeEntranceAnimation,
9398
required this.message,
99+
required this.buildAppBarFooter,
94100
required this.buildBottomAppBar,
95101
required this.child,
96102
});
97103

98104
final Animation<double> routeEntranceAnimation;
99105
final Message message;
106+
final PreferredSizeWidget Function(BuildContext context)? buildAppBarFooter;
100107
final Widget? Function(
101108
BuildContext context, Color color, double elevation) buildBottomAppBar;
102109
final Widget child;
@@ -171,7 +178,8 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> {
171178

172179
// Make smaller, like a subtitle
173180
style: themeData.textTheme.titleSmall!.copyWith(color: appBarForegroundColor)),
174-
])));
181+
])),
182+
bottom: widget.buildAppBarFooter == null ? null : widget.buildAppBarFooter!(context));
175183
}
176184

177185
Widget? bottomAppBar;
@@ -209,17 +217,27 @@ class _ImageLightboxPage extends StatefulWidget {
209217
required this.routeEntranceAnimation,
210218
required this.message,
211219
required this.src,
220+
required this.thumbnailUrl,
212221
});
213222

214223
final Animation<double> routeEntranceAnimation;
215224
final Message message;
216225
final Uri src;
226+
final Uri? thumbnailUrl;
217227

218228
@override
219229
State<_ImageLightboxPage> createState() => _ImageLightboxPageState();
220230
}
221231

222232
class _ImageLightboxPageState extends State<_ImageLightboxPage> {
233+
double? _loadingProgress;
234+
235+
PreferredSizeWidget _buildAppBarFooter(BuildContext context) {
236+
return PreferredSize(
237+
preferredSize: const Size.fromHeight(4.0),
238+
child: LinearProgressIndicator(minHeight: 4.0, value: _loadingProgress!));
239+
}
240+
223241
Widget _buildBottomAppBar(BuildContext context, Color color, double elevation) {
224242
return BottomAppBar(
225243
color: color,
@@ -234,17 +252,53 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
234252

235253
@override
236254
Widget build(BuildContext context) {
255+
final thumbnailUrl = widget.thumbnailUrl;
237256
return _LightboxPageLayout(
238257
routeEntranceAnimation: widget.routeEntranceAnimation,
239258
message: widget.message,
259+
buildAppBarFooter: _loadingProgress == null ? null : _buildAppBarFooter,
240260
buildBottomAppBar: _buildBottomAppBar,
241261
child: SizedBox.expand(
242262
child: InteractiveViewer(
243263
child: SafeArea(
244264
child: LightboxHero(
245265
message: widget.message,
246266
src: widget.src,
247-
child: RealmContentNetworkImage(widget.src, filterQuality: FilterQuality.medium))))));
267+
thumbnailUrl: thumbnailUrl,
268+
child: RealmContentNetworkImage(widget.src,
269+
filterQuality: FilterQuality.medium,
270+
frameBuilder: thumbnailUrl == null ? null
271+
: (context, child, frame, wasSynchronouslyLoaded) {
272+
if (wasSynchronouslyLoaded || frame != null) {
273+
// Image was already available or has finished downloading and
274+
// so it is now available.
275+
return child;
276+
}
277+
return RealmContentNetworkImage(thumbnailUrl,
278+
filterQuality: FilterQuality.medium);
279+
},
280+
loadingBuilder: thumbnailUrl == null ? null
281+
: (context, child, loadingProgress) {
282+
// `loadingProgress` is null when Image has finished downloading.
283+
double? progress = _loadingProgress;
284+
if (loadingProgress?.expectedTotalBytes == null) {
285+
progress = null;
286+
} else {
287+
progress = loadingProgress!.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!;
288+
}
289+
if (progress != _loadingProgress) {
290+
_loadingProgress = progress;
291+
// This function is called in a build method and setState
292+
// can't be called in a build method, so delay it.
293+
SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); });
294+
}
295+
return child;
296+
},
297+
),
298+
),
299+
),
300+
),
301+
));
248302
}
249303
}
250304

@@ -457,6 +511,7 @@ class _VideoLightboxPageState extends State<VideoLightboxPage> with PerAccountSt
457511
return _LightboxPageLayout(
458512
routeEntranceAnimation: widget.routeEntranceAnimation,
459513
message: widget.message,
514+
buildAppBarFooter: null,
460515
buildBottomAppBar: _buildBottomAppBar,
461516
child: SafeArea(
462517
child: Center(
@@ -484,6 +539,7 @@ Route<void> getLightboxRoute({
484539
BuildContext? context,
485540
required Message message,
486541
required Uri src,
542+
required Uri? thumbnailUrl,
487543
required MediaType mediaType,
488544
}) {
489545
return AccountPageRouteBuilder(
@@ -500,7 +556,8 @@ Route<void> getLightboxRoute({
500556
MediaType.image => _ImageLightboxPage(
501557
routeEntranceAnimation: animation,
502558
message: message,
503-
src: src),
559+
src: src,
560+
thumbnailUrl: thumbnailUrl),
504561
MediaType.video => VideoLightboxPage(
505562
routeEntranceAnimation: animation,
506563
message: message,

0 commit comments

Comments
 (0)