fullscreen: show low res image until high res is loaded, fixed hero animation

This commit is contained in:
Thibault Deckers 2020-04-02 19:41:48 +09:00
parent 1b5d2a96d5
commit adfc93a59c
9 changed files with 284 additions and 82 deletions

View file

@ -5,6 +5,7 @@ import 'package:aves/model/metadata_service.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart'; import 'package:geocoder/geocoder.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
@ -118,14 +119,15 @@ class ImageEntry {
bool get canRotate => canEdit && (mimeType == MimeTypes.MIME_JPEG || mimeType == MimeTypes.MIME_PNG); bool get canRotate => canEdit && (mimeType == MimeTypes.MIME_JPEG || mimeType == MimeTypes.MIME_PNG);
double get aspectRatio { bool get rotated => ((isVideo && isCatalogued) ? catalogMetadata.videoRotation : orientationDegrees) % 180 == 90;
double get displayAspectRatio {
if (width == 0 || height == 0) return 1; if (width == 0 || height == 0) return 1;
if (isVideo && isCatalogued) { return rotated ? height / width : width / height;
if (catalogMetadata.videoRotation % 180 == 90) return height / width;
}
return width / height;
} }
Size get displaySize => rotated ? Size(height.toDouble(), width.toDouble()) : Size(width.toDouble(), height.toDouble());
int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null; int get megaPixels => width != null && height != null ? (width * height / 1000000).round() : null;
DateTime get bestDate { DateTime get bestDate {

View file

@ -249,17 +249,7 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
child: ListTile( child: ListTile(
leading: const Icon(OMIcons.whatshot), leading: const Icon(OMIcons.whatshot),
title: const Text('Debug'), title: const Text('Debug'),
onTap: () { onTap: () => _goToDebug(context),
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DebugPage(
source: source,
),
),
);
},
), ),
), ),
], ],
@ -285,6 +275,18 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
), ),
); );
} }
void _goToDebug(BuildContext context) {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DebugPage(
source: source,
),
),
);
}
} }
class _FilteredCollectionNavTile extends StatelessWidget { class _FilteredCollectionNavTile extends StatelessWidget {
@ -311,21 +313,23 @@ class _FilteredCollectionNavTile extends StatelessWidget {
leading: leading, leading: leading,
title: Text(title), title: Text(title),
dense: dense, dense: dense,
onTap: () { onTap: () => _goToCollection(context),
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
(route) => false,
);
},
), ),
); );
} }
void _goToCollection(BuildContext context) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
(route) => false,
);
}
} }

View file

@ -85,7 +85,7 @@ class GridThumbnail extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
key: ValueKey(entry.uri), key: ValueKey(entry.uri),
onTap: () => _showFullscreen(context), onTap: () => _goToFullscreen(context),
child: Selector<MediaQueryData, double>( child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width, selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) { builder: (c, mqWidth, child) {
@ -102,7 +102,7 @@ class GridThumbnail extends StatelessWidget {
); );
} }
void _showFullscreen(BuildContext context) { void _goToFullscreen(BuildContext context) {
Navigator.push( Navigator.push(
context, context,
TransparentMaterialPageRoute( TransparentMaterialPageRoute(

View file

@ -5,6 +5,7 @@ import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/common/transition_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@ -59,17 +60,11 @@ class Thumbnail extends StatelessWidget {
? image ? image
: Hero( : Hero(
tag: heroTag, tag: heroTag,
flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) { flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
// use LayoutBuilder only during hero animation return TransitionImage(
return LayoutBuilder(builder: (context, constraints) { image: provider,
final dim = min(constraints.maxWidth, constraints.maxHeight); animation: animation,
return Image( );
image: provider,
width: dim,
height: dim,
fit: BoxFit.cover,
);
});
}, },
child: image, child: image,
); );

View file

@ -0,0 +1,191 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
// adapted from `RawImage`, `paintImage()` from `DecorationImagePainter`, etc.
// to transition between 2 different fits during hero animation:
// - BoxFit.cover at t=0
// - BoxFit.contain at t=1
class TransitionImage extends StatefulWidget {
final ImageProvider image;
final double width, height;
final ValueListenable<double> animation;
final gaplessPlayback = false;
const TransitionImage({
@required this.image,
@required this.animation,
this.width,
this.height,
});
@override
_TransitionImageState createState() => _TransitionImageState();
}
class _TransitionImageState extends State<TransitionImage> {
ImageStream _imageStream;
ImageInfo _imageInfo;
bool _isListeningToStream = false;
int _frameNumber;
@override
void initState() {
super.initState();
}
@override
void dispose() {
assert(_imageStream != null);
_stopListeningToStream();
super.dispose();
}
@override
void didChangeDependencies() {
_resolveImage();
if (TickerMode.of(context)) {
_listenToStream();
} else {
_stopListeningToStream();
}
super.didChangeDependencies();
}
@override
void didUpdateWidget(TransitionImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (_isListeningToStream) {
_imageStream.removeListener(_getListener());
_imageStream.addListener(_getListener());
}
if (widget.image != oldWidget.image) _resolveImage();
}
@override
void reassemble() {
_resolveImage(); // in case the image cache was flushed
super.reassemble();
}
void _resolveImage() {
final provider = widget.image;
final newStream = provider.resolve(createLocalImageConfiguration(
context,
size: widget.width != null && widget.height != null ? Size(widget.width, widget.height) : null,
));
assert(newStream != null);
_updateSourceStream(newStream);
}
ImageStreamListener _getListener() {
return ImageStreamListener(
_handleImageFrame,
onChunk: null,
);
}
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
setState(() {
_imageInfo = imageInfo;
_frameNumber = _frameNumber == null ? 0 : _frameNumber + 1;
});
}
// Updates _imageStream to newStream, and moves the stream listener
// registration from the old stream to the new stream (if a listener was
// registered).
void _updateSourceStream(ImageStream newStream) {
if (_imageStream?.key == newStream?.key) return;
if (_isListeningToStream) _imageStream.removeListener(_getListener());
if (!widget.gaplessPlayback) {
setState(() {
_imageInfo = null;
});
}
setState(() {
_frameNumber = null;
});
_imageStream = newStream;
if (_isListeningToStream) _imageStream.addListener(_getListener());
}
void _listenToStream() {
if (_isListeningToStream) return;
_imageStream.addListener(_getListener());
_isListeningToStream = true;
}
void _stopListeningToStream() {
if (!_isListeningToStream) return;
_imageStream.removeListener(_getListener());
_isListeningToStream = false;
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<double>(
valueListenable: widget.animation,
builder: (context, t, child) => CustomPaint(
painter: _TransitionImagePainter(
// AssetImage(name).resolve(configuration)
image: _imageInfo?.image,
scale: _imageInfo?.scale ?? 1.0,
t: t,
),
),
);
}
}
class _TransitionImagePainter extends CustomPainter {
final ui.Image image;
final double scale;
final double t;
const _TransitionImagePainter({
@required this.image,
@required this.scale,
@required this.t,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..isAntiAlias = false
..filterQuality = FilterQuality.low;
const alignment = Alignment.center;
final rect = ui.Rect.fromLTWH(0, 0, size.width, size.height);
final inputSize = Size(image.width.toDouble(), image.height.toDouble());
final outputSize = rect.size;
final coverSizes = applyBoxFit(BoxFit.cover, inputSize / scale, size);
final containSizes = applyBoxFit(BoxFit.contain, inputSize / scale, size);
final sourceSize = Size.lerp(coverSizes.source, containSizes.source, t) * scale;
final destinationSize = Size.lerp(coverSizes.destination, containSizes.destination, t);
final halfWidthDelta = (outputSize.width - destinationSize.width) / 2.0;
final halfHeightDelta = (outputSize.height - destinationSize.height) / 2.0;
final dx = halfWidthDelta + alignment.x * halfWidthDelta;
final dy = halfHeightDelta + alignment.y * halfHeightDelta;
final destinationPosition = rect.topLeft.translate(dx, dy);
final destinationRect = destinationPosition & destinationSize;
final sourceRect = alignment.inscribe(
sourceSize,
Offset.zero & inputSize,
);
canvas.drawImageRect(image, sourceRect, destinationRect, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => true;
}

View file

@ -1,5 +1,6 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/video_view.dart'; import 'package:aves/widgets/fullscreen/video_view.dart';
@ -28,6 +29,8 @@ class ImageView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
const backgroundDecoration = BoxDecoration(color: Colors.transparent); const backgroundDecoration = BoxDecoration(color: Colors.transparent);
// no hero for videos, as a typical video first frame is different from its thumbnail
if (entry.isVideo) { if (entry.isVideo) {
final videoController = videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; final videoController = videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return PhotoView.customChild( return PhotoView.customChild(
@ -38,7 +41,6 @@ class ImageView extends StatelessWidget {
) )
: const SizedBox(), : const SizedBox(),
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
// no hero as most videos fullscreen image is different from its thumbnail
scaleStateChangedCallback: onScaleChanged, scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
@ -46,53 +48,61 @@ class ImageView extends StatelessWidget {
); );
} }
final placeholderBuilder = (context) => const Center( // if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
child: SizedBox( // the route transition becomes visible if the final is loaded before the hero animation is done.
width: 64,
height: 64,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
);
final heroAttributes = heroTag != null
? PhotoViewHeroAttributes(
tag: heroTag,
transitionOnUserGestures: true,
)
: null;
// if the hero tag wraps the whole `PhotoView` and the `loadingBuilder` is not provided,
// there's a black frame between the hero animation and the final image, even when it's cached.
final loadingBuilder = (context) => Image(
image: ThumbnailProvider(
entry: entry,
extent: Constants.thumbnailCacheExtent,
),
fit: BoxFit.contain,
);
Widget child;
if (entry.isSvg) { if (entry.isSvg) {
return PhotoView.customChild( child = PhotoView.customChild(
child: SvgPicture( child: SvgPicture(
UriPicture( UriPicture(
uri: entry.uri, uri: entry.uri,
mimeType: entry.mimeType, mimeType: entry.mimeType,
colorFilter: Constants.svgColorFilter, colorFilter: Constants.svgColorFilter,
), ),
placeholderBuilder: placeholderBuilder, placeholderBuilder: loadingBuilder,
), ),
backgroundDecoration: backgroundDecoration, backgroundDecoration: backgroundDecoration,
heroAttributes: heroAttributes,
scaleStateChangedCallback: onScaleChanged, scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained, initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(), onTapUp: (tapContext, details, value) => onTap?.call(),
); );
} else {
child = PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: UriImage(
uri: entry.uri,
mimeType: entry.mimeType,
),
loadingBuilder: (context, event) => loadingBuilder(context),
backgroundDecoration: backgroundDecoration,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
} }
return PhotoView( return heroTag != null
// key includes size and orientation to refresh when the image is rotated ? Hero(
key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'), tag: heroTag,
imageProvider: UriImage(uri: entry.uri, mimeType: entry.mimeType), transitionOnUserGestures: true,
loadingBuilder: (context, event) => placeholderBuilder(context), child: child,
backgroundDecoration: backgroundDecoration, )
heroAttributes: heroAttributes, : child;
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
} }
} }

View file

@ -75,14 +75,14 @@ class InfoPageState extends State<InfoPage> {
entry: entry, entry: entry,
showTitle: !locationAtTop, showTitle: !locationAtTop,
visibleNotifier: widget.visibleNotifier, visibleNotifier: widget.visibleNotifier,
onFilter: _goToFilteredCollection, onFilter: _goToCollection,
); );
final basicAndLocationSliver = locationAtTop final basicAndLocationSliver = locationAtTop
? SliverToBoxAdapter( ? SliverToBoxAdapter(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToFilteredCollection)), Expanded(child: BasicSection(entry: entry, collection: collection, onFilter: _goToCollection)),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: locationSection), Expanded(child: locationSection),
], ],
@ -91,7 +91,7 @@ class InfoPageState extends State<InfoPage> {
: SliverList( : SliverList(
delegate: SliverChildListDelegate.fixed( delegate: SliverChildListDelegate.fixed(
[ [
BasicSection(entry: entry, collection: collection, onFilter: _goToFilteredCollection), BasicSection(entry: entry, collection: collection, onFilter: _goToCollection),
locationSection, locationSection,
], ],
), ),
@ -153,7 +153,7 @@ class InfoPageState extends State<InfoPage> {
); );
} }
void _goToFilteredCollection(CollectionFilter filter) { void _goToCollection(CollectionFilter filter) {
if (collection == null) return; if (collection == null) return;
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,

View file

@ -64,7 +64,7 @@ class AvesVideoState extends State<AvesVideo> {
} }
return Center( return Center(
child: AspectRatio( child: AspectRatio(
aspectRatio: entry.aspectRatio, aspectRatio: entry.displayAspectRatio,
child: VideoPlayer(widget.controller), child: VideoPlayer(widget.controller),
), ),
); );

View file

@ -223,7 +223,7 @@ class StatsPage extends StatelessWidget {
alignment: AlignmentDirectional.centerStart, alignment: AlignmentDirectional.centerStart,
child: AvesFilterChip( child: AvesFilterChip(
filter: filterBuilder(label), filter: filterBuilder(label),
onPressed: (filter) => _goToFilteredCollection(context, filter), onPressed: (filter) => _goToCollection(context, filter),
), ),
), ),
LinearPercentIndicator( LinearPercentIndicator(
@ -253,7 +253,7 @@ class StatsPage extends StatelessWidget {
]; ];
} }
void _goToFilteredCollection(BuildContext context, CollectionFilter filter) { void _goToCollection(BuildContext context, CollectionFilter filter) {
if (collection == null) return; if (collection == null) return;
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,