fullscreen: show low res image until high res is loaded, fixed hero animation
This commit is contained in:
parent
1b5d2a96d5
commit
adfc93a59c
9 changed files with 284 additions and 82 deletions
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
|
191
lib/widgets/common/transition_image.dart
Normal file
191
lib/widgets/common/transition_image.dart
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -64,7 +64,7 @@ class AvesVideoState extends State<AvesVideo> {
|
|||
}
|
||||
return Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: entry.aspectRatio,
|
||||
aspectRatio: entry.displayAspectRatio,
|
||||
child: VideoPlayer(widget.controller),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue