From 05496da3440e61129502a602dec5cea67087f3e2 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 17 Dec 2020 14:02:26 +0900 Subject: [PATCH 01/13] 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: From b9e64b552a982a5933d80a5c75b71a2209da4ed9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 17 Dec 2020 18:10:33 +0900 Subject: [PATCH 02/13] viewer: fixed focus & panning when scaling by pinch --- .../magnifier/controller/controller.dart | 4 --- .../controller/controller_delegate.dart | 5 +--- lib/widgets/common/magnifier/core/core.dart | 27 ++++++++++--------- .../magnifier/scale/scale_boundaries.dart | 21 ++++++++++----- 4 files changed, 30 insertions(+), 27 deletions(-) diff --git a/lib/widgets/common/magnifier/controller/controller.dart b/lib/widgets/common/magnifier/controller/controller.dart index f1b3e5468..c33a27595 100644 --- a/lib/widgets/common/magnifier/controller/controller.dart +++ b/lib/widgets/common/magnifier/controller/controller.dart @@ -51,7 +51,6 @@ class MagnifierController { } void setPosition(Offset position, ChangeSource source) { - // debugPrint('$runtimeType setPosition position=$position, source=$source'); if (value.position == position) return; prevValue = value; @@ -66,7 +65,6 @@ class MagnifierController { 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; @@ -86,7 +84,6 @@ class MagnifierController { double scale, @required ChangeSource source, }) { - // debugPrint('$runtimeType updateMultiple position=$position scale=$scale, source=$source'); prevValue = value; _setValue(MagnifierState( position: position ?? value.position, @@ -99,7 +96,6 @@ class MagnifierController { 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 index 3564135e6..5a3455e49 100644 --- a/lib/widgets/common/magnifier/controller/controller_delegate.dart +++ b/lib/widgets/common/magnifier/controller/controller_delegate.dart @@ -51,8 +51,7 @@ mixin MagnifierControllerDelegate on State { 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; + nextPosition = scaleBoundaries.childToStatePosition(nextScale, childFocalPoint); } } @@ -99,7 +98,6 @@ mixin MagnifierControllerDelegate on State { } 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; @@ -108,7 +106,6 @@ mixin MagnifierControllerDelegate on State { } 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); diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index bf4efd134..fc6f20f68 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -43,8 +43,8 @@ class MagnifierCore extends StatefulWidget { } class MagnifierCoreState extends State with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector { - Offset _normalizedPosition; - double _scaleBefore; + Offset _prevViewportFocalPosition; + double _gestureStartScale; AnimationController _scaleAnimationController; Animation _scaleAnimation; @@ -63,24 +63,27 @@ class MagnifierCoreState extends State with TickerProviderStateMi } void onScaleStart(ScaleStartDetails details) { - _scaleBefore = scale; - _normalizedPosition = details.focalPoint - controller.position; + _gestureStartScale = scale; + _prevViewportFocalPosition = details.localFocalPoint; + _scaleAnimationController.stop(); _positionAnimationController.stop(); } void onScaleUpdate(ScaleUpdateDetails details) { - final newScale = _scaleBefore * details.scale; - final delta = details.focalPoint - _normalizedPosition; + final newScale = _gestureStartScale * details.scale; + final panPositionDelta = details.focalPoint - _prevViewportFocalPosition; + final scalePositionDelta = scaleBoundaries.viewportToStatePosition(controller, details.focalPoint) * (scale / newScale - 1); + final newPosition = position + panPositionDelta + scalePositionDelta; updateScaleStateFromNewScale(newScale, ChangeSource.gesture); - - // updateMultiple( scale: newScale, - position: clampPosition(position: delta * details.scale), + position: newPosition, source: ChangeSource.gesture, ); + + _prevViewportFocalPosition = details.focalPoint; } void onScaleEnd(ScaleEndDetails details) { @@ -118,7 +121,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi 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) { + if (_gestureStartScale / _scale == 1.0 && magnitude >= 400.0) { final direction = details.velocity.pixelsPerSecond / magnitude; animatePosition( _position, @@ -131,13 +134,13 @@ class MagnifierCoreState extends State with TickerProviderStateMi if (widget.onTap == null) return; final viewportTapPosition = details.localPosition; - final childTapPosition = scaleBoundaries.toChildPosition(controller, viewportTapPosition); + final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition); widget.onTap.call(context, details, controller.value, childTapPosition); } void onDoubleTap(TapDownDetails details) { final viewportTapPosition = details?.localPosition; - final childTapPosition = scaleBoundaries.toChildPosition(controller, viewportTapPosition); + final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition); nextScaleState(ChangeSource.gesture, childFocalPoint: childTapPosition); } diff --git a/lib/widgets/common/magnifier/scale/scale_boundaries.dart b/lib/widgets/common/magnifier/scale/scale_boundaries.dart index e76561574..b5f565fb4 100644 --- a/lib/widgets/common/magnifier/scale/scale_boundaries.dart +++ b/lib/widgets/common/magnifier/scale/scale_boundaries.dart @@ -40,13 +40,20 @@ class ScaleBoundaries { 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; + Offset get _viewportCenter => viewportSize.center(Offset.zero); + + Offset get _childCenter => childSize.center(Offset.zero); + + Offset viewportToStatePosition(MagnifierController controller, Offset viewportPosition) { + return viewportPosition - _viewportCenter - controller.position; + } + + Offset viewportToChildPosition(MagnifierController controller, Offset viewportPosition) { + return viewportToStatePosition(controller, viewportPosition) / controller.scale + _childCenter; + } + + Offset childToStatePosition(double scale, Offset childPosition) { + return (_childCenter - childPosition) * scale; } @override From 431cf0652c390f6947b2829afd48643e9813f7e9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 17 Dec 2020 18:54:52 +0900 Subject: [PATCH 03/13] specified coroutine context --- .../aves/channel/calls/AppAdapterHandler.kt | 5 +++-- .../aves/channel/calls/AppShortcutHandler.kt | 3 ++- .../thibault/aves/channel/calls/DebugHandler.kt | 13 +++++++------ .../aves/channel/calls/ImageFileHandler.kt | 10 +++++----- .../aves/channel/calls/MetadataHandler.kt | 15 ++++++++------- .../thibault/aves/channel/calls/StorageHandler.kt | 3 ++- .../channel/streams/ImageByteStreamHandler.kt | 3 ++- .../channel/streams/MediaStoreStreamHandler.kt | 3 ++- 8 files changed, 31 insertions(+), 24 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index c134c5c16..3efc65b51 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -17,6 +17,7 @@ import deckers.thibault.aves.utils.LogUtils.createTag import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.io.File @@ -27,8 +28,8 @@ import kotlin.math.roundToInt class AppAdapterHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getAppIcon" -> GlobalScope.launch { getAppIcon(call, Coresult(result)) } - "getAppNames" -> GlobalScope.launch { getAppNames(Coresult(result)) } + "getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { getAppIcon(call, Coresult(result)) } + "getAppNames" -> GlobalScope.launch(Dispatchers.IO) { getAppNames(Coresult(result)) } "edit" -> { val title = call.argument("title") val uri = call.argument("uri")?.let { Uri.parse(it) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt index 591459ba9..1adcba3bc 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppShortcutHandler.kt @@ -12,6 +12,7 @@ import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -20,7 +21,7 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler { when (call.method) { "canPin" -> result.success(canPin()) "pin" -> { - GlobalScope.launch { pin(call) } + GlobalScope.launch(Dispatchers.IO) { pin(call) } result.success(null) } else -> result.notImplemented() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 171a5469f..91f0486d0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -25,6 +25,7 @@ import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.beyka.tiffbitmapfactory.TiffBitmapFactory @@ -36,12 +37,12 @@ class DebugHandler(private val context: Context) : MethodCallHandler { when (call.method) { "getContextDirs" -> result.success(getContextDirs()) "getEnv" -> result.success(System.getenv()) - "getBitmapFactoryInfo" -> GlobalScope.launch { getBitmapFactoryInfo(call, Coresult(result)) } - "getContentResolverMetadata" -> GlobalScope.launch { getContentResolverMetadata(call, Coresult(result)) } - "getExifInterfaceMetadata" -> GlobalScope.launch { getExifInterfaceMetadata(call, Coresult(result)) } - "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } - "getMetadataExtractorSummary" -> GlobalScope.launch { getMetadataExtractorSummary(call, Coresult(result)) } - "getTiffStructure" -> GlobalScope.launch { getTiffStructure(call, Coresult(result)) } + "getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { getBitmapFactoryInfo(call, Coresult(result)) } + "getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getContentResolverMetadata(call, Coresult(result)) } + "getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { getExifInterfaceMetadata(call, Coresult(result)) } + "getMediaMetadataRetrieverMetadata" -> GlobalScope.launch(Dispatchers.IO) { getMediaMetadataRetrieverMetadata(call, Coresult(result)) } + "getMetadataExtractorSummary" -> GlobalScope.launch(Dispatchers.IO) { getMetadataExtractorSummary(call, Coresult(result)) } + "getTiffStructure" -> GlobalScope.launch(Dispatchers.IO) { getTiffStructure(call, Coresult(result)) } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt index 6af5ad179..5d8555a5c 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/ImageFileHandler.kt @@ -26,12 +26,12 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getObsoleteEntries" -> GlobalScope.launch { getObsoleteEntries(call, Coresult(result)) } - "getImageEntry" -> GlobalScope.launch { getImageEntry(call, Coresult(result)) } - "getThumbnail" -> GlobalScope.launch { getThumbnail(call, Coresult(result)) } - "getRegion" -> GlobalScope.launch { getRegion(call, Coresult(result)) } + "getObsoleteEntries" -> GlobalScope.launch(Dispatchers.IO) { getObsoleteEntries(call, Coresult(result)) } + "getImageEntry" -> GlobalScope.launch(Dispatchers.IO) { getImageEntry(call, Coresult(result)) } + "getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { getThumbnail(call, Coresult(result)) } + "getRegion" -> GlobalScope.launch(Dispatchers.IO) { getRegion(call, Coresult(result)) } "clearSizedThumbnailDiskCache" -> { - GlobalScope.launch { Glide.get(activity).clearDiskCache() } + GlobalScope.launch(Dispatchers.IO) { Glide.get(activity).clearDiskCache() } result.success(null) } "rename" -> GlobalScope.launch(Dispatchers.IO) { rename(call, Coresult(result)) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt index 0b016d473..0d2d41135 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MetadataHandler.kt @@ -61,6 +61,7 @@ import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.io.File @@ -70,12 +71,12 @@ import kotlin.math.roundToLong class MetadataHandler(private val context: Context) : MethodCallHandler { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { when (call.method) { - "getAllMetadata" -> GlobalScope.launch { getAllMetadata(call, Coresult(result)) } - "getCatalogMetadata" -> GlobalScope.launch { getCatalogMetadata(call, Coresult(result)) } - "getOverlayMetadata" -> GlobalScope.launch { getOverlayMetadata(call, Coresult(result)) } - "getEmbeddedPictures" -> GlobalScope.launch { getEmbeddedPictures(call, Coresult(result)) } - "getExifThumbnails" -> GlobalScope.launch { getExifThumbnails(call, Coresult(result)) } - "extractXmpDataProp" -> GlobalScope.launch { extractXmpDataProp(call, Coresult(result)) } + "getAllMetadata" -> GlobalScope.launch(Dispatchers.IO) { getAllMetadata(call, Coresult(result)) } + "getCatalogMetadata" -> GlobalScope.launch(Dispatchers.IO) { getCatalogMetadata(call, Coresult(result)) } + "getOverlayMetadata" -> GlobalScope.launch(Dispatchers.IO) { getOverlayMetadata(call, Coresult(result)) } + "getEmbeddedPictures" -> GlobalScope.launch(Dispatchers.IO) { getEmbeddedPictures(call, Coresult(result)) } + "getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { getExifThumbnails(call, Coresult(result)) } + "extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { extractXmpDataProp(call, Coresult(result)) } else -> result.notImplemented() } } @@ -588,7 +589,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler { "mimeType" to embedMimeType, ) if (isImage(embedMimeType) || isVideo(embedMimeType)) { - GlobalScope.launch { + GlobalScope.launch(Dispatchers.IO) { FileImageProvider().fetchSingle(context, embedUri, embedMimeType, object : ImageProvider.ImageOpCallback { override fun onSuccess(fields: FieldMap) { embedFields.putAll(fields) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index ab2147817..6ff8babbb 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -11,6 +11,7 @@ import deckers.thibault.aves.utils.StorageUtils.getVolumePaths import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.io.File @@ -32,7 +33,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { "getGrantedDirectories" -> result.success(ArrayList(PermissionManager.getGrantedDirs(context))) "getInaccessibleDirectories" -> getInaccessibleDirectories(call, result) "revokeDirectoryAccess" -> revokeDirectoryAccess(call, result) - "scanFile" -> GlobalScope.launch { scanFile(call, Coresult(result)) } + "scanFile" -> GlobalScope.launch(Dispatchers.IO) { scanFile(call, Coresult(result)) } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt index df6e5177e..eceb88bce 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageByteStreamHandler.kt @@ -18,6 +18,7 @@ import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide import deckers.thibault.aves.utils.StorageUtils import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.beyka.tiffbitmapfactory.TiffBitmapFactory @@ -32,7 +33,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen this.eventSink = eventSink handler = Handler(Looper.getMainLooper()) - GlobalScope.launch { streamImage() } + GlobalScope.launch(Dispatchers.IO) { streamImage() } } override fun onCancel(o: Any) {} diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index ec2aa52c5..311589a9a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -7,6 +7,7 @@ import deckers.thibault.aves.model.provider.FieldMap import deckers.thibault.aves.model.provider.MediaStoreImageProvider import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -27,7 +28,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E this.eventSink = eventSink handler = Handler(Looper.getMainLooper()) - GlobalScope.launch { fetchAll() } + GlobalScope.launch(Dispatchers.IO) { fetchAll() } } override fun onCancel(arguments: Any?) {} From 4a6622de4963f9a1c29bc9f33f67b20394c21d7c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 17 Dec 2020 20:52:05 +0900 Subject: [PATCH 04/13] concurrent service tasks --- lib/services/service_policy.dart | 22 +++++++++++++++------- lib/widgets/debug/overlay.dart | 7 ++++++- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 6794a75e5..c4f8fa043 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -10,7 +10,10 @@ class ServicePolicy { final StreamController _queueStreamController = StreamController.broadcast(); final Map> _paused = {}; final SplayTreeMap> _queues = SplayTreeMap(); - _Task _running; + final Queue<_Task> _runningQueue = Queue(); + + // magic number + static const concurrentTaskMax = 4; Stream get queueStream => _queueStreamController.stream; @@ -23,6 +26,7 @@ class ServicePolicy { Object key, }) { _Task task; + key ??= platformCall.hashCode; final priorityTask = _paused.remove(key); if (priorityTask != null) { debugPrint('resume task with key=$key'); @@ -39,7 +43,7 @@ class ServicePolicy { completer.completeError(error, stackTrace); } if (debugLabel != null) debugPrint('$runtimeType $debugLabel completed'); - _running = null; + _runningQueue.removeWhere((task) => task.key == key); _pickNext(); }, completer, @@ -64,10 +68,13 @@ class ServicePolicy { void _pickNext() { _notifyQueueState(); - if (_running != null) return; + if (_runningQueue.length >= concurrentTaskMax) return; final queue = _queues.entries.firstWhere((kv) => kv.value.isNotEmpty, orElse: () => null)?.value; - _running = queue?.removeFirst(); - _running?.callback?.call(); + final task = queue?.removeFirst(); + if (task != null) { + _runningQueue.addLast(task); + task.callback(); + } } bool _takeOut(Object key, Iterable priorities, void Function(int priority, _Task task) action) { @@ -99,7 +106,7 @@ class ServicePolicy { if (!_queueStreamController.hasListener) return; final queueByPriority = Map.fromEntries(_queues.entries.map((kv) => MapEntry(kv.key, kv.value.length))); - _queueStreamController.add(QueueState(queueByPriority)); + _queueStreamController.add(QueueState(queueByPriority, _runningQueue.length)); } } @@ -124,6 +131,7 @@ class ServiceCallPriority { class QueueState { final Map queueByPriority; + final int runningQueue; - const QueueState(this.queueByPriority); + const QueueState(this.queueByPriority, this.runningQueue); } diff --git a/lib/widgets/debug/overlay.dart b/lib/widgets/debug/overlay.dart index 6d90d9038..9c0081c0e 100644 --- a/lib/widgets/debug/overlay.dart +++ b/lib/widgets/debug/overlay.dart @@ -20,7 +20,12 @@ class DebugTaskQueueOverlay extends StatelessWidget { stream: servicePolicy.queueStream, builder: (context, snapshot) { if (snapshot.hasError) return SizedBox.shrink(); - final queuedEntries = (snapshot.hasData ? snapshot.data.queueByPriority.entries.toList() : []); + final queuedEntries = >[]; + if (snapshot.hasData) { + final state = snapshot.data; + queuedEntries.add(MapEntry('run', state.runningQueue)); + queuedEntries.addAll(state.queueByPriority.entries.map((kv) => MapEntry(kv.key.toString(), kv.value))); + } queuedEntries.sort((a, b) => a.key.compareTo(b.key)); return Column( mainAxisSize: MainAxisSize.min, From c9fb94f326c78ac6b249eb241d8c0ae2c0bc7c78 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 18 Dec 2020 11:44:07 +0900 Subject: [PATCH 05/13] svg sizing --- lib/model/image_entry.dart | 42 +++++++-- lib/model/image_metadata.dart | 8 +- lib/model/source/tag.dart | 2 +- lib/services/svg_metadata_service.dart | 86 +++++++++++++++++++ lib/widgets/common/magnifier/magnifier.dart | 2 +- lib/widgets/fullscreen/image_view.dart | 11 +++ .../info/metadata/metadata_section.dart | 26 +++++- .../fullscreen/info/metadata/svg_tile.dart | 75 ---------------- lib/widgets/fullscreen/overlay/bottom.dart | 2 +- 9 files changed, 164 insertions(+), 90 deletions(-) create mode 100644 lib/services/svg_metadata_service.dart delete mode 100644 lib/widgets/fullscreen/info/metadata/svg_tile.dart diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index ba3c17335..6a54de74a 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -7,6 +7,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/service_policy.dart'; +import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/math_utils.dart'; import 'package:aves/utils/time_utils.dart'; @@ -24,8 +25,6 @@ class ImageEntry { String _path, _directory, _filename, _extension; int contentId; final String sourceMimeType; - - // TODO TLAD use SVG viewport as width/height int width; int height; int sourceRotationDegrees; @@ -236,10 +235,24 @@ class ImageEntry { // but it would take space and time, so a basic workaround will do. bool get isPortrait => rotationDegrees % 180 == 90 && (catalogMetadata?.rotationDegrees == null || width > height); + static const ratioSeparator = '\u2236'; + static const resolutionSeparator = ' \u00D7 '; + String get resolutionText { final w = width ?? '?'; final h = height ?? '?'; - return isPortrait ? '$h × $w' : '$w × $h'; + return isPortrait ? '$h$resolutionSeparator$w' : '$w$resolutionSeparator$h'; + } + + String get aspectRatioText { + if (width != null && height != null && width > 0 && height > 0) { + final gcd = width.gcd(height); + final w = width ~/ gcd; + final h = height ~/ gcd; + return isPortrait ? '$h$ratioSeparator$w' : '$w$ratioSeparator$h'; + } else { + return '?$ratioSeparator?'; + } } double get displayAspectRatio { @@ -319,7 +332,7 @@ class ImageEntry { String _bestTitle; String get bestTitle { - _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription.isNotEmpty) ? _catalogMetadata.xmpTitleDescription : sourceTitle; + _bestTitle ??= (isCatalogued && _catalogMetadata.xmpTitleDescription?.isNotEmpty == true) ? _catalogMetadata.xmpTitleDescription : sourceTitle; return _bestTitle; } @@ -350,7 +363,20 @@ class ImageEntry { Future catalog({bool background = false}) async { if (isCatalogued) return; - catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); + if (isSvg) { + // vector image sizing is not essential, so we should not spend time for it during loading + // but it is useful anyway (for aspect ratios etc.) so we size them during cataloguing + final size = await SvgMetadataService.getSize(this); + if (size != null) { + await _applyNewFields({ + 'width': size.width.round(), + 'height': size.height.round(), + }); + } + catalogMetadata = CatalogMetadata(contentId: contentId); + } else { + catalogMetadata = await MetadataService.getCatalogMetadata(this, background: background); + } } AddressDetails get addressDetails => _addressDetails; @@ -447,6 +473,12 @@ class ImageEntry { this.sourceTitle = sourceTitle; _bestTitle = null; } + + final width = newFields['width']; + if (width is int) this.width = width; + final height = newFields['height']; + if (height is int) this.height = height; + final dateModifiedSecs = newFields['dateModifiedSecs']; if (dateModifiedSecs is int) this.dateModifiedSecs = dateModifiedSecs; final rotationDegrees = newFields['rotationDegrees']; diff --git a/lib/model/image_metadata.dart b/lib/model/image_metadata.dart index e2042d0f3..04086161c 100644 --- a/lib/model/image_metadata.dart +++ b/lib/model/image_metadata.dart @@ -46,10 +46,10 @@ class CatalogMetadata { this.contentId, this.mimeType, this.dateMillis, - this.isAnimated, - this.isFlipped, - this.isGeotiff, - this.is360, + this.isAnimated = false, + this.isFlipped = false, + this.isGeotiff = false, + this.is360 = false, this.rotationDegrees, this.xmpSubjects, this.xmpTitleDescription, diff --git a/lib/model/source/tag.dart b/lib/model/source/tag.dart index 28a879ab3..55a1053ca 100644 --- a/lib/model/source/tag.dart +++ b/lib/model/source/tag.dart @@ -23,7 +23,7 @@ mixin TagMixin on SourceBase { Future catalogEntries() async { // final stopwatch = Stopwatch()..start(); - final todo = rawEntries.where((entry) => !entry.isCatalogued && !entry.isSvg).toList(); + final todo = rawEntries.where((entry) => !entry.isCatalogued).toList(); if (todo.isEmpty) return; var progressDone = 0; diff --git a/lib/services/svg_metadata_service.dart b/lib/services/svg_metadata_service.dart new file mode 100644 index 000000000..8d09750bc --- /dev/null +++ b/lib/services/svg_metadata_service.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/services/image_file_service.dart'; +import 'package:aves/utils/string_utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:xml/xml.dart'; + +class SvgMetadataService { + static const docDirectory = 'Document'; + static const metadataDirectory = 'Metadata'; + + static const _attributes = ['x', 'y', 'width', 'height', 'preserveAspectRatio', 'viewBox']; + static const _textElements = ['title', 'desc']; + static const _metadataElement = 'metadata'; + + static Future getSize(ImageEntry entry) async { + try { + final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); + + final document = XmlDocument.parse(utf8.decode(data)); + final root = document.rootElement; + + String getAttribute(String attributeName) => root.attributes.firstWhere((a) => a.name.qualified == attributeName, orElse: () => null)?.value; + double tryParseWithoutUnit(String s) => s == null ? null : double.tryParse(s.replaceAll(RegExp(r'[a-z%]'), '')); + + final width = tryParseWithoutUnit(getAttribute('width')); + final height = tryParseWithoutUnit(getAttribute('height')); + if (width != null && height != null) { + return Size(width, height); + } + + final viewBox = getAttribute('viewBox'); + if (viewBox != null) { + final parts = viewBox.split(RegExp(r'[\s,]+')); + if (parts.length == 4) { + final vbWidth = tryParseWithoutUnit(parts[2]); + final vbHeight = tryParseWithoutUnit(parts[3]); + if (vbWidth > 0 && vbHeight > 0) { + return Size(vbWidth, vbHeight); + } + } + } + } catch (exception, stack) { + debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); + } + return null; + } + + static Future>> getAllMetadata(ImageEntry entry) async { + String formatKey(String key) { + switch (key) { + case 'desc': + return 'Description'; + default: + return key.toSentenceCase(); + } + } + + try { + final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); + + final document = XmlDocument.parse(utf8.decode(data)); + final root = document.rootElement; + + final docDir = Map.fromEntries([ + ...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(formatKey(a.name.qualified), a.value)), + ..._textElements.map((name) => MapEntry(formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null), + ]); + + final metadata = root.getElement(_metadataElement); + final metadataDir = Map.fromEntries([ + if (metadata != null) MapEntry('Metadata', metadata.toXmlString(pretty: true)), + ]); + + return { + if (docDir.isNotEmpty) docDirectory: docDir, + if (metadataDir.isNotEmpty) metadataDirectory: metadataDir, + }; + } catch (exception, stack) { + debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); + return null; + } + } +} diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart index 156bdf306..265b445f0 100644 --- a/lib/widgets/common/magnifier/magnifier.dart +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -70,7 +70,7 @@ class _MagnifierState extends State { MagnifierScaleStateController _scaleStateController; void _setChildSize(Size childSize) { - _childSize = childSize; + _childSize = childSize.isEmpty ? null : childSize; } @override diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 0f0beb658..4734d7c71 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -160,9 +160,11 @@ class _ImageViewState extends State { colorFilter: colorFilter, ), ), + childSize: entry.displaySize, controller: _magnifierController, minScale: minScale, initialScale: initialScale, + scaleStateCycle: _vectorScaleStateCycle, onTap: (c, d, s, childPosition) => onTap?.call(childPosition), ); } @@ -190,6 +192,15 @@ class _ImageViewState extends State { _viewStateNotifier.value = viewState; ViewStateNotification(entry.uri, viewState).dispatch(context); } + + static ScaleState _vectorScaleStateCycle(ScaleState actual) { + switch (actual) { + case ScaleState.initial: + return ScaleState.covering; + default: + return ScaleState.initial; + } + } } class ViewState { diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index a74f09743..852ea2897 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:aves/model/image_entry.dart'; import 'package:aves/ref/brand_colors.dart'; import 'package:aves/services/metadata_service.dart'; +import 'package:aves/services/svg_metadata_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/color_utils.dart'; @@ -10,8 +11,8 @@ import 'package:aves/utils/constants.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/fullscreen/info/common.dart'; import 'package:aves/widgets/fullscreen/info/metadata/metadata_thumbnail.dart'; -import 'package:aves/widgets/fullscreen/info/metadata/svg_tile.dart'; import 'package:aves/widgets/fullscreen/info/metadata/xmp_tile.dart'; +import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -175,7 +176,7 @@ class _MetadataSectionSliverState extends State with Auto child: InfoRowGroup( dir.tags, maxValueLength: Constants.infoGroupMaxValueLength, - linkHandlers: dirName == SvgMetadata.metadataDirectory ? SvgMetadata.getLinkHandlers(dir.tags) : null, + linkHandlers: dirName == SvgMetadataService.metadataDirectory ? getSvgLinkHandlers(dir.tags) : null, ), ), ], @@ -192,7 +193,7 @@ class _MetadataSectionSliverState extends State with Auto if (entry == null) return; if (_loadedMetadataUri.value == entry.uri) return; if (isVisible) { - final rawMetadata = await (entry.isSvg ? SvgMetadata.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; + final rawMetadata = await (entry.isSvg ? SvgMetadataService.getAllMetadata(entry) : MetadataService.getAllMetadata(entry)) ?? {}; final directories = rawMetadata.entries.map((dirKV) { var directoryName = dirKV.key as String ?? ''; @@ -230,6 +231,25 @@ class _MetadataSectionSliverState extends State with Auto _expandedDirectoryNotifier.value = null; } + static Map getSvgLinkHandlers(SplayTreeMap tags) { + return { + 'Metadata': InfoLinkHandler( + linkText: 'View XML', + onTap: (context) { + Navigator.push( + context, + MaterialPageRoute( + settings: RouteSettings(name: SourceViewerPage.routeName), + builder: (context) => SourceViewerPage( + loader: () => SynchronousFuture(tags['Metadata']), + ), + ), + ); + }, + ), + }; + } + @override bool get wantKeepAlive => true; } diff --git a/lib/widgets/fullscreen/info/metadata/svg_tile.dart b/lib/widgets/fullscreen/info/metadata/svg_tile.dart deleted file mode 100644 index 7f519f363..000000000 --- a/lib/widgets/fullscreen/info/metadata/svg_tile.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'package:aves/model/image_entry.dart'; -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/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:xml/xml.dart'; - -class SvgMetadata { - static const docDirectory = 'Document'; - static const metadataDirectory = 'Metadata'; - - static const _attributes = ['x', 'y', 'width', 'height', 'preserveAspectRatio', 'viewBox']; - static const _textElements = ['title', 'desc']; - static const _metadataElement = 'metadata'; - - static Future>> getAllMetadata(ImageEntry entry) async { - try { - final data = await ImageFileService.getImage(entry.uri, entry.mimeType, 0, false); - - final document = XmlDocument.parse(utf8.decode(data)); - final root = document.rootElement; - - final docDir = Map.fromEntries([ - ...root.attributes.where((a) => _attributes.contains(a.name.qualified)).map((a) => MapEntry(_formatKey(a.name.qualified), a.value)), - ..._textElements.map((name) => MapEntry(_formatKey(name), root.getElement(name)?.text)).where((kv) => kv.value != null), - ]); - - final metadata = root.getElement(_metadataElement); - final metadataDir = Map.fromEntries([ - if (metadata != null) MapEntry('Metadata', metadata.toXmlString(pretty: true)), - ]); - - return { - if (docDir.isNotEmpty) docDirectory: docDir, - if (metadataDir.isNotEmpty) metadataDirectory: metadataDir, - }; - } catch (exception, stack) { - debugPrint('failed to parse XML from SVG with exception=$exception\n$stack'); - return null; - } - } - - static Map getLinkHandlers(SplayTreeMap tags) { - return { - 'Metadata': InfoLinkHandler( - linkText: 'View XML', - onTap: (context) { - Navigator.push( - context, - MaterialPageRoute( - settings: RouteSettings(name: SourceViewerPage.routeName), - builder: (context) => SourceViewerPage( - loader: () => SynchronousFuture(tags['Metadata']), - ), - ), - ); - }, - ), - }; - } - - static String _formatKey(String key) { - switch (key) { - case 'desc': - return 'Description'; - default: - return key.toSentenceCase(); - } - } -} diff --git a/lib/widgets/fullscreen/overlay/bottom.dart b/lib/widgets/fullscreen/overlay/bottom.dart index 577c2ad43..b4d87a4cf 100644 --- a/lib/widgets/fullscreen/overlay/bottom.dart +++ b/lib/widgets/fullscreen/overlay/bottom.dart @@ -278,7 +278,7 @@ class _DateRow extends StatelessWidget { DecoratedIcon(AIcons.date, shadows: [Constants.embossShadow], size: _iconSize), SizedBox(width: _iconPadding), Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)), - if (!entry.isSvg) Expanded(flex: 2, child: Text(entry.resolutionText, strutStyle: Constants.overflowStrutStyle)), + Expanded(flex: 2, child: Text(entry.isSvg ? entry.aspectRatioText : entry.resolutionText, strutStyle: Constants.overflowStrutStyle)), ], ); } From b14558e4514cdc0d806cb4437874c3223eb51f8c Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 21 Dec 2020 20:11:14 +0900 Subject: [PATCH 06/13] svg: optional checkered background --- lib/model/settings/entry_background.dart | 26 +++ lib/model/settings/settings.dart | 7 +- .../collection/thumbnail/decorated.dart | 1 - lib/widgets/collection/thumbnail/vector.dart | 58 +++++-- .../common/fx/checkered_decoration.dart | 57 ++++++ .../common/fx/highlight_decoration.dart | 8 +- .../magnifier/controller/controller.dart | 162 ++++++++++-------- .../controller/controller_delegate.dart | 81 +++------ lib/widgets/common/magnifier/core/core.dart | 85 +++++---- lib/widgets/common/magnifier/magnifier.dart | 35 +--- .../scale/scalestate_controller.dart | 41 ----- lib/widgets/filter_grids/album_pick.dart | 8 +- lib/widgets/fullscreen/image_view.dart | 109 ++++++++---- .../info/metadata/metadata_section.dart | 8 +- lib/widgets/fullscreen/overlay/minimap.dart | 34 ++-- lib/widgets/fullscreen/tiled_view.dart | 37 ++-- lib/widgets/settings/svg_background.dart | 54 ++++-- 17 files changed, 443 insertions(+), 368 deletions(-) create mode 100644 lib/model/settings/entry_background.dart create mode 100644 lib/widgets/common/fx/checkered_decoration.dart delete mode 100644 lib/widgets/common/magnifier/scale/scalestate_controller.dart diff --git a/lib/model/settings/entry_background.dart b/lib/model/settings/entry_background.dart new file mode 100644 index 000000000..ee0ffe4c1 --- /dev/null +++ b/lib/model/settings/entry_background.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +enum EntryBackground { black, white, transparent, checkered } + +extension ExtraEntryBackground on EntryBackground { + bool get isColor { + switch (this) { + case EntryBackground.black: + case EntryBackground.white: + return true; + default: + return false; + } + } + + Color get color { + switch (this) { + case EntryBackground.black: + return Colors.black; + case EntryBackground.white: + return Colors.white; + default: + return null; + } + } +} diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 87f6e0380..8ee13f644 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -1,5 +1,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/settings/coordinate_format.dart'; +import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/home_page.dart'; import 'package:aves/model/settings/screen_on.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart'; @@ -54,7 +55,7 @@ class Settings extends ChangeNotifier { static const coordinateFormatKey = 'coordinates_format'; // rendering - static const svgBackgroundKey = 'svg_background'; + static const vectorBackgroundKey = 'vector_background'; // search static const saveSearchHistoryKey = 'save_search_history'; @@ -184,9 +185,9 @@ class Settings extends ChangeNotifier { // rendering - int get svgBackground => _prefs.getInt(svgBackgroundKey) ?? 0xFFFFFFFF; + EntryBackground get vectorBackground => getEnumOrDefault(vectorBackgroundKey, EntryBackground.white, EntryBackground.values); - set svgBackground(int newValue) => setAndNotify(svgBackgroundKey, newValue); + set vectorBackground(EntryBackground newValue) => setAndNotify(vectorBackgroundKey, newValue.toString()); // search diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index 71dd0377c..a92d4e550 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -43,7 +43,6 @@ class DecoratedThumbnail extends StatelessWidget { ); child = Stack( - fit: StackFit.passthrough, children: [ child, Positioned( diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index ed238e02e..c589da062 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -1,6 +1,10 @@ -import 'package:aves/model/image_entry.dart'; -import 'package:aves/model/settings/settings.dart'; +import 'dart:math'; + import 'package:aves/image_providers/uri_picture_provider.dart'; +import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:provider/provider.dart'; @@ -19,23 +23,39 @@ class ThumbnailVectorImage extends StatelessWidget { @override Widget build(BuildContext context) { - final child = Container( - // center `SvgPicture` inside `Container` with the thumbnail dimensions - // so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons - width: extent, - height: extent, - child: Selector( - selector: (context, s) => s.svgBackground, - builder: (context, svgBackground, child) { - final colorFilter = ColorFilter.mode(Color(svgBackground), BlendMode.dstOver); - return SvgPicture( - UriPicture( - uri: entry.uri, - mimeType: entry.mimeType, - colorFilter: colorFilter, - ), - width: extent, - height: extent, + final pictureProvider = UriPicture( + uri: entry.uri, + mimeType: entry.mimeType, + ); + + final child = Center( + child: Selector( + selector: (context, s) => s.vectorBackground, + builder: (context, background, child) { + if (background == EntryBackground.transparent) { + return SvgPicture( + pictureProvider, + width: extent, + height: extent, + ); + } + + final longestSide = max(entry.width, entry.height); + final picture = SvgPicture( + pictureProvider, + width: extent * (entry.width / longestSide), + height: extent * (entry.height / longestSide), + ); + + Decoration decoration; + if (background == EntryBackground.checkered) { + decoration = CheckeredDecoration(checkSize: extent / 8); + } else if (background.isColor) { + decoration = BoxDecoration(color: background.color); + } + return DecoratedBox( + decoration: decoration, + child: picture, ); }, ), diff --git a/lib/widgets/common/fx/checkered_decoration.dart b/lib/widgets/common/fx/checkered_decoration.dart new file mode 100644 index 000000000..4d541eaee --- /dev/null +++ b/lib/widgets/common/fx/checkered_decoration.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +class CheckeredDecoration extends Decoration { + final Color light, dark; + final double checkSize; + final Offset offset; + + const CheckeredDecoration({ + this.light = const Color(0xFF999999), + this.dark = const Color(0xFF666666), + this.checkSize = 20, + this.offset = Offset.zero, + }); + + @override + _CheckeredDecorationPainter createBoxPainter([VoidCallback onChanged]) { + return _CheckeredDecorationPainter(this, onChanged); + } +} + +class _CheckeredDecorationPainter extends BoxPainter { + final CheckeredDecoration decoration; + + const _CheckeredDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged); + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + var dx = offset.dx; + var dy = offset.dy; + + final lightPaint = Paint()..color = decoration.light; + final darkPaint = Paint()..color = decoration.dark; + final checkSize = decoration.checkSize; + + // save/restore because of the clip + canvas.save(); + canvas.clipRect(Rect.fromLTWH(dx, dy, size.width, size.height)); + + canvas.drawPaint(lightPaint); + + dx += decoration.offset.dx % (decoration.checkSize * 2); + dy += decoration.offset.dy % (decoration.checkSize * 2); + + final xMax = size.width / checkSize; + final yMax = size.height / checkSize; + for (var x = -2; x < xMax; x++) { + for (var y = -2; y < yMax; y++) { + if ((x + y) % 2 == 0) { + final rect = Rect.fromLTWH(dx + x * checkSize, dy + y * checkSize, checkSize, checkSize); + canvas.drawRect(rect, darkPaint); + } + } + } + canvas.restore(); + } +} diff --git a/lib/widgets/common/fx/highlight_decoration.dart b/lib/widgets/common/fx/highlight_decoration.dart index 123e186a4..096d139cd 100644 --- a/lib/widgets/common/fx/highlight_decoration.dart +++ b/lib/widgets/common/fx/highlight_decoration.dart @@ -6,15 +6,15 @@ class HighlightDecoration extends Decoration { const HighlightDecoration({@required this.color}); @override - HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) { - return HighlightDecorationPainter(this, onChanged); + _HighlightDecorationPainter createBoxPainter([VoidCallback onChanged]) { + return _HighlightDecorationPainter(this, onChanged); } } -class HighlightDecorationPainter extends BoxPainter { +class _HighlightDecorationPainter extends BoxPainter { final HighlightDecoration decoration; - const HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged); + const _HighlightDecorationPainter(this.decoration, VoidCallback onChanged) : super(onChanged); @override void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { diff --git a/lib/widgets/common/magnifier/controller/controller.dart b/lib/widgets/common/magnifier/controller/controller.dart index c33a27595..d4c5b37f9 100644 --- a/lib/widgets/common/magnifier/controller/controller.dart +++ b/lib/widgets/common/magnifier/controller/controller.dart @@ -2,101 +2,127 @@ import 'dart:async'; import 'dart:ui'; import 'package:aves/widgets/common/magnifier/controller/state.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/state.dart'; import 'package:flutter/widgets.dart'; class MagnifierController { + final StreamController _stateStreamController = StreamController.broadcast(); + final StreamController _scaleBoundariesStreamController = StreamController.broadcast(); + final StreamController _scaleStateChangeStreamController = StreamController.broadcast(); + + MagnifierState _currentState, initial, previousState; + ScaleBoundaries _scaleBoundaries; + ScaleStateChange _currentScaleState, previousScaleState; + MagnifierController({ Offset initialPosition = Offset.zero, - }) : _valueNotifier = ValueNotifier( - MagnifierState( - position: initialPosition, - scale: null, - source: ChangeSource.internal, - ), - ), - super() { - initial = value; - prevValue = initial; + }) : super() { + initial = MagnifierState( + position: initialPosition, + scale: null, + source: ChangeSource.internal, + ); + previousState = initial; + _setState(initial); - _valueNotifier.addListener(_changeListener); - _outputCtrl = StreamController.broadcast(); - _outputCtrl.sink.add(initial); + final _initialScaleState = ScaleStateChange(state: ScaleState.initial, source: ChangeSource.internal); + previousScaleState = _initialScaleState; + _setScaleState(_initialScaleState); } - final ValueNotifier _valueNotifier; + Stream get stateStream => _stateStreamController.stream; - MagnifierState initial; + Stream get scaleBoundariesStream => _scaleBoundariesStreamController.stream; - StreamController _outputCtrl; + Stream get scaleStateChangeStream => _scaleStateChangeStreamController.stream; - /// The output for state/value updates. Usually a broadcast [Stream] - Stream get outputStateStream => _outputCtrl.stream; + MagnifierState get currentState => _currentState; - /// The state value before the last change or the initial state if the state has not been changed. - MagnifierState prevValue; + Offset get position => currentState.position; - /// Resets the state to the initial value; - void reset() { - _setValue(initial); - } + double get scale => currentState.scale; - void _changeListener() { - _outputCtrl.sink.add(value); - } + ScaleBoundaries get scaleBoundaries => _scaleBoundaries; + + ScaleStateChange get scaleState => _currentScaleState; + + bool get hasScaleSateChanged => previousScaleState != scaleState; + + bool get isZooming => scaleState.state == ScaleState.zoomedIn || scaleState.state == ScaleState.zoomedOut; /// Closes streams and removes eventual listeners. void dispose() { - _outputCtrl.close(); - _valueNotifier.dispose(); + _stateStreamController.close(); + _scaleBoundariesStreamController.close(); + _scaleStateChangeStreamController.close(); } - void setPosition(Offset position, ChangeSource 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) { - 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({ + void update({ Offset position, double scale, @required ChangeSource source, }) { - prevValue = value; - _setValue(MagnifierState( - position: position ?? value.position, - scale: scale ?? value.scale, + position = position ?? this.position; + scale = scale ?? this.scale; + if (this.position == position && this.scale == scale) return; + + previousState = currentState; + _setState(MagnifierState( + position: position, + scale: scale, source: source, )); } - /// The actual state value - MagnifierState get value => _valueNotifier.value; + void setScaleState(ScaleState newValue, ChangeSource source, {Offset childFocalPoint}) { + if (_currentScaleState.state == newValue) return; - void _setValue(MagnifierState newValue) { - if (_valueNotifier.value == newValue) return; - _valueNotifier.value = newValue; + previousScaleState = _currentScaleState; + _currentScaleState = ScaleStateChange(state: newValue, source: source, childFocalPoint: childFocalPoint); + _scaleStateChangeStreamController.sink.add(scaleState); + } + + void _setState(MagnifierState state) { + if (_currentState == state) return; + _currentState = state; + _stateStreamController.sink.add(state); + } + + void setScaleBoundaries(ScaleBoundaries scaleBoundaries) { + if (_scaleBoundaries == scaleBoundaries) return; + _scaleBoundaries = scaleBoundaries; + _scaleBoundariesStreamController.sink.add(scaleBoundaries); + + if (!isZooming) { + update( + scale: getScaleForScaleState(_currentScaleState.state), + source: ChangeSource.internal, + ); + } + } + + void _setScaleState(ScaleStateChange scaleState) { + if (_currentScaleState == scaleState) return; + _currentScaleState = scaleState; + _scaleStateChangeStreamController.sink.add(_currentScaleState); + } + + double getScaleForScaleState(ScaleState scaleState) { + 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; + } } } diff --git a/lib/widgets/common/magnifier/controller/controller_delegate.dart b/lib/widgets/common/magnifier/controller/controller_delegate.dart index 5a3455e49..4169dced5 100644 --- a/lib/widgets/common/magnifier/controller/controller_delegate.dart +++ b/lib/widgets/common/magnifier/controller/controller_delegate.dart @@ -5,8 +5,6 @@ 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'; @@ -16,9 +14,7 @@ import 'package:flutter/widgets.dart'; mixin MagnifierControllerDelegate on State { MagnifierController get controller => widget.controller; - MagnifierScaleStateController get scaleStateController => widget.scaleStateController; - - ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries; + ScaleBoundaries get scaleBoundaries => controller.scaleBoundaries; ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle; @@ -29,24 +25,24 @@ mixin MagnifierControllerDelegate on State { /// Mark if scale need recalculation, useful for scale boundaries changes. bool markNeedsScaleRecalc = true; - final List _streamSubs = []; + final List _subscriptions = []; void startListeners() { - _streamSubs.add(controller.outputStateStream.listen(_onMagnifierStateChange)); - _streamSubs.add(scaleStateController.scaleStateChangeStream.listen(_onScaleStateChange)); + _subscriptions.add(controller.stateStream.listen(_onMagnifierStateChange)); + _subscriptions.add(controller.scaleStateChangeStream.listen(_onScaleStateChange)); } void _onScaleStateChange(ScaleStateChange scaleStateChange) { if (scaleStateChange.source == ChangeSource.internal) return; - if (!scaleStateController.hasChanged) return; + if (!controller.hasScaleSateChanged) return; - if (_animateScale == null || scaleStateController.isZooming) { - controller.setScale(scale, scaleStateChange.source); + if (_animateScale == null || controller.isZooming) { + controller.update(scale: scale, source: scaleStateChange.source); return; } final nextScaleState = scaleStateChange.state; - final nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries); + final nextScale = controller.getScaleForScaleState(nextScaleState); var nextPosition = Offset.zero; if (nextScaleState == ScaleState.covering || nextScaleState == ScaleState.originalSize) { final childFocalPoint = scaleStateChange.childFocalPoint; @@ -55,31 +51,31 @@ mixin MagnifierControllerDelegate on State { } } - final prevScale = controller.scale ?? getScaleForScaleState(scaleStateController.prevScaleState.state, scaleBoundaries); + final prevScale = controller.scale ?? controller.getScaleForScaleState(controller.previousScaleState.state); _animateScale(prevScale, nextScale, nextPosition); } - void addAnimateOnScaleStateUpdate(void Function(double prevScale, double nextScale, Offset nextPosition) animateScale) { + void setScaleStateUpdateAnimation(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; + controller.update(position: clampPosition(), source: state.source); + if (controller.scale == controller.previousState.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); + controller.setScaleState(newScaleState, state.source); } Offset get position => controller.position; double get scale { - final scaleState = scaleStateController.scaleState.state; + final scaleState = controller.scaleState.state; final needsRecalc = markNeedsScaleRecalc && !(scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut); final scaleExistsOnController = controller.scale != null; if (needsRecalc || !scaleExistsOnController) { - final newScale = getScaleForScaleState(scaleState, scaleBoundaries); + final newScale = controller.getScaleForScaleState(scaleState); markNeedsScaleRecalc = false; setScale(newScale, ChangeSource.internal); return newScale; @@ -87,14 +83,14 @@ mixin MagnifierControllerDelegate on State { return controller.scale; } - void setScale(double scale, ChangeSource source) => controller.setScale(scale, source); + void setScale(double scale, ChangeSource source) => controller.update(scale: scale, source: source); void updateMultiple({ - Offset position, - double scale, + @required Offset position, + @required double scale, @required ChangeSource source, }) { - controller.updateMultiple(position: position, scale: scale, source: source); + controller.update(position: position, scale: scale, source: source); } void updateScaleStateFromNewScale(double newScale, ChangeSource source) { @@ -102,19 +98,16 @@ mixin MagnifierControllerDelegate on State { if (scale != scaleBoundaries.initialScale) { newScaleState = (newScale > scaleBoundaries.initialScale) ? ScaleState.zoomedIn : ScaleState.zoomedOut; } - scaleStateController.setScaleState(newScaleState, source); + controller.setScaleState(newScaleState, source); } void nextScaleState(ChangeSource source, {Offset childFocalPoint}) { - final scaleState = scaleStateController.scaleState.state; + final scaleState = controller.scaleState.state; if (scaleState == ScaleState.zoomedIn || scaleState == ScaleState.zoomedOut) { - scaleStateController.setScaleState(scaleStateCycle(scaleState), source, childFocalPoint: childFocalPoint); + controller.setScaleState(scaleStateCycle(scaleState), source, childFocalPoint: childFocalPoint); return; } - final originalScale = getScaleForScaleState( - scaleState, - scaleBoundaries, - ); + final originalScale = controller.getScaleForScaleState(scaleState); var prevScale = originalScale; var prevScaleState = scaleState; @@ -125,11 +118,11 @@ mixin MagnifierControllerDelegate on State { prevScale = nextScale; prevScaleState = nextScaleState; nextScaleState = scaleStateCycle(prevScaleState); - nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries); + nextScale = controller.getScaleForScaleState(nextScaleState); } while (prevScale == nextScale && scaleState != nextScaleState); if (originalScale == nextScale) return; - scaleStateController.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint); + controller.setScaleState(nextScaleState, source, childFocalPoint: childFocalPoint); } CornersRange cornersX({double scale}) { @@ -188,30 +181,10 @@ mixin MagnifierControllerDelegate on State { @override void dispose() { _animateScale = null; - _streamSubs.forEach((sub) => sub.cancel()); - _streamSubs.clear(); + _subscriptions.forEach((sub) => sub.cancel()); + _subscriptions.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 diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index fc6f20f68..916b10b49 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -5,7 +5,6 @@ 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'; @@ -18,17 +17,13 @@ class MagnifierCore extends StatefulWidget { @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; @@ -59,7 +54,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi } void handlePositionAnimate() { - controller.setPosition(_positionAnimation.value, ChangeSource.animation); + controller.update(position: _positionAnimation.value, source: ChangeSource.animation); } void onScaleStart(ScaleStartDetails details) { @@ -135,7 +130,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi final viewportTapPosition = details.localPosition; final childTapPosition = scaleBoundaries.viewportToChildPosition(controller, viewportTapPosition); - widget.onTap.call(context, details, controller.value, childTapPosition); + widget.onTap.call(context, details, controller.currentState, childTapPosition); } void onDoubleTap(TapDownDetails details) { @@ -169,8 +164,8 @@ class MagnifierCoreState extends State with TickerProviderStateMi /// 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); + if (controller.scaleState.state != ScaleState.initial && scale == scaleBoundaries.initialScale) { + controller.setScaleState(ScaleState.initial, ChangeSource.animation); } } @@ -183,9 +178,9 @@ class MagnifierCoreState extends State with TickerProviderStateMi _positionAnimationController = AnimationController(vsync: this)..addListener(handlePositionAnimate); startListeners(); - addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate); + setScaleStateUpdateAnimation(animateOnScaleStateUpdate); - cachedScaleBoundaries = widget.scaleBoundaries; + cachedScaleBoundaries = widget.controller.scaleBoundaries; } void animateOnScaleStateUpdate(double prevScale, double nextScale, Offset nextPosition) { @@ -204,49 +199,47 @@ class MagnifierCoreState extends State with TickerProviderStateMi @override Widget build(BuildContext context) { // Check if we need a recalc on the scale - if (widget.scaleBoundaries != cachedScaleBoundaries) { + if (widget.controller.scaleBoundaries != cachedScaleBoundaries) { markNeedsScaleRecalc = true; - cachedScaleBoundaries = widget.scaleBoundaries; + cachedScaleBoundaries = widget.controller.scaleBoundaries; } return StreamBuilder( - stream: controller.outputStateStream, - initialData: controller.prevValue, + stream: controller.stateStream, + initialData: controller.previousState, builder: (context, snapshot) { - if (snapshot.hasData) { - final value = snapshot.data; - final applyScale = widget.applyScale; + if (!snapshot.hasData) return Container(); - final computedScale = applyScale ? scale : 1.0; + final magnifierState = snapshot.data; + final position = magnifierState.position; + final applyScale = widget.applyScale; - final matrix = Matrix4.identity() - ..translate(value.position.dx, value.position.dy) - ..scale(computedScale); + Widget child = CustomSingleChildLayout( + delegate: _CenterWithOriginalSizeDelegate( + scaleBoundaries.childSize, + basePosition, + applyScale, + ), + child: widget.child, + ); - 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(); - } + child = Transform( + transform: Matrix4.identity() + ..translate(position.dx, position.dy) + ..scale(applyScale ? scale : 1.0), + alignment: basePosition, + child: child, + ); + + return MagnifierGestureDetector( + child: child, + onDoubleTap: onDoubleTap, + onScaleStart: onScaleStart, + onScaleUpdate: onScaleUpdate, + onScaleEnd: onScaleEnd, + hitDetector: this, + onTapUp: widget.onTap == null ? null : onTap, + ); }); } } diff --git a/lib/widgets/common/magnifier/magnifier.dart b/lib/widgets/common/magnifier/magnifier.dart index 265b445f0..f5e18a5d3 100644 --- a/lib/widgets/common/magnifier/magnifier.dart +++ b/lib/widgets/common/magnifier/magnifier.dart @@ -3,7 +3,6 @@ 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'; @@ -16,14 +15,13 @@ import 'package:flutter/material.dart'; /// - 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 +/// - fixed focus when scaling by double-tap/pinch class Magnifier extends StatefulWidget { const Magnifier({ Key key, @required this.child, this.childSize, this.controller, - this.scaleStateController, this.maxScale, this.minScale, this.initialScale, @@ -48,7 +46,6 @@ class Magnifier extends StatefulWidget { final ScaleLevel initialScale; final MagnifierController controller; - final MagnifierScaleStateController scaleStateController; final ScaleStateCycle scaleStateCycle; final MagnifierTapCallback onTap; final HitTestBehavior gestureDetectorBehavior; @@ -66,9 +63,6 @@ class _MagnifierState extends State { bool _controlledController; MagnifierController _controller; - bool _controlledScaleStateController; - MagnifierScaleStateController _scaleStateController; - void _setChildSize(Size childSize) { _childSize = childSize.isEmpty ? null : childSize; } @@ -84,14 +78,6 @@ class _MagnifierState extends State { _controlledController = false; _controller = widget.controller; } - - if (widget.scaleStateController == null) { - _controlledScaleStateController = true; - _scaleStateController = MagnifierScaleStateController(); - } else { - _controlledScaleStateController = false; - _scaleStateController = widget.scaleStateController; - } } @override @@ -110,16 +96,6 @@ class _MagnifierState extends State { _controlledController = false; _controller = widget.controller; } - - if (widget.scaleStateController == null) { - if (!_controlledScaleStateController) { - _controlledScaleStateController = true; - _scaleStateController = MagnifierScaleStateController(); - } - } else { - _controlledScaleStateController = false; - _scaleStateController = widget.scaleStateController; - } super.didUpdateWidget(oldWidget); } @@ -128,9 +104,6 @@ class _MagnifierState extends State { if (_controlledController) { _controller.dispose(); } - if (_controlledScaleStateController) { - _scaleStateController.dispose(); - } super.dispose(); } @@ -138,20 +111,18 @@ class _MagnifierState extends State { Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - final scaleBoundaries = ScaleBoundaries( + _controller.setScaleBoundaries(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, diff --git a/lib/widgets/common/magnifier/scale/scalestate_controller.dart b/lib/widgets/common/magnifier/scale/scalestate_controller.dart deleted file mode 100644 index 1296fe8de..000000000 --- a/lib/widgets/common/magnifier/scale/scalestate_controller.dart +++ /dev/null @@ -1,41 +0,0 @@ -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/filter_grids/album_pick.dart b/lib/widgets/filter_grids/album_pick.dart index 2828a8266..c1ce9da15 100644 --- a/lib/widgets/filter_grids/album_pick.dart +++ b/lib/widgets/filter_grids/album_pick.dart @@ -177,9 +177,9 @@ class _AlbumFilterBarState extends State { ), ConstrainedBox( constraints: BoxConstraints(minWidth: 16), - child: AnimatedBuilder( - animation: _controller, - builder: (context, child) => AnimatedSwitcher( + child: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) => AnimatedSwitcher( duration: Durations.appBarActionChangeAnimation, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, @@ -189,7 +189,7 @@ class _AlbumFilterBarState extends State { child: child, ), ), - child: _controller.text.isNotEmpty ? clearButton : SizedBox.shrink(), + child: value.text.isNotEmpty ? clearButton : SizedBox.shrink(), ), ), ) diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 4734d7c71..1c7040720 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,16 +1,19 @@ import 'dart:async'; +import 'dart:math'; import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/settings/entry_background.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/fx/checkered_decoration.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_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:aves/widgets/fullscreen/tiled_view.dart'; import 'package:aves/widgets/fullscreen/video_view.dart'; @@ -18,7 +21,6 @@ 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:provider/provider.dart'; import 'package:tuple/tuple.dart'; class ImageView extends StatefulWidget { @@ -43,10 +45,8 @@ class ImageView extends StatefulWidget { class _ImageViewState extends State { final MagnifierController _magnifierController = MagnifierController(); - final MagnifierScaleStateController _magnifierScaleStateController = MagnifierScaleStateController(); final ValueNotifier _viewStateNotifier = ValueNotifier(ViewState.zero); - StreamSubscription _subscription; - Size _magnifierChildSize; + final List _subscriptions = []; static const initialScale = ScaleLevel(ref: ScaleReference.contained); static const minScale = ScaleLevel(ref: ScaleReference.contained); @@ -56,17 +56,20 @@ class _ImageViewState extends State { MagnifierTapCallback get onTap => widget.onTap; + static const decorationCheckSize = 20.0; + @override void initState() { super.initState(); - _subscription = _magnifierController.outputStateStream.listen(_onViewChanged); - _magnifierChildSize = entry.displaySize; + _subscriptions.add(_magnifierController.stateStream.listen(_onViewStateChanged)); + _subscriptions.add(_magnifierController.scaleBoundariesStream.listen(_onViewScaleBoundariesChanged)); } @override void dispose() { - _subscription.cancel(); - _subscription = null; + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); widget.onDisposed?.call(); super.dispose(); } @@ -118,30 +121,14 @@ class _ImageViewState extends State { 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, - // `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 (_magnifierScaleStateController.scaleState.state == ScaleState.initial) { - final value = MagnifierState(position: Offset.zero, scale: 0, source: ChangeSource.internal); - WidgetsBinding.instance.addPostFrameCallback((_) => _onViewChanged(value)); - } - return TiledImageView( - entry: entry, - viewportSize: mqSize, - viewStateNotifier: _viewStateNotifier, - baseChild: _loadingBuilder(context, fastThumbnailProvider), - errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)), - ); - }, + child: TiledImageView( + entry: entry, + viewStateNotifier: _viewStateNotifier, + baseChild: _loadingBuilder(context, fastThumbnailProvider), + errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)), ), childSize: entry.displaySize, controller: _magnifierController, - scaleStateController: _magnifierScaleStateController, maxScale: maxScale, minScale: minScale, initialScale: initialScale, @@ -151,8 +138,10 @@ class _ImageViewState extends State { } Widget _buildSvgView() { - final colorFilter = ColorFilter.mode(Color(settings.svgBackground), BlendMode.dstOver); - return Magnifier( + final background = settings.vectorBackground; + final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null; + + Widget child = Magnifier( child: SvgPicture( UriPicture( uri: entry.uri, @@ -167,6 +156,42 @@ class _ImageViewState extends State { scaleStateCycle: _vectorScaleStateCycle, onTap: (c, d, s, childPosition) => onTap?.call(childPosition), ); + + if (background == EntryBackground.checkered) { + child = ValueListenableBuilder( + valueListenable: _viewStateNotifier, + builder: (context, viewState, child) { + final viewportSize = viewState.viewportSize; + if (viewportSize == null) return child; + + final side = viewportSize.shortestSide; + final checkSize = side / ((side / decorationCheckSize).round()); + + final viewSize = entry.displaySize * viewState.scale; + final decorationSize = Size(min(viewSize.width, viewportSize.width), min(viewSize.height, viewportSize.height)); + final offset = Offset(decorationSize.width - viewportSize.width, decorationSize.height - viewportSize.height) / 2; + + return Stack( + alignment: Alignment.center, + children: [ + Positioned( + width: decorationSize.width, + height: decorationSize.height, + child: DecoratedBox( + decoration: CheckeredDecoration( + checkSize: checkSize, + offset: offset, + ), + ), + ), + child, + ], + ); + }, + child: child, + ); + } + return child; } Widget _buildVideoView() { @@ -187,8 +212,16 @@ class _ImageViewState extends State { ); } - void _onViewChanged(MagnifierState v) { - final viewState = ViewState(v.position, v.scale, _magnifierChildSize); + void _onViewStateChanged(MagnifierState v) { + final current = _viewStateNotifier.value; + final viewState = ViewState(v.position, v.scale, current.viewportSize); + _viewStateNotifier.value = viewState; + ViewStateNotification(entry.uri, viewState).dispatch(context); + } + + void _onViewScaleBoundariesChanged(ScaleBoundaries v) { + final current = _viewStateNotifier.value; + final viewState = ViewState(current.position, current.scale, v.viewportSize); _viewStateNotifier.value = viewState; ViewStateNotification(entry.uri, viewState).dispatch(context); } @@ -206,14 +239,14 @@ class _ImageViewState extends State { class ViewState { final Offset position; final double scale; - final Size size; + final Size viewportSize; - static const ViewState zero = ViewState(Offset(0.0, 0.0), 0, null); + static const ViewState zero = ViewState(Offset.zero, 0, null); - const ViewState(this.position, this.scale, this.size); + const ViewState(this.position, this.scale, this.viewportSize); @override - String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, size=$size}'; + String toString() => '$runtimeType#${shortHash(this)}{position=$position, scale=$scale, viewportSize=$viewportSize}'; } class ViewStateNotification extends Notification { diff --git a/lib/widgets/fullscreen/info/metadata/metadata_section.dart b/lib/widgets/fullscreen/info/metadata/metadata_section.dart index 852ea2897..e12c8822d 100644 --- a/lib/widgets/fullscreen/info/metadata/metadata_section.dart +++ b/lib/widgets/fullscreen/info/metadata/metadata_section.dart @@ -92,9 +92,9 @@ class _MetadataSectionSliverState extends State with Auto // cancel notification bubbling so that the info page // does not misinterpret content scrolling for page scrolling onNotification: (notification) => true, - child: AnimatedBuilder( - animation: _loadedMetadataUri, - builder: (context, child) { + child: ValueListenableBuilder( + valueListenable: _loadedMetadataUri, + builder: (context, uri, child) { Widget content; if (_metadata.isEmpty) { content = SizedBox.shrink(); @@ -119,7 +119,7 @@ class _MetadataSectionSliverState extends State with Auto return AnimationLimiter( // we update the limiter key after fetching the metadata of a new entry, // in order to restart the staggered animation of the metadata section - key: Key(_loadedMetadataUri.value), + key: Key(uri), child: content, ); }, diff --git a/lib/widgets/fullscreen/overlay/minimap.dart b/lib/widgets/fullscreen/overlay/minimap.dart index 94c6edad1..561cc3971 100644 --- a/lib/widgets/fullscreen/overlay/minimap.dart +++ b/lib/widgets/fullscreen/overlay/minimap.dart @@ -4,7 +4,6 @@ import 'package:aves/model/image_entry.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; class Minimap extends StatelessWidget { final ImageEntry entry; @@ -22,24 +21,21 @@ class Minimap extends StatelessWidget { @override Widget build(BuildContext context) { 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, - ); - }); + child: ValueListenableBuilder( + valueListenable: viewStateNotifier, + builder: (context, viewState, child) { + final viewportSize = viewState.viewportSize; + if (viewportSize == null) return SizedBox.shrink(); + return CustomPaint( + painter: MinimapPainter( + viewportSize: viewportSize, + entrySize: 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 758db440b..cf829c479 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -10,14 +10,12 @@ import 'package:flutter/material.dart'; class TiledImageView extends StatefulWidget { final ImageEntry entry; - final Size viewportSize; final ValueNotifier viewStateNotifier; final Widget baseChild; final ImageErrorWidgetBuilder errorBuilder; const TiledImageView({ @required this.entry, - @required this.viewportSize, @required this.viewStateNotifier, @required this.baseChild, @required this.errorBuilder, @@ -28,14 +26,13 @@ class TiledImageView extends StatefulWidget { } class _TiledImageViewState extends State { + bool _initialized = false; double _tileSide, _initialScale; int _maxSampleSize; Matrix4 _transform; ImageEntry get entry => widget.entry; - Size get viewportSize => widget.viewportSize; - ValueNotifier get viewStateNotifier => widget.viewStateNotifier; bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096); @@ -51,24 +48,21 @@ class _TiledImageViewState extends State { // magic number used to derive sample size from scale static const scaleFactor = 2.0; - @override - void initState() { - super.initState(); - _init(); - } - @override void didUpdateWidget(TiledImageView oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.viewportSize != widget.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) { - _init(); + final oldViewState = oldWidget.viewStateNotifier.value; + final viewState = widget.viewStateNotifier.value; + if (oldViewState.viewportSize != viewState.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) { + _initialized = false; } } - void _init() { + void _initFromViewport(Size viewportSize) { + final displaySize = entry.displaySize; _tileSide = viewportSize.shortestSide * scaleFactor; - _initialScale = min(viewportSize.width / entry.displaySize.width, viewportSize.height / entry.displaySize.height); + _initialScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height); _maxSampleSize = _sampleSizeForScale(_initialScale); final rotationDegrees = entry.rotationDegrees; @@ -79,8 +73,9 @@ class _TiledImageViewState extends State { ..translate(entry.width / 2.0, entry.height / 2.0) ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) ..rotateZ(-toRadians(rotationDegrees.toDouble())) - ..translate(-entry.displaySize.width / 2.0, -entry.displaySize.height / 2.0); + ..translate(-displaySize.width / 2.0, -displaySize.height / 2.0); } + _initialized = true; } @override @@ -90,10 +85,13 @@ class _TiledImageViewState extends State { final displayWidth = entry.displaySize.width.round(); final displayHeight = entry.displaySize.height.round(); - return AnimatedBuilder( - animation: viewStateNotifier, - builder: (context, child) { - final viewState = viewStateNotifier.value; + return ValueListenableBuilder( + valueListenable: viewStateNotifier, + builder: (context, viewState, child) { + final viewportSize = viewState.viewportSize; + if (viewportSize == null) return SizedBox.shrink(); + if (!_initialized) _initFromViewport(viewportSize); + var scale = viewState.scale; if (scale == 0.0) { // for initial scale as `contained` @@ -136,6 +134,7 @@ class _TiledImageViewState extends State { List _getTiles(ViewState viewState, int displayWidth, int displayHeight, double scale) { final centerOffset = viewState.position; + final viewportSize = viewState.viewportSize; final viewOrigin = Offset( ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), diff --git a/lib/widgets/settings/svg_background.dart b/lib/widgets/settings/svg_background.dart index 929b5a8ad..a2c68e251 100644 --- a/lib/widgets/settings/svg_background.dart +++ b/lib/widgets/settings/svg_background.dart @@ -1,5 +1,7 @@ +import 'package:aves/model/settings/entry_background.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:flutter/material.dart'; class SvgBackgroundSelector extends StatefulWidget { @@ -10,33 +12,53 @@ class SvgBackgroundSelector extends StatefulWidget { class _SvgBackgroundSelectorState extends State { @override Widget build(BuildContext context) { - const radius = 24.0; + const radius = 12.0; return DropdownButtonHideUnderline( - child: DropdownButton( - items: [0xFFFFFFFF, 0xFF000000, 0x00000000].map((selected) { - return DropdownMenuItem( + child: DropdownButton( + items: [ + EntryBackground.white, + EntryBackground.black, + EntryBackground.checkered, + EntryBackground.transparent, + ].map((selected) { + Widget child; + switch (selected) { + case EntryBackground.transparent: + child = Icon( + Icons.clear, + size: 20, + color: Colors.white30, + ); + break; + case EntryBackground.checkered: + child = ClipOval( + child: DecoratedBox( + decoration: CheckeredDecoration( + checkSize: radius, + ), + ), + ); + break; + default: + break; + } + return DropdownMenuItem( value: selected, child: Container( - height: radius, - width: radius, + height: radius * 2, + width: radius * 2, decoration: BoxDecoration( - color: Color(selected), + color: selected.isColor ? selected.color : null, border: AvesCircleBorder.build(context), shape: BoxShape.circle, ), - child: selected == 0 - ? Icon( - Icons.clear, - size: 20, - color: Colors.white30, - ) - : null, + child: child, ), ); }).toList(), - value: settings.svgBackground, + value: settings.vectorBackground, onChanged: (selected) { - settings.svgBackground = selected; + settings.vectorBackground = selected; setState(() {}); }, ), From 5e7c85597a63838de70e5ea39e902a2d404bb8c9 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Tue, 22 Dec 2020 10:02:19 +0900 Subject: [PATCH 07/13] fixes for thumbnail checkered background with box fit --- .../collection/thumbnail/decorated.dart | 1 + lib/widgets/collection/thumbnail/vector.dart | 75 ++++++++++--------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/lib/widgets/collection/thumbnail/decorated.dart b/lib/widgets/collection/thumbnail/decorated.dart index a92d4e550..073656a6a 100644 --- a/lib/widgets/collection/thumbnail/decorated.dart +++ b/lib/widgets/collection/thumbnail/decorated.dart @@ -43,6 +43,7 @@ class DecoratedThumbnail extends StatelessWidget { ); child = Stack( + alignment: Alignment.center, children: [ child, Positioned( diff --git a/lib/widgets/collection/thumbnail/vector.dart b/lib/widgets/collection/thumbnail/vector.dart index c589da062..384fae2e8 100644 --- a/lib/widgets/collection/thumbnail/vector.dart +++ b/lib/widgets/collection/thumbnail/vector.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/entry_background.dart'; @@ -23,42 +21,47 @@ class ThumbnailVectorImage extends StatelessWidget { @override Widget build(BuildContext context) { - final pictureProvider = UriPicture( - uri: entry.uri, - mimeType: entry.mimeType, - ); - - final child = Center( - child: Selector( - selector: (context, s) => s.vectorBackground, - builder: (context, background, child) { - if (background == EntryBackground.transparent) { - return SvgPicture( - pictureProvider, - width: extent, - height: extent, - ); - } - - final longestSide = max(entry.width, entry.height); - final picture = SvgPicture( - pictureProvider, - width: extent * (entry.width / longestSide), - height: extent * (entry.height / longestSide), + final child = Selector( + selector: (context, s) => s.vectorBackground, + builder: (context, background, child) { + const fit = BoxFit.contain; + if (background == EntryBackground.checkered) { + return LayoutBuilder( + builder: (context, constraints) { + final availableSize = constraints.biggest; + final fitSize = applyBoxFit(fit, entry.displaySize, availableSize).destination; + final offset = fitSize / 2 - availableSize / 2; + final child = DecoratedBox( + decoration: CheckeredDecoration(checkSize: extent / 8, offset: offset), + child: SvgPicture( + UriPicture( + uri: entry.uri, + mimeType: entry.mimeType, + ), + width: fitSize.width, + height: fitSize.height, + fit: fit, + ), + ); + // the thumbnail is centered for correct decoration sizing + // when constraints are tight during hero animation + return constraints.isTight ? Center(child: child) : child; + }, ); + } - Decoration decoration; - if (background == EntryBackground.checkered) { - decoration = CheckeredDecoration(checkSize: extent / 8); - } else if (background.isColor) { - decoration = BoxDecoration(color: background.color); - } - return DecoratedBox( - decoration: decoration, - child: picture, - ); - }, - ), + final colorFilter = background.isColor ? ColorFilter.mode(background.color, BlendMode.dstOver) : null; + return SvgPicture( + UriPicture( + uri: entry.uri, + mimeType: entry.mimeType, + colorFilter: colorFilter, + ), + width: extent, + height: extent, + fit: fit, + ); + }, ); return heroTag == null ? child From 07b9db6750019be9b8e7957750aad1e535096ddf Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 23 Dec 2020 16:00:46 +0900 Subject: [PATCH 08/13] raster image background --- lib/model/image_entry.dart | 2 + lib/model/settings/settings.dart | 5 + lib/ref/mime_types.dart | 8 +- lib/ref/xmp.dart | 1 + lib/widgets/fullscreen/image_view.dart | 32 +- lib/widgets/fullscreen/tiled_view.dart | 334 +++++++++++++++------ lib/widgets/settings/entry_background.dart | 78 +++++ lib/widgets/settings/settings_page.dart | 16 +- lib/widgets/settings/svg_background.dart | 67 ----- 9 files changed, 345 insertions(+), 198 deletions(-) create mode 100644 lib/widgets/settings/entry_background.dart delete mode 100644 lib/widgets/settings/svg_background.dart diff --git a/lib/model/image_entry.dart b/lib/model/image_entry.dart index 6a54de74a..4cd6f3cd3 100644 --- a/lib/model/image_entry.dart +++ b/lib/model/image_entry.dart @@ -63,6 +63,8 @@ class ImageEntry { bool get canDecode => !undecodable.contains(mimeType); + bool get canHaveAlpha => MimeTypes.alphaImages.contains(mimeType); + ImageEntry copyWith({ @required String uri, @required String path, diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 8ee13f644..7e39a0ca8 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -55,6 +55,7 @@ class Settings extends ChangeNotifier { static const coordinateFormatKey = 'coordinates_format'; // rendering + static const rasterBackgroundKey = 'raster_background'; static const vectorBackgroundKey = 'vector_background'; // search @@ -185,6 +186,10 @@ class Settings extends ChangeNotifier { // rendering + EntryBackground get rasterBackground => getEnumOrDefault(rasterBackgroundKey, EntryBackground.transparent, EntryBackground.values); + + set rasterBackground(EntryBackground newValue) => setAndNotify(rasterBackgroundKey, newValue.toString()); + EntryBackground get vectorBackground => getEnumOrDefault(vectorBackgroundKey, EntryBackground.white, EntryBackground.values); set vectorBackground(EntryBackground newValue) => setAndNotify(vectorBackgroundKey, newValue.toString()); diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index fe95d5309..69dfe3800 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -1,15 +1,17 @@ class MimeTypes { static const anyImage = 'image/*'; + static const bmp = 'image/bmp'; static const gif = 'image/gif'; static const heic = 'image/heic'; static const heif = 'image/heif'; + static const ico = 'image/x-icon'; static const jpeg = 'image/jpeg'; static const png = 'image/png'; static const svg = 'image/svg+xml'; + static const tiff = 'image/tiff'; static const webp = 'image/webp'; - static const tiff = 'image/tiff'; static const psd = 'image/vnd.adobe.photoshop'; static const arw = 'image/x-sony-arw'; @@ -40,6 +42,10 @@ class MimeTypes { static const mp4 = 'video/mp4'; // groups + + // formats that support transparency + static const List alphaImages = [bmp, gif, ico, png, svg, tiff, webp]; + static const List rawImages = [arw, cr2, crw, dcr, dng, erf, k25, kdc, mrw, nef, nrw, orf, pef, raf, raw, rw2, sr2, srf, srw, x3f]; static bool isImage(String mimeType) => mimeType.startsWith('image'); diff --git a/lib/ref/xmp.dart b/lib/ref/xmp.dart index ebff07696..02a3bfa9e 100644 --- a/lib/ref/xmp.dart +++ b/lib/ref/xmp.dart @@ -15,6 +15,7 @@ class XMP { 'exifEX': 'Exif Ex', 'GettyImagesGIFT': 'Getty Images', 'GIMP': 'GIMP', + 'GCamera': 'Google Camera', 'GFocus': 'Google Focus', 'GPano': 'Google Panorama', 'illustrator': 'Illustrator', diff --git a/lib/widgets/fullscreen/image_view.dart b/lib/widgets/fullscreen/image_view.dart index 1c7040720..b3c5e1ada 100644 --- a/lib/widgets/fullscreen/image_view.dart +++ b/lib/widgets/fullscreen/image_view.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:math'; -import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/uri_picture_provider.dart'; import 'package:aves/model/image_entry.dart'; import 'package:aves/model/settings/entry_background.dart'; @@ -30,6 +28,8 @@ class ImageView extends StatefulWidget { final List> videoControllers; final VoidCallback onDisposed; + static const decorationCheckSize = 20.0; + const ImageView({ Key key, @required this.entry, @@ -56,8 +56,6 @@ class _ImageViewState extends State { MagnifierTapCallback get onTap => widget.onTap; - static const decorationCheckSize = 20.0; - @override void initState() { super.initState(); @@ -98,25 +96,6 @@ class _ImageViewState extends State { : child; } - 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 - // is laid the same way as the final image when `contained` - Widget _loadingBuilder(BuildContext context, ImageProvider imageProvider) { - return Center( - child: AspectRatio( - // enforce original aspect ratio, as some thumbnails aspect ratios slightly differ - aspectRatio: entry.displayAspectRatio, - child: Image( - image: imageProvider, - fit: BoxFit.fill, - ), - ), - ); - } - Widget _buildRasterView() { return Magnifier( // key includes size and orientation to refresh when the image is rotated @@ -124,7 +103,6 @@ class _ImageViewState extends State { child: TiledImageView( entry: entry, viewStateNotifier: _viewStateNotifier, - baseChild: _loadingBuilder(context, fastThumbnailProvider), errorBuilder: (context, error, stackTrace) => ErrorChild(onTap: () => onTap?.call(null)), ), childSize: entry.displaySize, @@ -165,11 +143,11 @@ class _ImageViewState extends State { if (viewportSize == null) return child; final side = viewportSize.shortestSide; - final checkSize = side / ((side / decorationCheckSize).round()); + final checkSize = side / ((side / ImageView.decorationCheckSize).round()); final viewSize = entry.displaySize * viewState.scale; - final decorationSize = Size(min(viewSize.width, viewportSize.width), min(viewSize.height, viewportSize.height)); - final offset = Offset(decorationSize.width - viewportSize.width, decorationSize.height - viewportSize.height) / 2; + final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; + final offset = ((decorationSize - viewportSize) as Offset) / 2; return Stack( alignment: Alignment.center, diff --git a/lib/widgets/fullscreen/tiled_view.dart b/lib/widgets/fullscreen/tiled_view.dart index cf829c479..c64fcea6f 100644 --- a/lib/widgets/fullscreen/tiled_view.dart +++ b/lib/widgets/fullscreen/tiled_view.dart @@ -1,23 +1,26 @@ import 'dart:math'; import 'package:aves/image_providers/region_provider.dart'; +import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/image_providers/uri_image_provider.dart'; import 'package:aves/model/image_entry.dart'; +import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/math_utils.dart'; +import 'package:aves/widgets/common/fx/checkered_decoration.dart'; import 'package:aves/widgets/fullscreen/image_view.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:tuple/tuple.dart'; class TiledImageView extends StatefulWidget { final ImageEntry entry; final ValueNotifier viewStateNotifier; - final Widget baseChild; final ImageErrorWidgetBuilder errorBuilder; const TiledImageView({ @required this.entry, @required this.viewStateNotifier, - @required this.baseChild, @required this.errorBuilder, }); @@ -26,159 +29,244 @@ class TiledImageView extends StatefulWidget { } class _TiledImageViewState extends State { - bool _initialized = false; - double _tileSide, _initialScale; + bool _isTilingInitialized = false; int _maxSampleSize; - Matrix4 _transform; + double _tileSide; + Matrix4 _tileTransform; + ImageStream _fullImageStream; + ImageStreamListener _fullImageListener; + final ValueNotifier _fullImageLoaded = ValueNotifier(false); ImageEntry get entry => widget.entry; ValueNotifier get viewStateNotifier => widget.viewStateNotifier; + bool get useBackground => entry.canHaveAlpha && settings.rasterBackground != EntryBackground.transparent; + bool get useTiles => entry.canTile && (entry.width > 4096 || entry.height > 4096); - ImageProvider get fullImage => UriImage( + ImageProvider get thumbnailProvider => ThumbnailProvider(ThumbnailProviderKey.fromEntry(entry)); + + ImageProvider get fullImageProvider { + if (useTiles) { + assert(_isTilingInitialized); + final displayWidth = entry.displaySize.width.round(); + final displayHeight = entry.displaySize.height.round(); + final viewState = viewStateNotifier.value; + final regionRect = _getTileRects( + x: 0, + y: 0, + layerRegionWidth: displayWidth, + layerRegionHeight: displayHeight, + displayWidth: displayWidth, + displayHeight: displayHeight, + scale: viewState.scale, + viewRect: _getViewRect(viewState, displayWidth, displayHeight), + ).item2; + return RegionProvider(RegionProviderKey.fromEntry( + entry, + sampleSize: _maxSampleSize, + rect: regionRect, + )); + } else { + return 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; + @override + void initState() { + super.initState(); + _fullImageListener = ImageStreamListener(_onFullImageCompleted); + if (!useTiles) _registerFullImage(); + } + @override void didUpdateWidget(TiledImageView oldWidget) { super.didUpdateWidget(oldWidget); final oldViewState = oldWidget.viewStateNotifier.value; final viewState = widget.viewStateNotifier.value; - if (oldViewState.viewportSize != viewState.viewportSize || oldWidget.entry.displaySize != widget.entry.displaySize) { - _initialized = false; + if (oldWidget.entry != widget.entry || oldViewState.viewportSize != viewState.viewportSize) { + _isTilingInitialized = false; + _fullImageLoaded.value = false; + _unregisterFullImage(); } } - void _initFromViewport(Size viewportSize) { - final displaySize = entry.displaySize; - _tileSide = viewportSize.shortestSide * scaleFactor; - _initialScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height); - _maxSampleSize = _sampleSizeForScale(_initialScale); + @override + void dispose() { + _unregisterFullImage(); + super.dispose(); + } - final rotationDegrees = entry.rotationDegrees; - final isFlipped = entry.isFlipped; - _transform = null; - if (rotationDegrees != 0 || isFlipped) { - _transform = Matrix4.identity() - ..translate(entry.width / 2.0, entry.height / 2.0) - ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) - ..rotateZ(-toRadians(rotationDegrees.toDouble())) - ..translate(-displaySize.width / 2.0, -displaySize.height / 2.0); - } - _initialized = true; + void _registerFullImage() { + _fullImageStream = fullImageProvider.resolve(ImageConfiguration.empty); + _fullImageStream.addListener(_fullImageListener); + } + + void _unregisterFullImage() { + _fullImageStream?.removeListener(_fullImageListener); + _fullImageStream = null; + } + + void _onFullImageCompleted(ImageInfo image, bool synchronousCall) { + _unregisterFullImage(); + _fullImageLoaded.value = true; } @override Widget build(BuildContext context) { if (viewStateNotifier == null) return SizedBox.shrink(); - final displayWidth = entry.displaySize.width.round(); - final displayHeight = entry.displaySize.height.round(); - return ValueListenableBuilder( valueListenable: viewStateNotifier, builder: (context, viewState, child) { final viewportSize = viewState.viewportSize; - if (viewportSize == null) return SizedBox.shrink(); - if (!_initialized) _initFromViewport(viewportSize); + final viewportSized = viewportSize != null; + if (viewportSized && useTiles && !_isTilingInitialized) _initTiling(viewportSize); - 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, - ); - - 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, - ) - ]; - } - - return Stack( - alignment: Alignment.center, - children: children, + return SizedBox.fromSize( + size: entry.displaySize * viewState.scale, + child: Stack( + alignment: Alignment.center, + children: [ + if (useBackground && viewportSized) _buildBackground(viewState), + _buildLoading(viewState), + if (useTiles) ..._getTiles(viewState), + if (!useTiles) + Image( + image: fullImageProvider, + gaplessPlayback: true, + errorBuilder: widget.errorBuilder, + width: (entry.displaySize * viewState.scale).width, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ), + ], + ), ); }, ); } - List _getTiles(ViewState viewState, int displayWidth, int displayHeight, double scale) { - final centerOffset = viewState.position; - final viewportSize = viewState.viewportSize; - final viewOrigin = Offset( - ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), - ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), + void _initTiling(Size viewportSize) { + final displaySize = entry.displaySize; + _tileSide = viewportSize.shortestSide * scaleFactor; + // scale for initial state `contained` + final containedScale = min(viewportSize.width / displaySize.width, viewportSize.height / displaySize.height); + _maxSampleSize = _sampleSizeForScale(containedScale); + + final rotationDegrees = entry.rotationDegrees; + final isFlipped = entry.isFlipped; + _tileTransform = null; + if (rotationDegrees != 0 || isFlipped) { + _tileTransform = Matrix4.identity() + ..translate(entry.width / 2.0, entry.height / 2.0) + ..scale(isFlipped ? -1.0 : 1.0, 1.0, 1.0) + ..rotateZ(-toRadians(rotationDegrees.toDouble())) + ..translate(-displaySize.width / 2.0, -displaySize.height / 2.0); + } + _isTilingInitialized = true; + _registerFullImage(); + } + + Widget _buildLoading(ViewState viewState) { + return ValueListenableBuilder( + valueListenable: _fullImageLoaded, + builder: (context, fullImageLoaded, child) { + if (fullImageLoaded) return SizedBox.shrink(); + + return Center( + child: AspectRatio( + // enforce original aspect ratio, as some thumbnails aspect ratios slightly differ + aspectRatio: entry.displayAspectRatio, + child: Image( + image: thumbnailProvider, + fit: BoxFit.fill, + ), + ), + ); + }, ); - final viewRect = viewOrigin & viewportSize; + } + + Widget _buildBackground(ViewState viewState) { + final viewportSize = viewState.viewportSize; + assert(viewportSize != null); + + final viewSize = entry.displaySize * viewState.scale; + final decorationOffset = ((viewSize - viewportSize) as Offset) / 2 - viewState.position; + final decorationSize = applyBoxFit(BoxFit.none, viewSize, viewportSize).source; + + Decoration decoration; + final background = settings.rasterBackground; + if (background == EntryBackground.checkered) { + final side = viewportSize.shortestSide; + final checkSize = side / ((side / ImageView.decorationCheckSize).round()); + final offset = ((decorationSize - viewportSize) as Offset) / 2; + decoration = CheckeredDecoration( + checkSize: checkSize, + offset: offset, + ); + } else { + decoration = BoxDecoration( + color: background.color, + ); + } + return Positioned( + left: decorationOffset.dx >= 0 ? decorationOffset.dx : null, + top: decorationOffset.dy >= 0 ? decorationOffset.dy : null, + width: decorationSize.width, + height: decorationSize.height, + child: DecoratedBox( + decoration: decoration, + ), + ); + } + + List _getTiles(ViewState viewState) { + if (!_isTilingInitialized) return []; + + final displayWidth = entry.displaySize.width.round(); + final displayHeight = entry.displaySize.height.round(); + final viewRect = _getViewRect(viewState, displayWidth, displayHeight); + final scale = viewState.scale; 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; + // so we subsample the whole image without tiling + final fullImageRegion = sampleSize == _maxSampleSize; final regionSide = (_tileSide * sampleSize).round(); - final layerRegionWidth = useTiles ? regionSide : displayWidth; - final layerRegionHeight = useTiles ? regionSide : displayHeight; + final layerRegionWidth = fullImageRegion ? displayWidth : regionSide; + final layerRegionHeight = fullImageRegion ? displayHeight : regionSide; 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); - - // 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); - } - + final rects = _getTileRects( + x: x, + y: y, + layerRegionWidth: layerRegionWidth, + layerRegionHeight: layerRegionHeight, + displayWidth: displayWidth, + displayHeight: displayHeight, + scale: scale, + viewRect: viewRect, + ); + if (rects != null) { tiles.add(RegionTile( entry: entry, - tileRect: tileRect, - regionRect: regionRect, + tileRect: rects.item1, + regionRect: rects.item2, sampleSize: sampleSize, )); } @@ -188,6 +276,52 @@ class _TiledImageViewState extends State { return tiles; } + Rect _getViewRect(ViewState viewState, int displayWidth, int displayHeight) { + final scale = viewState.scale; + final centerOffset = viewState.position; + final viewportSize = viewState.viewportSize; + final viewOrigin = Offset( + ((displayWidth * scale - viewportSize.width) / 2 - centerOffset.dx), + ((displayHeight * scale - viewportSize.height) / 2 - centerOffset.dy), + ); + return viewOrigin & viewportSize; + } + + Tuple2> _getTileRects({ + @required int x, + @required int y, + @required int layerRegionWidth, + @required int layerRegionHeight, + @required int displayWidth, + @required int displayHeight, + @required double scale, + @required Rect viewRect, + }) { + 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); + + // only build visible tiles + if (!viewRect.overlaps(tileRect)) return null; + + Rectangle regionRect; + if (_tileTransform != null) { + // apply EXIF orientation + final regionRectDouble = Rect.fromLTWH(x.toDouble(), y.toDouble(), thisRegionWidth.toDouble(), thisRegionHeight.toDouble()); + final tl = MatrixUtils.transformPoint(_tileTransform, regionRectDouble.topLeft); + final br = MatrixUtils.transformPoint(_tileTransform, 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 Tuple2>(tileRect, regionRect); + } + int _sampleSizeForScale(double scale) { var sample = 0; if (0 < scale && scale < 1) { diff --git a/lib/widgets/settings/entry_background.dart b/lib/widgets/settings/entry_background.dart new file mode 100644 index 000000000..ade54d894 --- /dev/null +++ b/lib/widgets/settings/entry_background.dart @@ -0,0 +1,78 @@ +import 'package:aves/model/settings/entry_background.dart'; +import 'package:aves/widgets/common/fx/borders.dart'; +import 'package:aves/widgets/common/fx/checkered_decoration.dart'; +import 'package:flutter/material.dart'; + +class EntryBackgroundSelector extends StatefulWidget { + final ValueGetter getter; + final ValueSetter setter; + + const EntryBackgroundSelector({ + @required this.getter, + @required this.setter, + }); + + @override + _EntryBackgroundSelectorState createState() => _EntryBackgroundSelectorState(); +} + +class _EntryBackgroundSelectorState extends State { + @override + Widget build(BuildContext context) { + return DropdownButtonHideUnderline( + child: DropdownButton( + items: _buildItems(context), + value: widget.getter(), + onChanged: (selected) { + widget.setter(selected); + setState(() {}); + }, + ), + ); + } + + List> _buildItems(BuildContext context) { + const radius = 12.0; + return [ + EntryBackground.white, + EntryBackground.black, + EntryBackground.checkered, + EntryBackground.transparent, + ].map((selected) { + Widget child; + switch (selected) { + case EntryBackground.transparent: + child = Icon( + Icons.clear, + size: 20, + color: Colors.white30, + ); + break; + case EntryBackground.checkered: + child = ClipOval( + child: DecoratedBox( + decoration: CheckeredDecoration( + checkSize: radius, + ), + ), + ); + break; + default: + break; + } + return DropdownMenuItem( + value: selected, + child: Container( + height: radius * 2, + width: radius * 2, + decoration: BoxDecoration( + color: selected.isColor ? selected.color : null, + border: AvesCircleBorder.build(context), + shape: BoxShape.circle, + ), + child: child, + ), + ); + }).toList(); + } +} diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index a6e52de38..65c320102 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -8,7 +8,7 @@ import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/dialogs/aves_selection_dialog.dart'; import 'package:aves/widgets/settings/access_grants.dart'; -import 'package:aves/widgets/settings/svg_background.dart'; +import 'package:aves/widgets/settings/entry_background.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; @@ -115,8 +115,18 @@ class _SettingsPageState extends State { }, ), ListTile( - title: Text('SVG background'), - trailing: SvgBackgroundSelector(), + title: Text('Raster image background'), + trailing: EntryBackgroundSelector( + getter: () => settings.rasterBackground, + setter: (value) => settings.rasterBackground = value, + ), + ), + ListTile( + title: Text('Vector image background'), + trailing: EntryBackgroundSelector( + getter: () => settings.vectorBackground, + setter: (value) => settings.vectorBackground = value, + ), ), ListTile( title: Text('Coordinate format'), diff --git a/lib/widgets/settings/svg_background.dart b/lib/widgets/settings/svg_background.dart deleted file mode 100644 index a2c68e251..000000000 --- a/lib/widgets/settings/svg_background.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:aves/model/settings/entry_background.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/fx/borders.dart'; -import 'package:aves/widgets/common/fx/checkered_decoration.dart'; -import 'package:flutter/material.dart'; - -class SvgBackgroundSelector extends StatefulWidget { - @override - _SvgBackgroundSelectorState createState() => _SvgBackgroundSelectorState(); -} - -class _SvgBackgroundSelectorState extends State { - @override - Widget build(BuildContext context) { - const radius = 12.0; - return DropdownButtonHideUnderline( - child: DropdownButton( - items: [ - EntryBackground.white, - EntryBackground.black, - EntryBackground.checkered, - EntryBackground.transparent, - ].map((selected) { - Widget child; - switch (selected) { - case EntryBackground.transparent: - child = Icon( - Icons.clear, - size: 20, - color: Colors.white30, - ); - break; - case EntryBackground.checkered: - child = ClipOval( - child: DecoratedBox( - decoration: CheckeredDecoration( - checkSize: radius, - ), - ), - ); - break; - default: - break; - } - return DropdownMenuItem( - value: selected, - child: Container( - height: radius * 2, - width: radius * 2, - decoration: BoxDecoration( - color: selected.isColor ? selected.color : null, - border: AvesCircleBorder.build(context), - shape: BoxShape.circle, - ), - child: child, - ), - ); - }).toList(), - value: settings.vectorBackground, - onChanged: (selected) { - settings.vectorBackground = selected; - setState(() {}); - }, - ), - ); - } -} From e914188917901e641f113b9c32d7ba06c1aedc8b Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 23 Dec 2020 18:51:10 +0900 Subject: [PATCH 09/13] viewer: quick scale --- lib/widgets/common/magnifier/core/core.dart | 43 +++-- .../magnifier/core/gesture_detector.dart | 159 ++---------------- .../core/scale_gesture_recognizer.dart | 145 ++++++++++++++++ 3 files changed, 189 insertions(+), 158 deletions(-) create mode 100644 lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index 916b10b49..19392588a 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -38,8 +38,9 @@ class MagnifierCore extends StatefulWidget { } class MagnifierCoreState extends State with TickerProviderStateMixin, MagnifierControllerDelegate, CornerHitDetector { - Offset _prevViewportFocalPosition; - double _gestureStartScale; + Offset _startFocalPoint, _lastViewportFocalPosition; + double _startScale, _quickScaleLastY, _quickScaleLastDistance; + bool _doubleTap, _quickScaleMoved; AnimationController _scaleAnimationController; Animation _scaleAnimation; @@ -57,18 +58,40 @@ class MagnifierCoreState extends State with TickerProviderStateMi controller.update(position: _positionAnimation.value, source: ChangeSource.animation); } - void onScaleStart(ScaleStartDetails details) { - _gestureStartScale = scale; - _prevViewportFocalPosition = details.localFocalPoint; + void onScaleStart(ScaleStartDetails details, bool doubleTap) { + _startScale = scale; + _startFocalPoint = details.localFocalPoint; + _lastViewportFocalPosition = _startFocalPoint; + _doubleTap = doubleTap; + _quickScaleLastDistance = null; + _quickScaleLastY = _startFocalPoint.dy; + _quickScaleMoved = false; _scaleAnimationController.stop(); _positionAnimationController.stop(); } void onScaleUpdate(ScaleUpdateDetails details) { - final newScale = _gestureStartScale * details.scale; - final panPositionDelta = details.focalPoint - _prevViewportFocalPosition; - final scalePositionDelta = scaleBoundaries.viewportToStatePosition(controller, details.focalPoint) * (scale / newScale - 1); + double newScale; + if (_doubleTap) { + // quick scale, aka one finger zoom + // magic numbers from `davemorrissey/subsampling-scale-image-view` + final focalPointY = details.focalPoint.dy; + final distance = (focalPointY - _startFocalPoint.dy).abs() * 2 + 20; + _quickScaleLastDistance ??= distance; + final spanDiff = (1 - (distance / _quickScaleLastDistance)).abs() * .5; + _quickScaleMoved |= spanDiff > .03; + final factor = _quickScaleMoved ? (focalPointY > _quickScaleLastY ? (1 + spanDiff) : (1 - spanDiff)) : 1; + _quickScaleLastDistance = distance; + _quickScaleLastY = focalPointY; + newScale = scale * factor; + } else { + newScale = _startScale * details.scale; + } + final scaleFocalPoint = _doubleTap ? _startFocalPoint : details.focalPoint; + + final panPositionDelta = scaleFocalPoint - _lastViewportFocalPosition; + final scalePositionDelta = scaleBoundaries.viewportToStatePosition(controller, scaleFocalPoint) * (scale / newScale - 1); final newPosition = position + panPositionDelta + scalePositionDelta; updateScaleStateFromNewScale(newScale, ChangeSource.gesture); @@ -78,7 +101,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi source: ChangeSource.gesture, ); - _prevViewportFocalPosition = details.focalPoint; + _lastViewportFocalPosition = scaleFocalPoint; } void onScaleEnd(ScaleEndDetails details) { @@ -116,7 +139,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi final magnitude = details.velocity.pixelsPerSecond.distance; // animate velocity only if there is no scale change and a significant magnitude - if (_gestureStartScale / _scale == 1.0 && magnitude >= 400.0) { + if (_startScale / _scale == 1.0 && magnitude >= 400.0) { final direction = details.velocity.pixelsPerSecond / magnitude; animatePosition( _position, diff --git a/lib/widgets/common/magnifier/core/gesture_detector.dart b/lib/widgets/common/magnifier/core/gesture_detector.dart index 933c06b41..b709725ec 100644 --- a/lib/widgets/common/magnifier/core/gesture_detector.dart +++ b/lib/widgets/common/magnifier/core/gesture_detector.dart @@ -1,5 +1,4 @@ -import 'dart:math'; - +import 'package:aves/widgets/common/magnifier/core/scale_gesture_recognizer.dart'; import 'package:aves/widgets/common/magnifier/pan/gesture_detector_scope.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; @@ -21,7 +20,7 @@ class MagnifierGestureDetector extends StatefulWidget { }) : super(key: key); final CornerHitDetector hitDetector; - final GestureScaleStartCallback onScaleStart; + final void Function(ScaleStartDetails details, bool doubleTap) onScaleStart; final GestureScaleUpdateCallback onScaleUpdate; final GestureScaleEndCallback onScaleEnd; @@ -37,7 +36,7 @@ class MagnifierGestureDetector extends StatefulWidget { } class _MagnifierGestureDetectorState extends State { - TapDownDetails doubleTapDetails; + final ValueNotifier doubleTapDetails = ValueNotifier(null); @override Widget build(BuildContext context) { @@ -65,23 +64,23 @@ class _MagnifierGestureDetectorState extends State { debugOwner: this, validateAxis: axis, touchSlopFactor: touchSlopFactor, + doubleTapDetails: doubleTapDetails, ), (instance) { - instance - ..onStart = widget.onScaleStart - ..onUpdate = widget.onScaleUpdate - ..onEnd = widget.onScaleEnd; + instance.onStart = (details) => widget.onScaleStart(details, doubleTapDetails.value != null); + instance.onUpdate = widget.onScaleUpdate; + instance.onEnd = widget.onScaleEnd; }, ); gestures[DoubleTapGestureRecognizer] = GestureRecognizerFactoryWithHandlers( () => DoubleTapGestureRecognizer(debugOwner: this), (instance) { - instance.onDoubleTapCancel = () => doubleTapDetails = null; - instance.onDoubleTapDown = (details) => doubleTapDetails = details; + instance.onDoubleTapCancel = () => doubleTapDetails.value = null; + instance.onDoubleTapDown = (details) => doubleTapDetails.value = details; instance.onDoubleTap = () { - widget.onDoubleTap(doubleTapDetails); - doubleTapDetails = null; + widget.onDoubleTap(doubleTapDetails.value); + doubleTapDetails.value = null; }; }, ); @@ -93,139 +92,3 @@ class _MagnifierGestureDetectorState extends State { ); } } - -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/core/scale_gesture_recognizer.dart b/lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart new file mode 100644 index 000000000..37db8bbc6 --- /dev/null +++ b/lib/widgets/common/magnifier/core/scale_gesture_recognizer.dart @@ -0,0 +1,145 @@ +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/widgets.dart'; + +import '../pan/corner_hit_detector.dart'; + +class MagnifierGestureRecognizer extends ScaleGestureRecognizer { + final CornerHitDetector hitDetector; + final List validateAxis; + final double touchSlopFactor; + final ValueNotifier doubleTapDetails; + + MagnifierGestureRecognizer({ + Object debugOwner, + PointerDeviceKind kind, + this.hitDetector, + this.validateAxis, + this.touchSlopFactor = 2, + this.doubleTapDetails, + }) : super(debugOwner: debugOwner, kind: kind); + + 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); + } + + final doubleTap = doubleTapDetails?.value != null; + if (shouldMove || doubleTap) { + 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); + } + } + } +} From 640bb272dde9966ef14cdc84a57c98181b19d378 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 24 Dec 2020 11:36:06 +0900 Subject: [PATCH 10/13] viewer: improved panning inertia --- lib/widgets/common/magnifier/core/core.dart | 75 ++++++++++++--------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/lib/widgets/common/magnifier/core/core.dart b/lib/widgets/common/magnifier/core/core.dart index 19392588a..735434a42 100644 --- a/lib/widgets/common/magnifier/core/core.dart +++ b/lib/widgets/common/magnifier/core/core.dart @@ -19,6 +19,7 @@ class MagnifierCore extends StatefulWidget { @required this.controller, @required this.scaleStateCycle, @required this.applyScale, + this.panInertia = .2, }) : super(key: key); final Widget child; @@ -30,6 +31,7 @@ class MagnifierCore extends StatefulWidget { final HitTestBehavior gestureDetectorBehavior; final bool applyScale; + final double panInertia; @override State createState() { @@ -41,6 +43,7 @@ class MagnifierCoreState extends State with TickerProviderStateMi Offset _startFocalPoint, _lastViewportFocalPosition; double _startScale, _quickScaleLastY, _quickScaleLastDistance; bool _doubleTap, _quickScaleMoved; + DateTime _lastScaleGestureDate; AnimationController _scaleAnimationController; Animation _scaleAnimation; @@ -105,47 +108,55 @@ class MagnifierCoreState extends State with TickerProviderStateMi } void onScaleEnd(ScaleEndDetails details) { - final _scale = scale; final _position = controller.position; + final _scale = controller.scale; 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); + // animate back to min/max scale if gesture yielded a scale exceeding them + if (_scale > maxScale || _scale < minScale) { + final newScale = _scale.clamp(minScale, maxScale); + final newPosition = clampPosition(position: _position * newScale / _scale, scale: newScale); + animateScale(_scale, newScale); + animatePosition(_position, newPosition); 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; + // The gesture recognizer triggers a new `onScaleStart` every time a pointer/finger is added or removed. + // Following a pinch-to-zoom gesture, a new panning gesture may start if the user does not lift both fingers at the same time, + // so we dismiss such panning gestures when it looks like it followed a scaling gesture. + final isPanning = _scale == _startScale && (DateTime.now().difference(_lastScaleGestureDate)).inMilliseconds > 100; - // animate velocity only if there is no scale change and a significant magnitude - if (_startScale / _scale == 1.0 && magnitude >= 400.0) { - final direction = details.velocity.pixelsPerSecond / magnitude; - animatePosition( - _position, - clampPosition(position: _position + direction * 100.0), - ); + // animate position only when panning without scaling + if (isPanning) { + final pps = details.velocity.pixelsPerSecond; + if (pps != Offset.zero) { + final newPosition = clampPosition(position: _position + pps * widget.panInertia); + final tween = Tween(begin: _position, end: newPosition); + const curve = Curves.easeOutCubic; + _positionAnimation = tween.animate(CurvedAnimation(parent: _positionAnimationController, curve: curve)); + _positionAnimationController + ..duration = _getAnimationDurationForVelocity(curve: curve, tween: tween, targetPixelPerSecond: pps) + ..forward(from: 0.0); + } } + + if (_scale != _startScale) { + _lastScaleGestureDate = DateTime.now(); + } + } + + Duration _getAnimationDurationForVelocity({ + Cubic curve, + Tween tween, + Offset targetPixelPerSecond, + }) { + assert(targetPixelPerSecond != Offset.zero); + // find initial animation velocity over the first 20% of the specified curve + const t = 0.2; + final animationVelocity = (tween.end - tween.begin).distance * curve.transform(t) / t; + final gestureVelocity = targetPixelPerSecond.distance; + return Duration(milliseconds: (animationVelocity / gestureVelocity * 1000).round()); } void onTap(TapUpDetails details) { From 14fc4b291329a0a25d4e5f3c5357cb0386f95848 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 24 Dec 2020 11:43:49 +0900 Subject: [PATCH 11/13] updated changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf5bc06bc..f783cf575 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added +- Viewer: quick scale (aka one finger zoom) +- Viewer: optional checkered background for transparent images + +### Changed +- Viewer: changed panning inertia + +### Fixed +- Viewer: fixed scaling focus when zooming by double-tap or pinch +- Viewer: fixed panning during scaling ## [v1.2.9] - 2020-12-12 ### Added From a1c7851a8083f6c9683b2c9223c77abaf125bca1 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Thu, 24 Dec 2020 12:44:27 +0900 Subject: [PATCH 12/13] packages upgrade, android library upgrade --- android/app/build.gradle | 4 +-- android/build.gradle | 2 +- .../fullscreen/entry_action_delegate.dart | 34 ++++++++----------- pubspec.lock | 23 +++++-------- 4 files changed, 26 insertions(+), 37 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 775910c2c..d84d5d96b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -53,7 +53,7 @@ android { defaultConfig { applicationId "deckers.thibault.aves" - // TODO TLAD try minSdkVersion 23 when kotlin migration is done + // TODO TLAD try minSdkVersion 23 minSdkVersion 24 targetSdkVersion 30 // same as compileSdkVersion versionCode flutterVersionCode.toInteger() @@ -99,7 +99,7 @@ repositories { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9' implementation 'androidx.core:core-ktx:1.5.0-alpha05' // v1.5.0-alpha02+ for ShortcutManagerCompat.setDynamicShortcuts - implementation 'androidx.exifinterface:exifinterface:1.3.1' + implementation 'androidx.exifinterface:exifinterface:1.3.2' implementation 'com.commonsware.cwac:document:0.4.1' implementation 'com.drewnoakes:metadata-extractor:2.15.0' // as of v0.9.8.7, `Android-TiffBitmapFactory` master branch is set up to release and distribute via Bintray diff --git a/android/build.gradle b/android/build.gradle index 11444be0a..65df263a7 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,6 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = '1.4.20' + ext.kotlin_version = '1.4.21' repositories { google() jcenter() diff --git a/lib/widgets/fullscreen/entry_action_delegate.dart b/lib/widgets/fullscreen/entry_action_delegate.dart index 0ee1078ed..b6102bf1d 100644 --- a/lib/widgets/fullscreen/entry_action_delegate.dart +++ b/lib/widgets/fullscreen/entry_action_delegate.dart @@ -15,8 +15,6 @@ import 'package:aves/widgets/fullscreen/source_viewer_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:pdf/pdf.dart'; import 'package:pdf/widgets.dart' as pdf; import 'package:pedantic/pedantic.dart'; import 'package:printing/printing.dart'; @@ -100,34 +98,32 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin { final documentName = entry.bestTitle ?? 'Aves'; final doc = pdf.Document(title: documentName); - PdfImage pdfImage; + pdf.Widget pdfChild; if (entry.isSvg) { final bytes = await ImageFileService.getImage(uri, mimeType, entry.rotationDegrees, entry.isFlipped); if (bytes != null && bytes.isNotEmpty) { - final svgRoot = await svg.fromSvgBytes(bytes, uri); - final viewBox = svgRoot.viewport.viewBox; - // 1000 is arbitrary, but large enough to look ok in the print preview - final targetSize = viewBox * 1000 / viewBox.longestSide; - final picture = svgRoot.toPicture(size: targetSize); - final uiImage = await picture.toImage(targetSize.width.ceil(), targetSize.height.ceil()); - pdfImage = await pdfImageFromImage( - pdf: doc.document, - image: uiImage, - ); + pdfChild = pdf.SvgImage(svg: utf8.decode(bytes)); } } else { - pdfImage = await pdfImageFromImageProvider( - pdf: doc.document, - image: UriImage( + pdfChild = pdf.Image.provider(await flutterImageProvider( + UriImage( uri: uri, mimeType: mimeType, rotationDegrees: rotationDegrees, isFlipped: isFlipped, ), - ); + )); } - if (pdfImage != null) { - doc.addPage(pdf.Page(build: (context) => pdf.Center(child: pdf.Image(pdfImage)))); // Page + if (pdfChild != null) { + doc.addPage(pdf.Page( + orientation: entry.isPortrait ? pdf.PageOrientation.portrait : pdf.PageOrientation.landscape, + build: (context) => pdf.FullPage( + ignoreMargins: true, + child: pdf.Center( + child: pdfChild, + ), + ), + )); // Page unawaited(Printing.layoutPdf( onLayout: (format) => doc.save(), name: documentName, diff --git a/pubspec.lock b/pubspec.lock index ce31a005d..b3090e230 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -63,7 +63,7 @@ packages: name: cached_network_image url: "https://pub.dartlang.org" source: hosted - version: "2.4.1" + version: "2.5.0" characters: dependency: transitive description: @@ -282,7 +282,7 @@ packages: name: flutter_cache_manager url: "https://pub.dartlang.org" source: hosted - version: "2.0.0" + version: "2.1.0" flutter_cube: dependency: transitive description: @@ -605,7 +605,7 @@ packages: name: panorama url: "https://pub.dartlang.org" source: hosted - version: "0.1.0" + version: "0.1.2" path: dependency: transitive description: @@ -668,7 +668,7 @@ packages: name: pdf url: "https://pub.dartlang.org" source: hosted - version: "1.12.0" + version: "1.13.0" pedantic: dependency: "direct main" description: @@ -682,7 +682,7 @@ packages: name: percent_indicator url: "https://pub.dartlang.org" source: hosted - version: "2.1.8" + version: "2.1.9" permission_handler: dependency: "direct main" description: @@ -738,7 +738,7 @@ packages: name: printing url: "https://pub.dartlang.org" source: hosted - version: "3.7.1" + version: "3.7.2" process: dependency: transitive description: @@ -787,7 +787,7 @@ packages: name: rxdart url: "https://pub.dartlang.org" source: hosted - version: "0.24.1" + version: "0.25.0" screen: dependency: "direct main" description: @@ -857,7 +857,7 @@ packages: name: shelf_static url: "https://pub.dartlang.org" source: hosted - version: "0.2.8" + version: "0.2.9+1" shelf_web_socket: dependency: transitive description: @@ -1052,13 +1052,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.0.1+3" - utf: - dependency: transitive - description: - name: utf - url: "https://pub.dartlang.org" - source: hosted - version: "0.9.0+5" uuid: dependency: transitive description: From c93393a365cbe1d2894e1c25361ef6e631651423 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 26 Dec 2020 20:48:00 +0900 Subject: [PATCH 13/13] version bump --- CHANGELOG.md | 2 ++ pubspec.yaml | 2 +- whatsnew/whatsnew-en-US | 8 +++----- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f783cf575..1ad950ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] + +## [v1.3.0] - 2020-12-26 ### Added - Viewer: quick scale (aka one finger zoom) - Viewer: optional checkered background for transparent images diff --git a/pubspec.yaml b/pubspec.yaml index 376a15840..7a6aeed7f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Aves is a gallery and metadata explorer app, built for Android. publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.2.9+35 +version: 1.3.0+36 # brendan-duncan/image (as of v2.1.19): # - does not support TIFF with JPEG compression (issue #184) diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index e7bbf696e..b6ade0151 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,7 +1,5 @@ Thanks for using Aves! -v1.2.9: -- identify 360 photos/videos, GeoTIFF -- open panoramas (360 photos) -- open GImage/GAudio/GDepth media and thumbnails embedded in XMP -- improved large TIFF handling +v1.3.0: +- added quick scale (aka one finger zoom) gesture to the viewer +- fixed zoom focus with double-tap or pinch-to-zoom gestures Full changelog available on Github \ No newline at end of file