From 05496da3440e61129502a602dec5cea67087f3e2 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 17 Dec 2020 14:02:26 +0900 Subject: [PATCH] reworked and integrated photo_view package, fixed double tap zoom focus --- README.md | 1 - lib/image_providers/region_provider.dart | 4 +- lib/image_providers/thumbnail_provider.dart | 4 +- lib/image_providers/uri_image_provider.dart | 2 +- lib/image_providers/uri_picture_provider.dart | 2 +- lib/model/filters/album.dart | 4 +- lib/model/filters/location.dart | 4 +- lib/model/filters/mime.dart | 4 +- lib/model/filters/query.dart | 4 +- lib/model/filters/tag.dart | 4 +- lib/model/image_entry.dart | 4 +- lib/model/image_metadata.dart | 21 +- lib/services/image_file_service.dart | 8 +- lib/utils/constants.dart | 6 - lib/widgets/collection/grid/list_sliver.dart | 2 +- .../magnifier/controller/controller.dart | 106 +++++++ .../controller/controller_delegate.dart | 226 ++++++++++++++ .../common/magnifier/controller/state.dart | 28 ++ lib/widgets/common/magnifier/core/core.dart | 290 ++++++++++++++++++ .../magnifier/core/gesture_detector.dart | 231 ++++++++++++++ lib/widgets/common/magnifier/magnifier.dart | 169 ++++++++++ .../magnifier/pan/corner_hit_detector.dart | 76 +++++ .../magnifier/pan/gesture_detector_scope.dart | 33 ++ .../common/magnifier/pan/scroll_physics.dart | 29 ++ .../magnifier/scale/scale_boundaries.dart | 60 ++++ .../common/magnifier/scale/scale_level.dart | 32 ++ .../scale/scalestate_controller.dart | 41 +++ lib/widgets/common/magnifier/scale/state.dart | 53 ++++ lib/widgets/fullscreen/fullscreen_body.dart | 4 +- lib/widgets/fullscreen/image_page.dart | 13 +- lib/widgets/fullscreen/image_view.dart | 183 +++++------ .../fullscreen/info/metadata/svg_tile.dart | 1 - .../info/metadata/xmp_namespaces.dart | 12 +- lib/widgets/fullscreen/overlay/minimap.dart | 40 +-- lib/widgets/fullscreen/tiled_view.dart | 168 ++++++---- lib/widgets/stats/stats.dart | 4 +- pubspec.lock | 9 - pubspec.yaml | 4 - 38 files changed, 1599 insertions(+), 287 deletions(-) create mode 100644 lib/widgets/common/magnifier/controller/controller.dart create mode 100644 lib/widgets/common/magnifier/controller/controller_delegate.dart create mode 100644 lib/widgets/common/magnifier/controller/state.dart create mode 100644 lib/widgets/common/magnifier/core/core.dart create mode 100644 lib/widgets/common/magnifier/core/gesture_detector.dart create mode 100644 lib/widgets/common/magnifier/magnifier.dart create mode 100644 lib/widgets/common/magnifier/pan/corner_hit_detector.dart create mode 100644 lib/widgets/common/magnifier/pan/gesture_detector_scope.dart create mode 100644 lib/widgets/common/magnifier/pan/scroll_physics.dart create mode 100644 lib/widgets/common/magnifier/scale/scale_boundaries.dart create mode 100644 lib/widgets/common/magnifier/scale/scale_level.dart create mode 100644 lib/widgets/common/magnifier/scale/scalestate_controller.dart create mode 100644 lib/widgets/common/magnifier/scale/state.dart diff --git a/README.md b/README.md index 0604bccb9..52ed6d473 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,6 @@ Aves is a gallery and metadata explorer app. It is built for Android, with Flutt ## Known Issues -- gesture: double tap on image does not zoom on tapped area (cf [photo_view issue #82](https://github.com/renancaraujo/photo_view/issues/82)) - performance: image info page stutters the first time it loads a Google Maps view (cf [flutter issue #28493](https://github.com/flutter/flutter/issues/28493)) - SVG: unsupported `currentColor` (cf [flutter_svg issue #31](https://github.com/dnfield/flutter_svg/issues/31)) - SVG: unsupported out of order defs/references (cf [flutter_svg issue #102](https://github.com/dnfield/flutter_svg/issues/102)) diff --git a/lib/image_providers/region_provider.dart b/lib/image_providers/region_provider.dart index d99a2217d..d1a21313d 100644 --- a/lib/image_providers/region_provider.dart +++ b/lib/image_providers/region_provider.dart @@ -125,7 +125,5 @@ class RegionProviderKey { ); @override - String toString() { - return 'RegionProviderKey(uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale)'; - } + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, regionRect=$regionRect, imageSize=$imageSize, scale=$scale}'; } diff --git a/lib/image_providers/thumbnail_provider.dart b/lib/image_providers/thumbnail_provider.dart index e726ad0b0..e0cebca55 100644 --- a/lib/image_providers/thumbnail_provider.dart +++ b/lib/image_providers/thumbnail_provider.dart @@ -116,7 +116,5 @@ class ThumbnailProviderKey { ); @override - String toString() { - return 'ThumbnailProviderKey{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}'; - } + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, dateModifiedSecs=$dateModifiedSecs, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, extent=$extent, scale=$scale}'; } diff --git a/lib/image_providers/uri_image_provider.dart b/lib/image_providers/uri_image_provider.dart index 66f3bd8fb..1368f890c 100644 --- a/lib/image_providers/uri_image_provider.dart +++ b/lib/image_providers/uri_image_provider.dart @@ -80,5 +80,5 @@ class UriImage extends ImageProvider { int get hashCode => hashValues(uri, scale); @override - String toString() => '${objectRuntimeType(this, 'UriImage')}(uri=$uri, mimeType=$mimeType, scale=$scale)'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, scale=$scale}'; } diff --git a/lib/image_providers/uri_picture_provider.dart b/lib/image_providers/uri_picture_provider.dart index 9165fc3f1..913c78690 100644 --- a/lib/image_providers/uri_picture_provider.dart +++ b/lib/image_providers/uri_picture_provider.dart @@ -54,5 +54,5 @@ class UriPicture extends PictureProvider { int get hashCode => hashValues(uri, colorFilter); @override - String toString() => '${objectRuntimeType(this, 'UriPicture')}(uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter)'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, colorFilter=$colorFilter}'; } diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index b5062e80e..82f3bcc56 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -87,7 +87,5 @@ class AlbumFilter extends CollectionFilter { int get hashCode => hashValues(type, album); @override - String toString() { - return '$runtimeType#${shortHash(this)}{album=$album}'; - } + String toString() => '$runtimeType#${shortHash(this)}{album=$album}'; } diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 2c4a4a7cc..cf46da1b2 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -62,9 +62,7 @@ class LocationFilter extends CollectionFilter { int get hashCode => hashValues(type, level, _location); @override - String toString() { - return '$runtimeType#${shortHash(this)}{level=$level, location=$_location}'; - } + String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}'; // U+0041 Latin Capital letter A // U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 9b9cb7213..2b3342140 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -88,7 +88,5 @@ class MimeFilter extends CollectionFilter { int get hashCode => hashValues(type, mime); @override - String toString() { - return '$runtimeType#${shortHash(this)}{mime=$mime}'; - } + String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}'; } diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index 23794cdd0..bb880cb2f 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -71,7 +71,5 @@ class QueryFilter extends CollectionFilter { int get hashCode => hashValues(type, query); @override - String toString() { - return '$runtimeType#${shortHash(this)}{query=$query}'; - } + String toString() => '$runtimeType#${shortHash(this)}{query=$query}'; } diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index 3188d583c..ff9e94611 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -48,7 +48,5 @@ class TagFilter extends CollectionFilter { int get hashCode => hashValues(type, tag); @override - String toString() { - return '$runtimeType#${shortHash(this)}{tag=$tag}'; - } + String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}'; } diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 56251265e..ba3c17335 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -134,9 +134,7 @@ class ImageEntry { } @override - String toString() { - return 'ImageEntry{uri=$uri, path=$path}'; - } + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, path=$path}'; set path(String path) { _path = path; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index ac10537d9..e2042d0f3 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:geocoder/model.dart'; import 'package:intl/intl.dart'; @@ -23,9 +24,7 @@ class DateMetadata { }; @override - String toString() { - return 'DateMetadata{contentId=$contentId, dateMillis=$dateMillis}'; - } + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, dateMillis=$dateMillis}'; } class CatalogMetadata { @@ -117,9 +116,7 @@ class CatalogMetadata { }; @override - String toString() { - return 'CatalogMetadata{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; - } + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, mimeType=$mimeType, dateMillis=$dateMillis, isAnimated=$isAnimated, isFlipped=$isFlipped, isGeotiff=$isGeotiff, is360=$is360, rotationDegrees=$rotationDegrees, latitude=$latitude, longitude=$longitude, xmpSubjects=$xmpSubjects, xmpTitleDescription=$xmpTitleDescription}'; } class OverlayMetadata { @@ -150,9 +147,7 @@ class OverlayMetadata { bool get isEmpty => aperture == null && exposureTime == null && focalLength == null && iso == null; @override - String toString() { - return 'OverlayMetadata{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}'; - } + String toString() => '$runtimeType#${shortHash(this)}{aperture=$aperture, exposureTime=$exposureTime, focalLength=$focalLength, iso=$iso}'; } class AddressDetails { @@ -200,9 +195,7 @@ class AddressDetails { }; @override - String toString() { - return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; - } + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; } @immutable @@ -237,7 +230,5 @@ class FavouriteRow { int get hashCode => hashValues(contentId, path); @override - String toString() { - return 'FavouriteRow{contentId=$contentId, path=$path}'; - } + String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}'; } diff --git a/lib/services/image_file_service.dart b/lib/services/image_file_service.dart index c27d72d30..71d9d9bb1 100644 --- a/lib/services/image_file_service.dart +++ b/lib/services/image_file_service.dart @@ -300,9 +300,7 @@ class ImageOpEvent { int get hashCode => hashValues(success, uri); @override - String toString() { - return 'ImageOpEvent{success=$success, uri=$uri}'; - } + String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}'; } class MoveOpEvent extends ImageOpEvent { @@ -323,9 +321,7 @@ class MoveOpEvent extends ImageOpEvent { } @override - String toString() { - return 'MoveOpEvent{success=$success, uri=$uri, newFields=$newFields}'; - } + String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, newFields=$newFields}'; } // cf flutter/foundation `consolidateHttpClientResponseBytes` diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index a86fbfee5..b31208632 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -239,12 +239,6 @@ class Constants { licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE', sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler', ), - Dependency( - name: 'Photo View', - license: 'MIT', - licenseUrl: 'https://github.com/renancaraujo/photo_view/blob/master/LICENSE', - sourceUrl: 'https://github.com/renancaraujo/photo_view', - ), Dependency( name: 'Printing', license: 'Apache 2.0', diff --git a/lib/widgets/collection/grid/list_sliver.dart b/lib/widgets/collection/grid/list_sliver.dart index 91089845c..d01c7800e 100644 --- a/lib/widgets/collection/grid/list_sliver.dart +++ b/lib/widgets/collection/grid/list_sliver.dart @@ -4,9 +4,9 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/services/viewer_service.dart'; import 'package:aves/widgets/collection/grid/list_known_extent.dart'; import 'package:aves/widgets/collection/grid/list_section_layout.dart'; -import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/collection/thumbnail/decorated.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; +import 'package:aves/widgets/common/scaling.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/common/magnifier/controller/controller.dart b/lib/widgets/common/magnifier/controller/controller.dart new file mode 100644 index 000000000..f1b3e5468 --- /dev/null +++ b/lib/widgets/common/magnifier/controller/controller.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:flutter/widgets.dart'; + +class MagnifierController { + MagnifierController({ + Offset initialPosition = Offset.zero, + }) : _valueNotifier = ValueNotifier( + MagnifierState( + position: initialPosition, + scale: null, + source: ChangeSource.internal, + ), + ), + super() { + initial = value; + prevValue = initial; + + _valueNotifier.addListener(_changeListener); + _outputCtrl = StreamController.broadcast(); + _outputCtrl.sink.add(initial); + } + + final ValueNotifier _valueNotifier; + + MagnifierState initial; + + StreamController _outputCtrl; + + /// The output for state/value updates. Usually a broadcast [Stream] + Stream get outputStateStream => _outputCtrl.stream; + + /// The state value before the last change or the initial state if the state has not been changed. + MagnifierState prevValue; + + /// Resets the state to the initial value; + void reset() { + _setValue(initial); + } + + void _changeListener() { + _outputCtrl.sink.add(value); + } + + /// Closes streams and removes eventual listeners. + void dispose() { + _outputCtrl.close(); + _valueNotifier.dispose(); + } + + void setPosition(Offset position, ChangeSource source) { + // debugPrint('$runtimeType setPosition position=$position, source=$source'); + if (value.position == position) return; + + prevValue = value; + _setValue(MagnifierState( + position: position, + scale: scale, + source: source, + )); + } + + /// The position of the image in the screen given its offset after pan gestures. + Offset get position => value.position; + + void setScale(double scale, ChangeSource source) { + // debugPrint('$runtimeType setScale scale=$scale source=$source'); + if (value.scale == scale) return; + + prevValue = value; + _setValue(MagnifierState( + position: position, + scale: scale, + source: source, + )); + } + + /// The scale factor to transform the child (image or a customChild). + double get scale => value.scale; + + /// Update multiple fields of the state with only one update streamed. + void updateMultiple({ + Offset position, + double scale, + @required ChangeSource source, + }) { + // debugPrint('$runtimeType updateMultiple position=$position scale=$scale, source=$source'); + prevValue = value; + _setValue(MagnifierState( + position: position ?? value.position, + scale: scale ?? value.scale, + source: source, + )); + } + + /// The actual state value + MagnifierState get value => _valueNotifier.value; + + void _setValue(MagnifierState newValue) { + // debugPrint('$runtimeType setValue value=$newValue'); + if (_valueNotifier.value == newValue) return; + _valueNotifier.value = newValue; + } +} diff --git a/lib/widgets/common/magnifier/controller/controller_delegate.dart b/lib/widgets/common/magnifier/controller/controller_delegate.dart new file mode 100644 index 000000000..3564135e6 --- /dev/null +++ b/lib/widgets/common/magnifier/controller/controller_delegate.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:aves/widgets/common/magnifier/core/core.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; +import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart'; +import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:flutter/widgets.dart'; + +/// A class to hold internal layout logic to sync both controller states +/// +/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers. +mixin MagnifierControllerDelegate on State { + MagnifierController get controller => widget.controller; + + MagnifierScaleStateController get scaleStateController => widget.scaleStateController; + + ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries; + + ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle; + + Alignment get basePosition => Alignment.center; + + Function(double prevScale, double nextScale, Offset nextPosition) _animateScale; + + /// Mark if scale need recalculation, useful for scale boundaries changes. + bool markNeedsScaleRecalc = true; + + final List _streamSubs = []; + + void startListeners() { + _streamSubs.add(controller.outputStateStream.listen(_onMagnifierStateChange)); + _streamSubs.add(scaleStateController.scaleStateChangeStream.listen(_onScaleStateChange)); + } + + void _onScaleStateChange(ScaleStateChange scaleStateChange) { + if (scaleStateChange.source == ChangeSource.internal) return; + if (!scaleStateController.hasChanged) return; + + if (_animateScale == null || scaleStateController.isZooming) { + controller.setScale(scale, scaleStateChange.source); + return; + } + + final nextScaleState = scaleStateChange.state; + final nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries); + var nextPosition = Offset.zero; + if (nextScaleState == ScaleState.covering || nextScaleState == ScaleState.originalSize) { + final childFocalPoint = scaleStateChange.childFocalPoint; + if (childFocalPoint != null) { + final childCenter = scaleBoundaries.childSize.center(Offset.zero); + nextPosition = (childCenter - childFocalPoint) * nextScale; + } + } + + final prevScale = controller.scale ?? getScaleForScaleState(scaleStateController.prevScaleState.state, scaleBoundaries); + _animateScale(prevScale, nextScale, nextPosition); + } + + void addAnimateOnScaleStateUpdate(void Function(double prevScale, double nextScale, Offset nextPosition) animateScale) { + _animateScale = animateScale; + } + + void _onMagnifierStateChange(MagnifierState state) { + controller.setPosition(clampPosition(), state.source); + if (controller.scale == controller.prevValue.scale) return; + + if (state.source == ChangeSource.internal || state.source == ChangeSource.animation) return; + final newScaleState = (scale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut; + scaleStateController.setScaleState(newScaleState, state.source); + } + + Offset get position => controller.position; + + double get scale { + final scaleState = scaleStateController.scaleState.state; + final needsRecalc = markNeedsScaleRecalc && !(scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut); + final scaleExistsOnController = controller.scale != null; + if (needsRecalc || !scaleExistsOnController) { + final newScale = getScaleForScaleState(scaleState, scaleBoundaries); + markNeedsScaleRecalc = false; + setScale(newScale, ChangeSource.internal); + return newScale; + } + return controller.scale; + } + + void setScale(double scale, ChangeSource source) => controller.setScale(scale, source); + + void updateMultiple({ + Offset position, + double scale, + @required ChangeSource source, + }) { + controller.updateMultiple(position: position, scale: scale, source: source); + } + + void updateScaleStateFromNewScale(double newScale, ChangeSource source) { + // debugPrint('updateScaleStateFromNewScale scale=$newScale, source=$source'); + var newScaleState = ScaleState.initial; + if (scale != scaleBoundaries.initialScale) { + newScaleState = (newScale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut; + } + scaleStateController.setScaleState(newScaleState, source); + } + + void nextScaleState(ChangeSource source, {Offset childFocalPoint}) { + // debugPrint('$runtimeType nextScaleState source=$source'); + final scaleState = scaleStateController.scaleState.state; + if (scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut) { + scaleStateController.setScaleState(scaleStateCycle(scaleState), source, childFocalPoint: childFocalPoint); + return; + } + final originalScale = getScaleForScaleState( + scaleState, + scaleBoundaries, + ); + + var prevScale = originalScale; + var prevScaleState = scaleState; + var nextScale = originalScale; + var nextScaleState = scaleState; + + do { + prevScale = nextScale; + prevScaleState = nextScaleState; + nextScaleState = scaleStateCycle(prevScaleState); + nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries); + } while (prevScale == nextScale && scaleState != nextScaleState); + + if (originalScale == nextScale) return; + scaleStateController.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint); + } + + CornersRange cornersX({double scale}) { + final _scale = scale ?? this.scale; + + final computedWidth = scaleBoundaries.childSize.width * _scale; + final screenWidth = scaleBoundaries.viewportSize.width; + + final positionX = basePosition.x; + final widthDiff = computedWidth - screenWidth; + + final minX = ((positionX - 1).abs() / 2) * widthDiff * -1; + final maxX = ((positionX + 1).abs() / 2) * widthDiff; + return CornersRange(minX, maxX); + } + + CornersRange cornersY({double scale}) { + final _scale = scale ?? this.scale; + + final computedHeight = scaleBoundaries.childSize.height * _scale; + final screenHeight = scaleBoundaries.viewportSize.height; + + final positionY = basePosition.y; + final heightDiff = computedHeight - screenHeight; + + final minY = ((positionY - 1).abs() / 2) * heightDiff * -1; + final maxY = ((positionY + 1).abs() / 2) * heightDiff; + return CornersRange(minY, maxY); + } + + Offset clampPosition({Offset position, double scale}) { + final _scale = scale ?? this.scale; + final _position = position ?? this.position; + + final computedWidth = scaleBoundaries.childSize.width * _scale; + final computedHeight = scaleBoundaries.childSize.height * _scale; + + final screenWidth = scaleBoundaries.viewportSize.width; + final screenHeight = scaleBoundaries.viewportSize.height; + + var finalX = 0.0; + if (screenWidth < computedWidth) { + final cornersX = this.cornersX(scale: _scale); + finalX = _position.dx.clamp(cornersX.min, cornersX.max); + } + + var finalY = 0.0; + if (screenHeight < computedHeight) { + final cornersY = this.cornersY(scale: _scale); + finalY = _position.dy.clamp(cornersY.min, cornersY.max); + } + + return Offset(finalX, finalY); + } + + @override + void dispose() { + _animateScale = null; + _streamSubs.forEach((sub) => sub.cancel()); + _streamSubs.clear(); + super.dispose(); + } + + double getScaleForScaleState( + ScaleState scaleState, + ScaleBoundaries scaleBoundaries, + ) { + double _clamp(double scale, ScaleBoundaries boundaries) => scale.clamp(boundaries.minScale, boundaries.maxScale); + + switch (scaleState) { + case ScaleState.initial: + case ScaleState.zoomedIn: + case ScaleState.zoomedOut: + return _clamp(scaleBoundaries.initialScale, scaleBoundaries); + case ScaleState.covering: + return _clamp(ScaleLevel.scaleForCovering(scaleBoundaries.viewportSize, scaleBoundaries.childSize), scaleBoundaries); + case ScaleState.originalSize: + return _clamp(1.0, scaleBoundaries); + default: + return null; + } + } +} + +/// Simple class to store a min and a max value +class CornersRange { + const CornersRange(this.min, this.max); + + final double min; + final double max; +} diff --git a/lib/widgets/common/magnifier/controller/state.dart b/lib/widgets/common/magnifier/controller/state.dart new file mode 100644 index 000000000..6185a1707 --- /dev/null +++ b/lib/widgets/common/magnifier/controller/state.dart @@ -0,0 +1,28 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class MagnifierState { + const MagnifierState({ + @required this.position, + @required this.scale, + @required this.source, + }); + + final Offset position; + final double scale; + final ChangeSource source; + + @override + bool operator ==(Object other) => identical(this, other) || other is MagnifierState && runtimeType == other.runtimeType && position == other.position && scale == other.scale; + + @override + int get hashCode => hashValues(position, scale, source); + + @override + String toString() => '$runtimeType#${shortHash(this)}{position: $position, scale: $scale, source: $source}'; +} + +enum ChangeSource { internal, gesture, animation } diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart new file mode 100644 index 000000000..bf4efd134 --- /dev/null +++ b/lib/widgets/common/magnifier/core/core.dart @@ -0,0 +1,290 @@ +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/controller/controller_delegate.dart'; +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:aves/widgets/common/magnifier/core/gesture_detector.dart'; +import 'package:aves/widgets/common/magnifier/magnifier.dart'; +import 'package:aves/widgets/common/magnifier/pan/corner_hit_detector.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; +import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart'; +import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:flutter/widgets.dart'; + +/// Internal widget in which controls all animations lifecycle, core responses +/// to user gestures, updates to the controller state and mounts the entire Layout +class MagnifierCore extends StatefulWidget { + const MagnifierCore({ + Key key, + @required this.child, + @required this.onTap, + @required this.gestureDetectorBehavior, + @required this.controller, + @required this.scaleBoundaries, + @required this.scaleStateCycle, + @required this.scaleStateController, + @required this.applyScale, + }) : super(key: key); + + final Widget child; + + final MagnifierController controller; + final MagnifierScaleStateController scaleStateController; + final ScaleBoundaries scaleBoundaries; + final ScaleStateCycle scaleStateCycle; + + final MagnifierTapCallback onTap; + + final HitTestBehavior gestureDetectorBehavior; + final bool applyScale; + + @override + State createState() { + return MagnifierCoreState(); + } +} + +class MagnifierCoreState extends State with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector { + Offset _normalizedPosition; + double _scaleBefore; + + AnimationController _scaleAnimationController; + Animation _scaleAnimation; + + AnimationController _positionAnimationController; + Animation _positionAnimation; + + ScaleBoundaries cachedScaleBoundaries; + + void handleScaleAnimation() { + setScale(_scaleAnimation.value, ChangeSource.animation); + } + + void handlePositionAnimate() { + controller.setPosition(_positionAnimation.value, ChangeSource.animation); + } + + void onScaleStart(ScaleStartDetails details) { + _scaleBefore = scale; + _normalizedPosition = details.focalPoint - controller.position; + _scaleAnimationController.stop(); + _positionAnimationController.stop(); + } + + void onScaleUpdate(ScaleUpdateDetails details) { + final newScale = _scaleBefore * details.scale; + final delta = details.focalPoint - _normalizedPosition; + + updateScaleStateFromNewScale(newScale, ChangeSource.gesture); + + // + updateMultiple( + scale: newScale, + position: clampPosition(position: delta * details.scale), + source: ChangeSource.gesture, + ); + } + + void onScaleEnd(ScaleEndDetails details) { + final _scale = scale; + final _position = controller.position; + final maxScale = scaleBoundaries.maxScale; + final minScale = scaleBoundaries.minScale; + + //animate back to maxScale if gesture exceeded the maxScale specified + if (_scale > maxScale) { + final scaleComebackRatio = maxScale / _scale; + animateScale(_scale, maxScale); + final clampedPosition = clampPosition( + position: _position * scaleComebackRatio, + scale: maxScale, + ); + animatePosition(_position, clampedPosition); + return; + } + + //animate back to minScale if gesture fell smaller than the minScale specified + if (_scale < minScale) { + final scaleComebackRatio = minScale / _scale; + animateScale(_scale, minScale); + animatePosition( + _position, + clampPosition( + position: _position * scaleComebackRatio, + scale: minScale, + ), + ); + return; + } + // get magnitude from gesture velocity + final magnitude = details.velocity.pixelsPerSecond.distance; + + // animate velocity only if there is no scale change and a significant magnitude + if (_scaleBefore / _scale == 1.0 && magnitude >= 400.0) { + final direction = details.velocity.pixelsPerSecond / magnitude; + animatePosition( + _position, + clampPosition(position: _position + direction * 100.0), + ); + } + } + + void onTap(TapUpDetails details) { + if (widget.onTap == null) return; + + final viewportTapPosition = details.localPosition; + final childTapPosition = scaleBoundaries.toChildPosition(controller, viewportTapPosition); + widget.onTap.call(context, details, controller.value, childTapPosition); + } + + void onDoubleTap(TapDownDetails details) { + final viewportTapPosition = details?.localPosition; + final childTapPosition = scaleBoundaries.toChildPosition(controller, viewportTapPosition); + nextScaleState(ChangeSource.gesture, childFocalPoint: childTapPosition); + } + + void animateScale(double from, double to) { + _scaleAnimation = Tween( + begin: from, + end: to, + ).animate(_scaleAnimationController); + _scaleAnimationController + ..value = 0.0 + ..fling(velocity: 0.4); + } + + void animatePosition(Offset from, Offset to) { + _positionAnimation = Tween(begin: from, end: to).animate(_positionAnimationController); + _positionAnimationController + ..value = 0.0 + ..fling(velocity: 0.4); + } + + void onAnimationStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) { + onAnimationStatusCompleted(); + } + } + + /// Check if scale is equal to initial after scale animation update + void onAnimationStatusCompleted() { + if (scaleStateController.scaleState.state != ScaleState.initial && scale == scaleBoundaries.initialScale) { + scaleStateController.setScaleState(ScaleState.initial, ChangeSource.animation); + } + } + + @override + void initState() { + super.initState(); + _scaleAnimationController = AnimationController(vsync: this)..addListener(handleScaleAnimation); + _scaleAnimationController.addStatusListener(onAnimationStatus); + + _positionAnimationController = AnimationController(vsync: this)..addListener(handlePositionAnimate); + + startListeners(); + addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate); + + cachedScaleBoundaries = widget.scaleBoundaries; + } + + void animateOnScaleStateUpdate(double prevScale, double nextScale, Offset nextPosition) { + animateScale(prevScale, nextScale); + animatePosition(controller.position, nextPosition); + } + + @override + void dispose() { + _scaleAnimationController.removeStatusListener(onAnimationStatus); + _scaleAnimationController.dispose(); + _positionAnimationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Check if we need a recalc on the scale + if (widget.scaleBoundaries != cachedScaleBoundaries) { + markNeedsScaleRecalc = true; + cachedScaleBoundaries = widget.scaleBoundaries; + } + + return StreamBuilder( + stream: controller.outputStateStream, + initialData: controller.prevValue, + builder: (context, snapshot) { + if (snapshot.hasData) { + final value = snapshot.data; + final applyScale = widget.applyScale; + + final computedScale = applyScale ? scale : 1.0; + + final matrix = Matrix4.identity() + ..translate(value.position.dx, value.position.dy) + ..scale(computedScale); + + final Widget customChildLayout = CustomSingleChildLayout( + delegate: _CenterWithOriginalSizeDelegate( + scaleBoundaries.childSize, + basePosition, + applyScale, + ), + child: widget.child, + ); + return MagnifierGestureDetector( + child: Transform( + child: customChildLayout, + transform: matrix, + alignment: basePosition, + ), + onDoubleTap: onDoubleTap, + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onScaleEnd: onScaleEnd, + hitDetector: this, + onTapUp: widget.onTap == null ? null : onTap, + ); + } else { + return Container(); + } + }); + } +} + +class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate { + const _CenterWithOriginalSizeDelegate( + this.subjectSize, + this.basePosition, + this.applyScale, + ); + + final Size subjectSize; + final Alignment basePosition; + final bool applyScale; + + @override + Offset getPositionForChild(Size size, Size childSize) { + final childWidth = applyScale ? subjectSize.width : childSize.width; + final childHeight = applyScale ? subjectSize.height : childSize.height; + + final halfWidth = (size.width - childWidth) / 2; + final halfHeight = (size.height - childHeight) / 2; + + final offsetX = halfWidth * (basePosition.x + 1); + final offsetY = halfHeight * (basePosition.y + 1); + return Offset(offsetX, offsetY); + } + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + return applyScale ? BoxConstraints.tight(subjectSize) : BoxConstraints(); + } + + @override + bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) { + return oldDelegate != this; + } + + @override + bool operator ==(Object other) => identical(this, other) || other is _CenterWithOriginalSizeDelegate && runtimeType == other.runtimeType && subjectSize == other.subjectSize && basePosition == other.basePosition && applyScale == other.applyScale; + + @override + int get hashCode => hashValues(subjectSize, basePosition, applyScale); +} diff --git a/lib/widgets/common/magnifier/core/gesture_detector.dart b/lib/widgets/common/magnifier/core/gesture_detector.dart new file mode 100644 index 000000000..933c06b41 --- /dev/null +++ b/lib/widgets/common/magnifier/core/gesture_detector.dart @@ -0,0 +1,231 @@ +import 'dart:math'; + +import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../pan/corner_hit_detector.dart'; + +class MagnifierGestureDetector extends StatefulWidget { + const MagnifierGestureDetector({ + Key key, + this.hitDetector, + this.onScaleStart, + this.onScaleUpdate, + this.onScaleEnd, + this.onTapDown, + this.onTapUp, + this.onDoubleTap, + this.behavior, + this.child, + }) : super(key: key); + + final CornerHitDetector hitDetector; + final GestureScaleStartCallback onScaleStart; + final GestureScaleUpdateCallback onScaleUpdate; + final GestureScaleEndCallback onScaleEnd; + + final GestureTapDownCallback onTapDown; + final GestureTapUpCallback onTapUp; + final GestureTapDownCallback onDoubleTap; + + final HitTestBehavior behavior; + final Widget child; + + @override + _MagnifierGestureDetectorState createState() => _MagnifierGestureDetectorState(); +} + +class _MagnifierGestureDetectorState extends State { + TapDownDetails doubleTapDetails; + + @override + Widget build(BuildContext context) { + final scope = MagnifierGestureDetectorScope.of(context); + + final axis = scope?.axis; + final touchSlopFactor = scope?.touchSlopFactor; + + final gestures = {}; + + if (widget.onTapDown != null || widget.onTapUp != null) { + gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(debugOwner: this), + (instance) { + instance + ..onTapDown = widget.onTapDown + ..onTapUp = widget.onTapUp; + }, + ); + } + + gestures[MagnifierGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => MagnifierGestureRecognizer( + hitDetector: widget.hitDetector, + debugOwner: this, + validateAxis: axis, + touchSlopFactor: touchSlopFactor, + ), + (instance) { + instance + ..onStart = widget.onScaleStart + ..onUpdate = widget.onScaleUpdate + ..onEnd = widget.onScaleEnd; + }, + ); + + gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( + () => DoubleTapGestureRecognizer(debugOwner: this), + (instance) { + instance.onDoubleTapCancel = () => doubleTapDetails = null; + instance.onDoubleTapDown = (details) => doubleTapDetails = details; + instance.onDoubleTap = () { + widget.onDoubleTap(doubleTapDetails); + doubleTapDetails = null; + }; + }, + ); + + return RawGestureDetector( + child: widget.child, + gestures: gestures, + behavior: widget.behavior ?? HitTestBehavior.translucent, + ); + } +} + +class MagnifierGestureRecognizer extends ScaleGestureRecognizer { + MagnifierGestureRecognizer({ + this.hitDetector, + Object debugOwner, + this.validateAxis, + this.touchSlopFactor = 2, + PointerDeviceKind kind, + }) : super(debugOwner: debugOwner, kind: kind); + final CornerHitDetector hitDetector; + final List validateAxis; + final double touchSlopFactor; + + Map _pointerLocations = {}; + + Offset _initialFocalPoint; + Offset _currentFocalPoint; + double _initialSpan; + double _currentSpan; + + bool ready = true; + + @override + void addAllowedPointer(PointerEvent event) { + if (ready) { + ready = false; + _initialSpan = 0.0; + _currentSpan = 0.0; + _pointerLocations = {}; + } + super.addAllowedPointer(event); + } + + @override + void didStopTrackingLastPointer(int pointer) { + ready = true; + super.didStopTrackingLastPointer(pointer); + } + + @override + void handleEvent(PointerEvent event) { + if (validateAxis != null && validateAxis.isNotEmpty) { + var didChangeConfiguration = false; + if (event is PointerMoveEvent) { + if (!event.synthesized) { + _pointerLocations[event.pointer] = event.position; + } + } else if (event is PointerDownEvent) { + _pointerLocations[event.pointer] = event.position; + didChangeConfiguration = true; + } else if (event is PointerUpEvent || event is PointerCancelEvent) { + _pointerLocations.remove(event.pointer); + didChangeConfiguration = true; + } + + _updateDistances(); + + if (didChangeConfiguration) { + // cf super._reconfigure + _initialFocalPoint = _currentFocalPoint; + _initialSpan = _currentSpan; + } + + _decideIfWeAcceptEvent(event); + } + super.handleEvent(event); + } + + void _updateDistances() { + // cf super._update + final count = _pointerLocations.keys.length; + + // Compute the focal point + var focalPoint = Offset.zero; + for (final pointer in _pointerLocations.keys) { + focalPoint += _pointerLocations[pointer]; + } + _currentFocalPoint = count > 0 ? focalPoint / count.toDouble() : Offset.zero; + + // Span is the average deviation from focal point. Horizontal and vertical + // spans are the average deviations from the focal point's horizontal and + // vertical coordinates, respectively. + var totalDeviation = 0.0; + for (final pointer in _pointerLocations.keys) { + totalDeviation += (_currentFocalPoint - _pointerLocations[pointer]).distance; + } + _currentSpan = count > 0 ? totalDeviation / count : 0.0; + } + + void _decideIfWeAcceptEvent(PointerEvent event) { + if (!(event is PointerMoveEvent)) { + return; + } + + if (_pointerLocations.keys.length >= 2) { + // when there are multiple pointers, we always accept the gesture to scale + // as this is not competing with single taps or other drag gestures + acceptGesture(event.pointer); + return; + } + + final move = _initialFocalPoint - _currentFocalPoint; + var shouldMove = false; + if (validateAxis.length == 2) { + // the image is the descendant of gesture detector(s) handling drag in both directions + final shouldMoveX = validateAxis.contains(Axis.horizontal) && hitDetector.shouldMoveX(move); + final shouldMoveY = validateAxis.contains(Axis.vertical) && hitDetector.shouldMoveY(move); + if (shouldMoveX == shouldMoveY) { + // consistently can/cannot pan the image in both direction the same way + shouldMove = shouldMoveX; + } else { + // can pan the image in one direction, but should yield to an ascendant gesture detector in the other one + final d = move.direction; + // the gesture direction angle is in ]-pi, pi], cf `Offset` doc for details + final xPan = (-pi / 4 < d && d < pi / 4) || (3 / 4 * pi < d && d <= pi) || (-pi < d && d < -3 / 4 * pi); + final yPan = (pi / 4 < d && d < 3 / 4 * pi) || (-3 / 4 * pi < d && d < -pi / 4); + shouldMove = (xPan && shouldMoveX) || (yPan && shouldMoveY); + } + } else { + // the image is the descendant of a gesture detector handling drag in one direction + shouldMove = validateAxis.contains(Axis.vertical) ? hitDetector.shouldMoveY(move) : hitDetector.shouldMoveX(move); + } + if (shouldMove) { + final spanDelta = (_currentSpan - _initialSpan).abs(); + final focalPointDelta = (_currentFocalPoint - _initialFocalPoint).distance; + // warning: do not compare `focalPointDelta` to `kPanSlop` + // `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop` + // and the magnifier recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView` + // setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0` + // setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView` + if (spanDelta > kScaleSlop || focalPointDelta > kTouchSlop * touchSlopFactor) { + acceptGesture(event.pointer); + } + } + } +} diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart new file mode 100644 index 000000000..156bdf306 --- /dev/null +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -0,0 +1,169 @@ +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:aves/widgets/common/magnifier/core/core.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; +import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart'; +import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:flutter/material.dart'; + +/// `Magnifier` is derived from `photo_view` package v0.9.2: +/// - removed image related aspects to focus on a general purpose pan/scale viewer (à la `InteractiveViewer`) +/// - removed rotation and many customization parameters +/// - removed ignorable/ignoring partial notifiers +/// - formatted, renamed and reorganized +/// - fixed gesture recognizers when used inside a scrollable widget like `PageView` +/// - fixed corner hit detection when in containers scrollable in both axes +/// - fixed corner hit detection issues due to imprecise double comparisons +/// - added single & double tap position feedback +/// - fixed focusing on tap position when scaling by double tap +class Magnifier extends StatefulWidget { + const Magnifier({ + Key key, + @required this.child, + this.childSize, + this.controller, + this.scaleStateController, + this.maxScale, + this.minScale, + this.initialScale, + this.scaleStateCycle, + this.onTap, + this.gestureDetectorBehavior, + this.applyScale, + }) : super(key: key); + + final Widget child; + + /// The size of the custom [child]. This value is used to compute the relation between the child and the container's size to calculate the scale value. + final Size childSize; + + /// Defines the maximum size in which the image will be allowed to assume, it is proportional to the original image size. + final ScaleLevel maxScale; + + /// Defines the minimum size in which the image will be allowed to assume, it is proportional to the original image size. + final ScaleLevel minScale; + + /// Defines the size the image will assume when the component is initialized, it is proportional to the original image size. + final ScaleLevel initialScale; + + final MagnifierController controller; + final MagnifierScaleStateController scaleStateController; + final ScaleStateCycle scaleStateCycle; + final MagnifierTapCallback onTap; + final HitTestBehavior gestureDetectorBehavior; + final bool applyScale; + + @override + State createState() { + return _MagnifierState(); + } +} + +class _MagnifierState extends State { + Size _childSize; + + bool _controlledController; + MagnifierController _controller; + + bool _controlledScaleStateController; + MagnifierScaleStateController _scaleStateController; + + void _setChildSize(Size childSize) { + _childSize = childSize; + } + + @override + void initState() { + super.initState(); + _setChildSize(widget.childSize); + if (widget.controller == null) { + _controlledController = true; + _controller = MagnifierController(); + } else { + _controlledController = false; + _controller = widget.controller; + } + + if (widget.scaleStateController == null) { + _controlledScaleStateController = true; + _scaleStateController = MagnifierScaleStateController(); + } else { + _controlledScaleStateController = false; + _scaleStateController = widget.scaleStateController; + } + } + + @override + void didUpdateWidget(Magnifier oldWidget) { + if (oldWidget.childSize != widget.childSize && widget.childSize != null) { + setState(() { + _setChildSize(widget.childSize); + }); + } + if (widget.controller == null) { + if (!_controlledController) { + _controlledController = true; + _controller = MagnifierController(); + } + } else { + _controlledController = false; + _controller = widget.controller; + } + + if (widget.scaleStateController == null) { + if (!_controlledScaleStateController) { + _controlledScaleStateController = true; + _scaleStateController = MagnifierScaleStateController(); + } + } else { + _controlledScaleStateController = false; + _scaleStateController = widget.scaleStateController; + } + super.didUpdateWidget(oldWidget); + } + + @override + void dispose() { + if (_controlledController) { + _controller.dispose(); + } + if (_controlledScaleStateController) { + _scaleStateController.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final scaleBoundaries = ScaleBoundaries( + widget.minScale ?? 0.0, + widget.maxScale ?? ScaleLevel(factor: double.infinity), + widget.initialScale ?? ScaleLevel(ref: ScaleReference.contained), + constraints.biggest, + _childSize ?? constraints.biggest, + ); + + return MagnifierCore( + child: widget.child, + controller: _controller, + scaleStateController: _scaleStateController, + scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle, + scaleBoundaries: scaleBoundaries, + onTap: widget.onTap, + gestureDetectorBehavior: widget.gestureDetectorBehavior, + applyScale: widget.applyScale ?? true, + ); + }, + ); + } +} + +typedef MagnifierTapCallback = Function( + BuildContext context, + TapUpDetails details, + MagnifierState state, + Offset childTapPosition, +); diff --git a/lib/widgets/common/magnifier/pan/corner_hit_detector.dart b/lib/widgets/common/magnifier/pan/corner_hit_detector.dart new file mode 100644 index 000000000..482b39f5b --- /dev/null +++ b/lib/widgets/common/magnifier/pan/corner_hit_detector.dart @@ -0,0 +1,76 @@ +import 'package:aves/widgets/common/magnifier/controller/controller_delegate.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +mixin CornerHitDetector on MagnifierControllerDelegate { + _AxisHit hitAxis() => _AxisHit(_hitCornersX(), _hitCornersY()); + + // the child width/height is not accurate for some image size & scale combos + // e.g. 3580.0 * 0.1005586592178771 yields 360.0 + // but 4764.0 * 0.07556675062972293 yields 360.00000000000006 + // so be sure to compare with `precisionErrorTolerance` + + _CornerHit _hitCornersX() { + final childWidth = scaleBoundaries.childSize.width * scale; + final viewportWidth = scaleBoundaries.viewportSize.width; + if (viewportWidth + precisionErrorTolerance >= childWidth) { + return _CornerHit(true, true); + } + final x = -position.dx; + final cornersX = this.cornersX(); + return _CornerHit(x <= cornersX.min, x >= cornersX.max); + } + + _CornerHit _hitCornersY() { + final childHeight = scaleBoundaries.childSize.height * scale; + final viewportHeight = scaleBoundaries.viewportSize.height; + if (viewportHeight + precisionErrorTolerance >= childHeight) { + return _CornerHit(true, true); + } + final y = -position.dy; + final cornersY = this.cornersY(); + return _CornerHit(y <= cornersY.min, y >= cornersY.max); + } + + bool shouldMoveX(Offset move) { + final hitCornersX = _hitCornersX(); + if (hitCornersX.hasHitAny && move != Offset.zero) { + if (hitCornersX.hasHitBoth) return false; + if (hitCornersX.hasHitMax) return move.dx < 0; + return move.dx > 0; + } + return true; + } + + bool shouldMoveY(Offset move) { + final hitCornersY = _hitCornersY(); + if (hitCornersY.hasHitAny && move != Offset.zero) { + if (hitCornersY.hasHitBoth) return false; + if (hitCornersY.hasHitMax) return move.dy < 0; + return move.dy > 0; + } + return true; + } +} + +class _AxisHit { + _AxisHit(this.hasHitX, this.hasHitY); + + final _CornerHit hasHitX; + final _CornerHit hasHitY; + + bool get hasHitAny => hasHitX.hasHitAny || hasHitY.hasHitAny; + + bool get hasHitBoth => hasHitX.hasHitBoth && hasHitY.hasHitBoth; +} + +class _CornerHit { + const _CornerHit(this.hasHitMin, this.hasHitMax); + + final bool hasHitMin; + final bool hasHitMax; + + bool get hasHitAny => hasHitMin || hasHitMax; + + bool get hasHitBoth => hasHitMin && hasHitMax; +} diff --git a/lib/widgets/common/magnifier/pan/gesture_detector_scope.dart b/lib/widgets/common/magnifier/pan/gesture_detector_scope.dart new file mode 100644 index 000000000..8eaee4f69 --- /dev/null +++ b/lib/widgets/common/magnifier/pan/gesture_detector_scope.dart @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; + +/// When a `Magnifier` is wrapped in this inherited widget, +/// it will check whether the zoomed content has hit edges, +/// and if so, will let parent gesture detectors win the gesture arena +/// +/// Useful when placing Magnifier inside a gesture sensitive context, +/// such as [PageView], [Dismissible], [BottomSheet]. +class MagnifierGestureDetectorScope extends InheritedWidget { + const MagnifierGestureDetectorScope({ + this.axis, + this.touchSlopFactor = .8, + @required Widget child, + }) : super(child: child); + + static MagnifierGestureDetectorScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + return scope; + } + + final List axis; + + // in [0, 1[ + // 0: most reactive but will not let tap recognizers accept gestures + // <1: less reactive but gives the most leeway to other recognizers + // 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree + final double touchSlopFactor; + + @override + bool updateShouldNotify(MagnifierGestureDetectorScope oldWidget) { + return axis != oldWidget.axis && touchSlopFactor != oldWidget.touchSlopFactor; + } +} diff --git a/lib/widgets/common/magnifier/pan/scroll_physics.dart b/lib/widgets/common/magnifier/pan/scroll_physics.dart new file mode 100644 index 000000000..9f8e14d13 --- /dev/null +++ b/lib/widgets/common/magnifier/pan/scroll_physics.dart @@ -0,0 +1,29 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +// `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer` +// this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop` +// we cannot change that, but we can prevent the scrollable from panning until this threshold is reached +// and let other recognizers accept the gesture instead +class MagnifierScrollerPhysics extends ScrollPhysics { + const MagnifierScrollerPhysics({ + this.touchSlopFactor = 1, + ScrollPhysics parent, + }) : super(parent: parent); + + // in [0, 1] + // 0: most reactive but will not let Magnifier recognizers accept gestures + // 1: less reactive but gives the most leeway to Magnifier recognizers + final double touchSlopFactor; + + @override + MagnifierScrollerPhysics applyTo(ScrollPhysics ancestor) { + return MagnifierScrollerPhysics( + touchSlopFactor: touchSlopFactor, + parent: buildParent(ancestor), + ); + } + + @override + double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor; +} diff --git a/lib/widgets/common/magnifier/scale/scale_boundaries.dart b/lib/widgets/common/magnifier/scale/scale_boundaries.dart new file mode 100644 index 000000000..e76561574 --- /dev/null +++ b/lib/widgets/common/magnifier/scale/scale_boundaries.dart @@ -0,0 +1,60 @@ +import 'dart:ui'; + +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; +import 'package:flutter/foundation.dart'; + +/// Internal class to wrap custom scale boundaries (min, max and initial) +/// Also, stores values regarding the two sizes: the container and the child. +class ScaleBoundaries { + const ScaleBoundaries( + this._minScale, + this._maxScale, + this._initialScale, + this.viewportSize, + this.childSize, + ); + + final ScaleLevel _minScale; + final ScaleLevel _maxScale; + final ScaleLevel _initialScale; + final Size viewportSize; + final Size childSize; + + double _scaleForLevel(ScaleLevel level) { + final factor = level.factor; + switch (level.ref) { + case ScaleReference.contained: + return factor * ScaleLevel.scaleForContained(viewportSize, childSize); + case ScaleReference.covered: + return factor * ScaleLevel.scaleForCovering(viewportSize, childSize); + case ScaleReference.absolute: + default: + return factor; + } + } + + double get minScale => _scaleForLevel(_minScale); + + double get maxScale => _scaleForLevel(_maxScale).clamp(minScale, double.infinity); + + double get initialScale => _scaleForLevel(_initialScale).clamp(minScale, maxScale); + + Offset toChildPosition(MagnifierController controller, Offset viewportPosition) { + final position = controller.position; + final scale = controller.scale; + final viewportCenter = viewportSize.center(Offset.zero); + final childCenter = childSize.center(Offset.zero); + final childPosition = (viewportPosition - viewportCenter) / scale - position / scale + childCenter; + return childPosition; + } + + @override + bool operator ==(Object other) => identical(this, other) || other is ScaleBoundaries && runtimeType == other.runtimeType && _minScale == other._minScale && _maxScale == other._maxScale && _initialScale == other._initialScale && viewportSize == other.viewportSize && childSize == other.childSize; + + @override + int get hashCode => hashValues(_minScale, _maxScale, _initialScale, viewportSize, childSize); + + @override + String toString() => '$runtimeType#${shortHash(this)}{viewportSize=$viewportSize, childSize=$childSize, initialScale=$initialScale, minScale=$minScale, maxScale=$maxScale}'; +} diff --git a/lib/widgets/common/magnifier/scale/scale_level.dart b/lib/widgets/common/magnifier/scale/scale_level.dart new file mode 100644 index 000000000..ac7b5b1a4 --- /dev/null +++ b/lib/widgets/common/magnifier/scale/scale_level.dart @@ -0,0 +1,32 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; + +class ScaleLevel { + final ScaleReference ref; + final double factor; + + const ScaleLevel({ + this.ref = ScaleReference.absolute, + this.factor = 1.0, + }); + + static double scaleForContained(Size containerSize, Size childSize) => min(containerSize.width / childSize.width, containerSize.height / childSize.height); + + static double scaleForCovering(Size containerSize, Size childSize) => max(containerSize.width / childSize.width, containerSize.height / childSize.height); + + @override + String toString() => '$runtimeType#${shortHash(this)}{ref=$ref, factor=$factor}'; + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) return false; + return other is ScaleLevel && other.ref == ref && other.factor == factor; + } + + @override + int get hashCode => hashValues(ref, factor); +} + +enum ScaleReference { absolute, contained, covered } diff --git a/lib/widgets/common/magnifier/scale/scalestate_controller.dart b/lib/widgets/common/magnifier/scale/scalestate_controller.dart new file mode 100644 index 000000000..1296fe8de --- /dev/null +++ b/lib/widgets/common/magnifier/scale/scalestate_controller.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:aves/widgets/common/magnifier/scale/state.dart'; +import 'package:flutter/rendering.dart'; + +typedef ScaleStateListener = void Function(double prevScale, double nextScale); + +class MagnifierScaleStateController { + ScaleStateChange _scaleState; + StreamController _outputScaleStateCtrl; + ScaleStateChange prevScaleState; + + Stream get scaleStateChangeStream => _outputScaleStateCtrl.stream; + + ScaleStateChange get scaleState => _scaleState; + + bool get hasChanged => prevScaleState != scaleState; + + bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut; + + MagnifierScaleStateController() { + _scaleState = ScaleStateChange(state: ScaleState.initial, source: ChangeSource.internal); + prevScaleState = _scaleState; + + _outputScaleStateCtrl = StreamController.broadcast(); + _outputScaleStateCtrl.sink.add(_scaleState); + } + + void dispose() { + _outputScaleStateCtrl.close(); + } + + void setScaleState(ScaleState newValue, ChangeSource source, {Offset childFocalPoint}) { + if (_scaleState.state == newValue) return; + + prevScaleState = _scaleState; + _scaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint); + _outputScaleStateCtrl.sink.add(scaleState); + } +} diff --git a/lib/widgets/common/magnifier/scale/state.dart b/lib/widgets/common/magnifier/scale/state.dart new file mode 100644 index 000000000..81595109e --- /dev/null +++ b/lib/widgets/common/magnifier/scale/state.dart @@ -0,0 +1,53 @@ +import 'dart:ui'; + +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +@immutable +class ScaleStateChange { + const ScaleStateChange({ + @required this.state, + @required this.source, + this.childFocalPoint, + }); + + final ScaleState state; + final ChangeSource source; + final Offset childFocalPoint; + + @override + bool operator ==(Object other) => identical(this, other) || other is ScaleStateChange && runtimeType == other.runtimeType && state == other.state && childFocalPoint == other.childFocalPoint; + + @override + int get hashCode => hashValues(state, source, childFocalPoint); + + @override + String toString() => '$runtimeType#${shortHash(this)}{scaleState: $state, source: $source, childFocalPoint: $childFocalPoint}'; +} + +enum ScaleState { + initial, + covering, + originalSize, + zoomedIn, + zoomedOut, +} + +ScaleState defaultScaleStateCycle(ScaleState actual) { + switch (actual) { + case ScaleState.initial: + return ScaleState.covering; + case ScaleState.covering: + return ScaleState.originalSize; + case ScaleState.originalSize: + return ScaleState.initial; + case ScaleState.zoomedIn: + case ScaleState.zoomedOut: + return ScaleState.initial; + default: + return ScaleState.initial; + } +} + +typedef ScaleStateCycle = ScaleState Function(ScaleState actual); diff --git a/lib/widgets/fullscreen/fullscreen_body.dart b/lib/widgets/fullscreen/fullscreen_body.dart index 1363a2ec2..e7b3791bd 100644 --- a/lib/widgets/fullscreen/fullscreen_body.dart +++ b/lib/widgets/fullscreen/fullscreen_body.dart @@ -8,6 +8,7 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/fullscreen/entry_action_delegate.dart'; import 'package:aves/widgets/fullscreen/image_page.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; @@ -22,7 +23,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; -import 'package:photo_view/photo_view.dart'; import 'package:provider/provider.dart'; import 'package:screen/screen.dart'; import 'package:tuple/tuple.dart'; @@ -557,7 +557,7 @@ class _FullscreenVerticalPageViewState extends State key: Key('vertical-pageview'), scrollDirection: Axis.vertical, controller: widget.verticalPager, - physics: PhotoViewPageViewScrollPhysics(parent: PageScrollPhysics()), + physics: MagnifierScrollerPhysics(parent: PageScrollPhysics()), onPageChanged: (page) { widget.onVerticalPageChanged(page); _infoPageVisibleNotifier.value = page == pages.length - 1; diff --git a/lib/widgets/fullscreen/image_page.dart b/lib/widgets/fullscreen/image_page.dart index a95fc1b1d..7046661e5 100644 --- a/lib/widgets/fullscreen/image_page.dart +++ b/lib/widgets/fullscreen/image_page.dart @@ -1,9 +1,10 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; +import 'package:aves/widgets/common/magnifier/pan/scroll_physics.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; -import 'package:photo_view/photo_view.dart'; import 'package:tuple/tuple.dart'; class MultiImagePage extends StatefulWidget { @@ -34,13 +35,13 @@ class MultiImagePageState extends State with AutomaticKeepAliveC Widget build(BuildContext context) { super.build(context); - return PhotoViewGestureDetectorScope( + return MagnifierGestureDetectorScope( axis: [Axis.horizontal, Axis.vertical], child: PageView.builder( key: Key('horizontal-pageview'), scrollDirection: Axis.horizontal, controller: widget.pageController, - physics: PhotoViewPageViewScrollPhysics(parent: BouncingScrollPhysics()), + physics: MagnifierScrollerPhysics(parent: BouncingScrollPhysics()), onPageChanged: widget.onPageChanged, itemBuilder: (context, index) { final entry = entries[index]; @@ -49,7 +50,7 @@ class MultiImagePageState extends State with AutomaticKeepAliveC key: Key('imageview'), entry: entry, heroTag: widget.collection.heroTag(entry), - onTap: widget.onTap, + onTap: (_) => widget.onTap?.call(), videoControllers: widget.videoControllers, onDisposed: () => widget.onViewDisposed?.call(entry.uri), ), @@ -84,11 +85,11 @@ class SingleImagePageState extends State with AutomaticKeepAliv Widget build(BuildContext context) { super.build(context); - return PhotoViewGestureDetectorScope( + return MagnifierGestureDetectorScope( axis: [Axis.vertical], child: ImageView( entry: widget.entry, - onTap: widget.onTap, + onTap: (_) => widget.onTap?.call(), videoControllers: widget.videoControllers, ), ); diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 3a9b22719..0f0beb658 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,26 +1,30 @@ import 'dart:async'; import 'package:aves/image_providers/thumbnail_provider.dart'; -import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/collection/empty.dart'; +import 'package:aves/widgets/common/magnifier/controller/controller.dart'; +import 'package:aves/widgets/common/magnifier/controller/state.dart'; +import 'package:aves/widgets/common/magnifier/magnifier.dart'; +import 'package:aves/widgets/common/magnifier/scale/scale_level.dart'; +import 'package:aves/widgets/common/magnifier/scale/scalestate_controller.dart'; +import 'package:aves/widgets/common/magnifier/scale/state.dart'; import 'package:aves/widgets/fullscreen/tiled_view.dart'; import 'package:aves/widgets/fullscreen/video_view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_ijkplayer/flutter_ijkplayer.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:photo_view/photo_view.dart'; import 'package:provider/provider.dart'; import 'package:tuple/tuple.dart'; class ImageView extends StatefulWidget { final ImageEntry entry; final Object heroTag; - final VoidCallback onTap; + final MagnifierTapCallback onTap; final List> videoControllers; final VoidCallback onDisposed; @@ -38,26 +42,25 @@ class ImageView extends StatefulWidget { } class _ImageViewState extends State { - final PhotoViewController _photoViewController = PhotoViewController(); - final PhotoViewScaleStateController _photoViewScaleStateController = PhotoViewScaleStateController(); + final MagnifierController _magnifierController = MagnifierController(); + final MagnifierScaleStateController _magnifierScaleStateController = MagnifierScaleStateController(); final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); - StreamSubscription _subscription; - Size _photoViewChildSize; + StreamSubscription _subscription; + Size _magnifierChildSize; - static const backgroundDecoration = BoxDecoration(color: Colors.transparent); - static const maxScale = 2.0; + static const initialScale = ScaleLevel(ref: ScaleReference.contained); + static const minScale = ScaleLevel(ref: ScaleReference.contained); + static const maxScale = ScaleLevel(factor: 2.0); ImageEntry get entry => widget.entry; - VoidCallback get onTap => widget.onTap; + MagnifierTapCallback get onTap => widget.onTap; @override void initState() { super.initState(); - _subscription = _photoViewController.outputStateStream.listen(_onViewChanged); - if (entry.isVideo || (!entry.isSvg && entry.canDecode && useTile)) { - _photoViewChildSize = entry.displaySize; - } + _subscription = _magnifierController.outputStateStream.listen(_onViewChanged); + _magnifierChildSize = entry.displaySize; } @override @@ -78,19 +81,9 @@ class _ImageViewState extends State { } else if (entry.isSvg) { child = _buildSvgView(); } else if (entry.canDecode) { - if (useTile) { - child = _buildTiledImageView(); - } else { - child = _buildImageView(); - } + child = _buildRasterView(); } - child ??= _buildError(); - - // if the hero tag is defined in the `loadingBuilder` and also set by the `heroAttributes`, - // the route transition becomes visible if the final image 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. + child ??= ErrorChild(onTap: () => onTap?.call(null)); // no hero for videos, as a typical video first frame is different from its thumbnail return widget.heroTag != null && !entry.isVideo @@ -102,17 +95,12 @@ class _ImageViewState extends State { : child; } - // the images loaded by `PhotoView` cannot have a width or height larger than 8192 - // so the reported offset and scale does not match expected values derived from the original dimensions - // besides, large images should be tiled to be memory-friendly - bool get useTile => entry.canTile && (entry.width > 4096 || entry.height > 4096); - ImageProvider get fastThumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry)); // this loading builder shows a transition image until the final image is ready // if the image is already in the cache it will show the final image, otherwise the thumbnail // in any case, we should use `Center` + `AspectRatio` + `BoxFit.fill` so that the transition image - // appears as the final image with `PhotoViewComputedScale.contained` for `initialScale` + // is laid the same way as the final image when `contained` Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) { return Center( child: AspectRatio( @@ -126,53 +114,20 @@ class _ImageViewState extends State { ); } - Widget _buildImageView() { - final uriImage = UriImage( - uri: entry.uri, - mimeType: entry.mimeType, - rotationDegrees: entry.rotationDegrees, - isFlipped: entry.isFlipped, - expectedContentLength: entry.sizeBytes, - ); - return PhotoView( - // key includes size and orientation to refresh when the image is rotated - key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), - imageProvider: uriImage, - // when the full image is ready, we use it in the `loadingBuilder` - // we still provide a `loadingBuilder` in that case to avoid a black frame after hero animation - loadingBuilder: (context, event) => _loadingBuilder( - context, - imageCache.statusForKey(uriImage).keepAlive ? uriImage : fastThumbnailProvider, - ), - loadFailedChild: _buildError(), - backgroundDecoration: backgroundDecoration, - imageSizedCallback: (size) { - // do not directly update the `ViewState` notifier as this callback is called during build - _photoViewChildSize = size; - }, - controller: _photoViewController, - maxScale: maxScale, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), - filterQuality: FilterQuality.low, - ); - } - - Widget _buildTiledImageView() { - return PhotoView.customChild( + Widget _buildRasterView() { + return Magnifier( // key includes size and orientation to refresh when the image is rotated key: ValueKey('${entry.rotationDegrees}_${entry.isFlipped}_${entry.width}_${entry.height}_${entry.path}'), child: Selector( selector: (context, mq) => mq.size, builder: (context, mqSize, child) { // When the scale state is cycled to be in its `initial` state (i.e. `contained`), and the device is rotated, - // `PhotoView` keeps the scale state as `contained`, but the controller does not update or notify the new scale value. - // We cannot use `scaleStateChangedCallback` as a workaround, because the scale state is updated before animating the scale change, + // `Magnifier` keeps the scale state as `contained`, but the controller does not update or notify the new scale value. + // We cannot monitor scale state changes as a workaround, because the scale state is updated before animating the scale, // so we keep receiving scale updates after the scale state update. // Instead we check the scale state here when the constraints change, so we can reset the obsolete scale value. - if (_photoViewScaleStateController.scaleState == PhotoViewScaleState.initial) { - final value = PhotoViewControllerValue(position: Offset.zero, scale: 0, rotation: 0, rotationFocusPoint: null); + if (_magnifierScaleStateController.scaleState.state == ScaleState.initial) { + final value = MagnifierState(position: Offset.zero, scale: 0, source: ChangeSource.internal); WidgetsBinding.instance.addPostFrameCallback((_) => _onViewChanged(value)); } return TiledImageView( @@ -180,25 +135,24 @@ class _ImageViewState extends State { viewportSize: mqSize, viewStateNotifier: _viewStateNotifier, baseChild: _loadingBuilder(context, fastThumbnailProvider), - errorBuilder: (context, error, stackTrace) => _buildError(), + errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)), ); }, ), childSize: entry.displaySize, - backgroundDecoration: backgroundDecoration, - controller: _photoViewController, - scaleStateController: _photoViewScaleStateController, + controller: _magnifierController, + scaleStateController: _magnifierScaleStateController, maxScale: maxScale, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), - filterQuality: FilterQuality.low, + minScale: minScale, + initialScale: initialScale, + onTap: (c, d, s, childPosition) => onTap?.call(childPosition), + applyScale: false, ); } Widget _buildSvgView() { final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver); - return PhotoView.customChild( + return Magnifier( child: SvgPicture( UriPicture( uri: entry.uri, @@ -206,17 +160,16 @@ class _ImageViewState extends State { colorFilter: colorFilter, ), ), - backgroundDecoration: backgroundDecoration, - controller: _photoViewController, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), + controller: _magnifierController, + minScale: minScale, + initialScale: initialScale, + onTap: (c, d, s, childPosition) => onTap?.call(childPosition), ); } Widget _buildVideoView() { final videoController = widget.videoControllers.firstWhere((kv) => kv.item1 == entry.uri, orElse: () => null)?.item2; - return PhotoView.customChild( + return Magnifier( child: videoController != null ? AvesVideo( entry: entry, @@ -224,31 +177,16 @@ class _ImageViewState extends State { ) : SizedBox(), childSize: entry.displaySize, - backgroundDecoration: backgroundDecoration, - controller: _photoViewController, + controller: _magnifierController, maxScale: maxScale, - minScale: PhotoViewComputedScale.contained, - initialScale: PhotoViewComputedScale.contained, - onTapUp: (tapContext, details, value) => onTap?.call(), + minScale: minScale, + initialScale: initialScale, + onTap: (c, d, s, childPosition) => onTap?.call(childPosition), ); } - Widget _buildError() => GestureDetector( - onTap: () => onTap?.call(), - // use a `Container` with a dummy color to make it expand - // so that we can also detect taps around the title `Text` - child: Container( - color: Colors.transparent, - child: EmptyContent( - icon: AIcons.error, - text: 'Oops!', - alignment: Alignment.center, - ), - ), - ); - - void _onViewChanged(PhotoViewControllerValue v) { - final viewState = ViewState(v.position, v.scale, _photoViewChildSize); + void _onViewChanged(MagnifierState v) { + final viewState = ViewState(v.position, v.scale, _magnifierChildSize); _viewStateNotifier.value = viewState; ViewStateNotification(entry.uri, viewState).dispatch(context); } @@ -264,9 +202,7 @@ class ViewState { const ViewState(this.position, this.scale, this.size); @override - String toString() { - return '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}'; - } + String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}'; } class ViewStateNotification extends Notification { @@ -276,7 +212,30 @@ class ViewStateNotification extends Notification { const ViewStateNotification(this.uri, this.viewState); @override - String toString() { - return '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}'; + String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, viewState=$viewState}'; +} + +class ErrorChild extends StatelessWidget { + final VoidCallback onTap; + + const ErrorChild({@required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onTap?.call(), + // use a `Container` with a dummy color to make it expand + // so that we can also detect taps around the title `Text` + child: Container( + color: Colors.transparent, + child: EmptyContent( + icon: AIcons.error, + text: 'Oops!', + alignment: Alignment.center, + ), + ), + ); } } + +typedef MagnifierTapCallback = void Function(Offset childPosition); diff --git a/lib/widgets/fullscreen/info/metadata/svg_tile.dart b/lib/widgets/fullscreen/info/metadata/svg_tile.dart index b56140a8a..7f519f363 100644 --- a/lib/widgets/fullscreen/info/metadata/svg_tile.dart +++ b/lib/widgets/fullscreen/info/metadata/svg_tile.dart @@ -6,7 +6,6 @@ import 'package:aves/services/image_file_service.dart'; import 'package:aves/utils/string_utils.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:xml/xml.dart'; diff --git a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart index 0d9a5c23d..459676466 100644 --- a/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart +++ b/lib/widgets/fullscreen/info/metadata/xmp_namespaces.dart @@ -95,9 +95,7 @@ class XmpNamespace { int get hashCode => namespace.hashCode; @override - String toString() { - return '$runtimeType#${shortHash(this)}{namespace=$namespace}'; - } + String toString() => '$runtimeType#${shortHash(this)}{namespace=$namespace}'; } class XmpProp { @@ -116,9 +114,7 @@ class XmpProp { } @override - String toString() { - return '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; - } + String toString() => '$runtimeType#${shortHash(this)}{path=$path, value=$value}'; } class OpenEmbeddedDataNotification extends Notification { @@ -131,7 +127,5 @@ class OpenEmbeddedDataNotification extends Notification { }); @override - String toString() { - return '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}'; - } + String toString() => '$runtimeType#${shortHash(this)}{propPath=$propPath, mimeType=$mimeType}'; } diff --git a/lib/widgets/fullscreen/overlay/minimap.dart b/lib/widgets/fullscreen/overlay/minimap.dart index d8a33446f..94c6edad1 100644 --- a/lib/widgets/fullscreen/overlay/minimap.dart +++ b/lib/widgets/fullscreen/overlay/minimap.dart @@ -21,25 +21,27 @@ class Minimap extends StatelessWidget { @override Widget build(BuildContext context) { - return Selector( - selector: (context, mq) => mq.size, - builder: (context, mqSize, child) { - return AnimatedBuilder( - animation: viewStateNotifier, - builder: (context, child) { - final viewState = viewStateNotifier.value; - return CustomPaint( - painter: MinimapPainter( - viewportSize: mqSize, - entrySize: viewState.size ?? entry.displaySize, - viewCenterOffset: viewState.position, - viewScale: viewState.scale, - minimapBorderColor: Colors.white30, - ), - size: size, - ); - }); - }); + return IgnorePointer( + child: Selector( + selector: (context, mq) => mq.size, + builder: (context, mqSize, child) { + return AnimatedBuilder( + animation: viewStateNotifier, + builder: (context, child) { + final viewState = viewStateNotifier.value; + return CustomPaint( + painter: MinimapPainter( + viewportSize: mqSize, + entrySize: viewState.size ?? entry.displaySize, + viewCenterOffset: viewState.position, + viewScale: viewState.scale, + minimapBorderColor: Colors.white30, + ), + size: size, + ); + }); + }), + ); } } diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/fullscreen/tiled_view.dart index ad123a960..758db440b 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -1,8 +1,9 @@ import 'dart:math'; +import 'package:aves/image_providers/region_provider.dart'; +import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/utils/math_utils.dart'; -import 'package:aves/image_providers/region_provider.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -37,6 +38,16 @@ class _TiledImageViewState extends State { ValueNotifier get viewStateNotifier => widget.viewStateNotifier; + bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096); + + ImageProvider get fullImage => UriImage( + uri: entry.uri, + mimeType: entry.mimeType, + rotationDegrees: entry.rotationDegrees, + isFlipped: entry.isFlipped, + expectedContentLength: entry.sizeBytes, + ); + // magic number used to derive sample size from scale static const scaleFactor = 2.0; @@ -80,79 +91,102 @@ class _TiledImageViewState extends State { final displayHeight = entry.displaySize.height.round(); return AnimatedBuilder( - animation: viewStateNotifier, - builder: (context, child) { - final viewState = viewStateNotifier.value; - var scale = viewState.scale; - if (scale == 0.0) { - // for initial scale as `PhotoViewComputedScale.contained` - scale = _initialScale; - } + animation: viewStateNotifier, + builder: (context, child) { + final viewState = viewStateNotifier.value; + var scale = viewState.scale; + if (scale == 0.0) { + // for initial scale as `contained` + scale = _initialScale; + } + final scaledSize = entry.displaySize * scale; + final loading = SizedBox( + width: scaledSize.width, + height: scaledSize.height, + child: widget.baseChild, + ); - final centerOffset = viewState.position; - final viewOrigin = Offset( - ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), - ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), - ); - final viewRect = viewOrigin & viewportSize; + List children; + if (useTiles) { + children = [ + loading, + ..._getTiles(viewState, displayWidth, displayHeight, scale), + ]; + } else { + children = [ + if (!imageCache.statusForKey(fullImage).keepAlive) loading, + Image( + image: fullImage, + gaplessPlayback: true, + errorBuilder: widget.errorBuilder, + width: scaledSize.width, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ) + ]; + } - final tiles = []; - var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); - for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { - // for the largest sample size (matching the initial scale), the whole image is in view - // so we subsample the whole image instead of splitting it in tiles - final useTiles = sampleSize != _maxSampleSize; - final regionSide = (_tileSide * sampleSize).round(); - final layerRegionWidth = useTiles ? regionSide : displayWidth; - final layerRegionHeight = useTiles ? regionSide : displayHeight; - for (var x = 0; x < displayWidth; x += layerRegionWidth) { - for (var y = 0; y < displayHeight; y += layerRegionHeight) { - final nextX = x + layerRegionWidth; - final nextY = y + layerRegionHeight; - final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0); - final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0); - final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale); + return Stack( + alignment: Alignment.center, + children: children, + ); + }, + ); + } - // only build visible tiles - if (viewRect.overlaps(tileRect)) { - Rectangle regionRect; + List _getTiles(ViewState viewState, int displayWidth, int displayHeight, double scale) { + final centerOffset = viewState.position; + final viewOrigin = Offset( + ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), + ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), + ); + final viewRect = viewOrigin & viewportSize; - if (_transform != null) { - // apply EXIF orientation - final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble()); - final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft); - final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight); - regionRect = Rectangle.fromPoints( - Point(tl.dx.round(), tl.dy.round()), - Point(br.dx.round(), br.dy.round()), - ); - } else { - regionRect = Rectangle(x, y, thisRegionWidth, thisRegionHeight); - } + final tiles = []; + var minSampleSize = min(_sampleSizeForScale(scale), _maxSampleSize); + for (var sampleSize = _maxSampleSize; sampleSize >= minSampleSize; sampleSize = (sampleSize / 2).floor()) { + // for the largest sample size (matching the initial scale), the whole image is in view + // so we subsample the whole image instead of splitting it in tiles + final useTiles = sampleSize != _maxSampleSize; + final regionSide = (_tileSide * sampleSize).round(); + final layerRegionWidth = useTiles ? regionSide : displayWidth; + final layerRegionHeight = useTiles ? regionSide : displayHeight; + for (var x = 0; x < displayWidth; x += layerRegionWidth) { + for (var y = 0; y < displayHeight; y += layerRegionHeight) { + final nextX = x + layerRegionWidth; + final nextY = y + layerRegionHeight; + final thisRegionWidth = layerRegionWidth - (nextX >= displayWidth ? nextX - displayWidth : 0); + final thisRegionHeight = layerRegionHeight - (nextY >= displayHeight ? nextY - displayHeight : 0); + final tileRect = Rect.fromLTWH(x * scale, y * scale, thisRegionWidth * scale, thisRegionHeight * scale); - tiles.add(RegionTile( - entry: entry, - tileRect: tileRect, - regionRect: regionRect, - sampleSize: sampleSize, - )); - } - } + // only build visible tiles + if (viewRect.overlaps(tileRect)) { + Rectangle regionRect; + + if (_transform != null) { + // apply EXIF orientation + final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble()); + final tl = MatrixUtils.transformPoint(_transform, regionRectDouble.topLeft); + final br = MatrixUtils.transformPoint(_transform, regionRectDouble.bottomRight); + regionRect = Rectangle.fromPoints( + Point(tl.dx.round(), tl.dy.round()), + Point(br.dx.round(), br.dy.round()), + ); + } else { + regionRect = Rectangle(x, y, thisRegionWidth, thisRegionHeight); } - } - return Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: displayWidth * scale, - height: displayHeight * scale, - child: widget.baseChild, - ), - ...tiles, - ], - ); - }); + tiles.add(RegionTile( + entry: entry, + tileRect: tileRect, + regionRect: regionRect, + sampleSize: sampleSize, + )); + } + } + } + } + return tiles; } int _sampleSizeForScale(double scale) { diff --git a/lib/widgets/stats/stats.dart b/lib/widgets/stats/stats.dart index 96abd7807..2907bcb96 100644 --- a/lib/widgets/stats/stats.dart +++ b/lib/widgets/stats/stats.dart @@ -295,7 +295,5 @@ class EntryByMimeDatum { Color get color => stringToColor(displayText); @override - String toString() { - return '[$runtimeType#${shortHash(this)}: mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount]'; - } + String toString() => '$runtimeType#${shortHash(this)}{mimeType=$mimeType, displayText=$displayText, entryCount=$entryCount}'; } diff --git a/pubspec.lock b/pubspec.lock index 59280dd12..ce31a005d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -704,15 +704,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.0" - photo_view: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: aa6400bbc85bf6ce953c4609d126796cdb4ca3c2 - url: "git://github.com/deckerst/photo_view.git" - source: git - version: "0.9.2" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 46b5c29f8..376a15840 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,10 +70,6 @@ dependencies: pedantic: percent_indicator: permission_handler: - photo_view: -# path: ../photo_view - git: - url: git://github.com/deckerst/photo_view.git printing: provider: screen: