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/time_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart';
import 'package:path/path.dart';
import 'package:tuple/tuple.dart';
@ -118,14 +119,15 @@ class ImageEntry {
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 (isVideo && isCatalogued) {
if (catalogMetadata.videoRotation % 180 == 90) return height / width;
}
return width / height;
return rotated ? height / width : 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;
DateTime get bestDate {

View file

@ -249,17 +249,7 @@ class _CollectionDrawerState extends State<CollectionDrawer> {
child: ListTile(
leading: const Icon(OMIcons.whatshot),
title: const Text('Debug'),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DebugPage(
source: source,
),
),
);
},
onTap: () => _goToDebug(context),
),
),
],
@ -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 {
@ -311,21 +313,23 @@ class _FilteredCollectionNavTile extends StatelessWidget {
leading: leading,
title: Text(title),
dense: dense,
onTap: () {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
(route) => false,
);
},
onTap: () => _goToCollection(context),
),
);
}
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) {
return GestureDetector(
key: ValueKey(entry.uri),
onTap: () => _showFullscreen(context),
onTap: () => _goToFullscreen(context),
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.size.width,
builder: (c, mqWidth, child) {
@ -102,7 +102,7 @@ class GridThumbnail extends StatelessWidget {
);
}
void _showFullscreen(BuildContext context) {
void _goToFullscreen(BuildContext context) {
Navigator.push(
context,
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/image_providers/thumbnail_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_svg/flutter_svg.dart';
@ -59,17 +60,11 @@ class Thumbnail extends StatelessWidget {
? image
: Hero(
tag: heroTag,
flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) {
// use LayoutBuilder only during hero animation
return LayoutBuilder(builder: (context, constraints) {
final dim = min(constraints.maxWidth, constraints.maxHeight);
return Image(
image: provider,
width: dim,
height: dim,
fit: BoxFit.cover,
);
});
flightShuttleBuilder: (flight, animation, direction, fromHero, toHero) {
return TransitionImage(
image: provider,
animation: animation,
);
},
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/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_picture_provider.dart';
import 'package:aves/widgets/fullscreen/video_view.dart';
@ -28,6 +29,8 @@ class ImageView extends StatelessWidget {
Widget build(BuildContext context) {
const backgroundDecoration = BoxDecoration(color: Colors.transparent);
// no hero for videos, as a typical video first frame is different from its thumbnail
if (entry.isVideo) {
final videoController = videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2;
return PhotoView.customChild(
@ -38,7 +41,6 @@ class ImageView extends StatelessWidget {
)
: const SizedBox(),
backgroundDecoration: backgroundDecoration,
// no hero as most videos fullscreen image is different from its thumbnail
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
@ -46,53 +48,61 @@ class ImageView extends StatelessWidget {
);
}
final placeholderBuilder = (context) => const Center(
child: SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
);
final heroAttributes = heroTag != null
? PhotoViewHeroAttributes(
tag: heroTag,
transitionOnUserGestures: true,
)
: null;
// if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`,
// the route transition becomes visible if the final is loaded before the hero animation is done.
// 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) {
return PhotoView.customChild(
child = PhotoView.customChild(
child: SvgPicture(
UriPicture(
uri: entry.uri,
mimeType: entry.mimeType,
colorFilter: Constants.svgColorFilter,
),
placeholderBuilder: placeholderBuilder,
placeholderBuilder: loadingBuilder,
),
backgroundDecoration: backgroundDecoration,
heroAttributes: heroAttributes,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
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(
// 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) => placeholderBuilder(context),
backgroundDecoration: backgroundDecoration,
heroAttributes: heroAttributes,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
filterQuality: FilterQuality.low,
);
return heroTag != null
? Hero(
tag: heroTag,
transitionOnUserGestures: true,
child: child,
)
: child;
}
}

View file

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

View file

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

View file

@ -223,7 +223,7 @@ class StatsPage extends StatelessWidget {
alignment: AlignmentDirectional.centerStart,
child: AvesFilterChip(
filter: filterBuilder(label),
onPressed: (filter) => _goToFilteredCollection(context, filter),
onPressed: (filter) => _goToCollection(context, filter),
),
),
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;
Navigator.pushAndRemoveUntil(
context,