mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(mobile): sqlite asset viewer (#19552)
* add full image provider and refactor thumb providers * photo_view updates * wip: asset-viewer * fix controller dispose on page change * wip: bottom sheet * fix interactions * more bottomsheet changes * generate schema * PR feedback * refactor asset viewer * never rotate and fix background on page change * use photoview as the loading builder * precache after delay * claude: optimizing rebuild of image provider * claude: optimizing image decoding and caching * use proper cache for new full size image providers * chore: load local HEIC fullsize for iOS * make controller callbacks nullable * remove imageprovider cache * do not handle drag gestures when zoomed * use loadOriginal setting for HEIC / larger images * preload assets outside timer * never use same controllers in photo-view gallery * fix: cannot scroll down once swipe with bottom sheet --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
ec603a008c
commit
7855974a29
47 changed files with 1867 additions and 490 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart';
|
||||
|
|
@ -16,6 +15,11 @@ export 'src/photo_view_computed_scale.dart';
|
|||
export 'src/photo_view_scale_state.dart';
|
||||
export 'src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
typedef PhotoViewControllerCallback = PhotoViewControllerBase Function();
|
||||
typedef PhotoViewControllerCallbackBuilder = void Function(
|
||||
PhotoViewControllerCallback photoViewMethod,
|
||||
);
|
||||
|
||||
/// A [StatefulWidget] that contains all the photo view rendering elements.
|
||||
///
|
||||
/// Sample code to use within an image:
|
||||
|
|
@ -239,8 +243,11 @@ class PhotoView extends StatefulWidget {
|
|||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.heroAttributes,
|
||||
this.onPageBuild,
|
||||
this.controllerCallbackBuilder,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.semanticLabel,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.maxScale,
|
||||
|
|
@ -260,6 +267,7 @@ class PhotoView extends StatefulWidget {
|
|||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.disableScaleGestures,
|
||||
this.errorBuilder,
|
||||
this.enablePanAlways,
|
||||
}) : child = null,
|
||||
|
|
@ -278,6 +286,8 @@ class PhotoView extends StatefulWidget {
|
|||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.heroAttributes,
|
||||
this.onPageBuild,
|
||||
this.controllerCallbackBuilder,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.controller,
|
||||
|
|
@ -298,9 +308,11 @@ class PhotoView extends StatefulWidget {
|
|||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableScaleGestures,
|
||||
this.disableGestures,
|
||||
this.enablePanAlways,
|
||||
}) : errorBuilder = null,
|
||||
}) : semanticLabel = null,
|
||||
errorBuilder = null,
|
||||
imageProvider = null,
|
||||
gaplessPlayback = false,
|
||||
loadingBuilder = null,
|
||||
|
|
@ -325,6 +337,11 @@ class PhotoView extends StatefulWidget {
|
|||
/// `true` -> keeps the state
|
||||
final bool wantKeepAlive;
|
||||
|
||||
/// A Semantic description of the image.
|
||||
///
|
||||
/// Used to provide a description of the image to TalkBack on Android, and VoiceOver on iOS.
|
||||
final String? semanticLabel;
|
||||
|
||||
/// This is used to continue showing the old image (`true`), or briefly show
|
||||
/// nothing (`false`), when the `imageProvider` changes. By default it's set
|
||||
/// to `false`.
|
||||
|
|
@ -338,6 +355,12 @@ class PhotoView extends StatefulWidget {
|
|||
/// by default it is `MediaQuery.of(context).size`.
|
||||
final Size? customSize;
|
||||
|
||||
// Called when a new PhotoView widget is built
|
||||
final ValueChanged<PhotoViewControllerBase>? onPageBuild;
|
||||
|
||||
// Called from the parent during page change to get the new controller
|
||||
final PhotoViewControllerCallbackBuilder? controllerCallbackBuilder;
|
||||
|
||||
/// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in.
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
|
||||
|
|
@ -419,6 +442,9 @@ class PhotoView extends StatefulWidget {
|
|||
// Useful when custom gesture detector is used in child widget.
|
||||
final bool? disableGestures;
|
||||
|
||||
/// Mirror to [PhotoView.disableGestures]
|
||||
final bool? disableScaleGestures;
|
||||
|
||||
/// Enable pan the widget even if it's smaller than the hole parent widget.
|
||||
/// Useful when you want to drag a widget without restrictions.
|
||||
final bool? enablePanAlways;
|
||||
|
|
@ -452,6 +478,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||
if (widget.controller == null) {
|
||||
_controlledController = true;
|
||||
_controller = PhotoViewController();
|
||||
widget.onPageBuild?.call(_controller);
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller!;
|
||||
|
|
@ -466,6 +493,8 @@ class _PhotoViewState extends State<PhotoView>
|
|||
}
|
||||
|
||||
_scaleStateController.outputScaleStateStream.listen(scaleStateListener);
|
||||
// Pass a ref to the method back to the gallery so it can fetch the controller on page changes
|
||||
widget.controllerCallbackBuilder?.call(_controllerGetter);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -474,6 +503,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||
if (!_controlledController) {
|
||||
_controlledController = true;
|
||||
_controller = PhotoViewController();
|
||||
widget.onPageBuild?.call(_controller);
|
||||
}
|
||||
} else {
|
||||
_controlledController = false;
|
||||
|
|
@ -509,6 +539,8 @@ class _PhotoViewState extends State<PhotoView>
|
|||
}
|
||||
}
|
||||
|
||||
PhotoViewControllerBase _controllerGetter() => _controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
|
|
@ -547,6 +579,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||
tightMode: widget.tightMode,
|
||||
filterQuality: widget.filterQuality,
|
||||
disableGestures: widget.disableGestures,
|
||||
disableScaleGestures: widget.disableScaleGestures,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
child: widget.child,
|
||||
)
|
||||
|
|
@ -554,6 +587,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||
imageProvider: widget.imageProvider!,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
||||
|
|
@ -577,6 +611,7 @@ class _PhotoViewState extends State<PhotoView>
|
|||
tightMode: widget.tightMode,
|
||||
filterQuality: widget.filterQuality,
|
||||
disableGestures: widget.disableGestures,
|
||||
disableScaleGestures: widget.disableScaleGestures,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
index: widget.index,
|
||||
|
|
@ -626,6 +661,7 @@ typedef PhotoViewImageDragStartCallback = Function(
|
|||
BuildContext context,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
PhotoViewScaleStateController scaleStateController,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user drags
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ import 'package:immich_mobile/widgets/photo_view/photo_view.dart'
|
|||
show
|
||||
LoadingBuilder,
|
||||
PhotoView,
|
||||
PhotoViewControllerCallback,
|
||||
PhotoViewImageDragEndCallback,
|
||||
PhotoViewImageDragStartCallback,
|
||||
PhotoViewImageDragUpdateCallback,
|
||||
PhotoViewImageLongPressStartCallback,
|
||||
PhotoViewImageScaleEndCallback,
|
||||
PhotoViewImageTapDownCallback,
|
||||
PhotoViewImageTapUpCallback,
|
||||
PhotoViewImageDragStartCallback,
|
||||
PhotoViewImageDragEndCallback,
|
||||
PhotoViewImageDragUpdateCallback,
|
||||
PhotoViewImageScaleEndCallback,
|
||||
PhotoViewImageLongPressStartCallback,
|
||||
ScaleStateCycle;
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
|
|
@ -19,7 +20,10 @@ import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart
|
|||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
/// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery]
|
||||
typedef PhotoViewGalleryPageChangedCallback = void Function(int index);
|
||||
typedef PhotoViewGalleryPageChangedCallback = void Function(
|
||||
int index,
|
||||
PhotoViewControllerBase? controller,
|
||||
);
|
||||
|
||||
/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
|
||||
typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(
|
||||
|
|
@ -114,12 +118,14 @@ class PhotoViewGallery extends StatefulWidget {
|
|||
this.reverse = false,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.onPageBuild,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.scrollPhysics,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.customSize,
|
||||
this.allowImplicitScrolling = false,
|
||||
this.enablePanAlways = false,
|
||||
}) : itemCount = null,
|
||||
builder = null;
|
||||
|
||||
|
|
@ -137,12 +143,14 @@ class PhotoViewGallery extends StatefulWidget {
|
|||
this.reverse = false,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.onPageBuild,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.scrollPhysics,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.customSize,
|
||||
this.allowImplicitScrolling = false,
|
||||
this.enablePanAlways = false,
|
||||
}) : pageOptions = null,
|
||||
assert(itemCount != null),
|
||||
assert(builder != null);
|
||||
|
|
@ -168,6 +176,9 @@ class PhotoViewGallery extends StatefulWidget {
|
|||
/// Mirror to [PhotoView.wantKeepAlive]
|
||||
final bool wantKeepAlive;
|
||||
|
||||
/// Mirror to [PhotoView.enablePanAlways]
|
||||
final bool enablePanAlways;
|
||||
|
||||
/// Mirror to [PhotoView.gaplessPlayback]
|
||||
final bool gaplessPlayback;
|
||||
|
||||
|
|
@ -180,6 +191,9 @@ class PhotoViewGallery extends StatefulWidget {
|
|||
/// An callback to be called on a page change
|
||||
final PhotoViewGalleryPageChangedCallback? onPageChanged;
|
||||
|
||||
/// Mirror to [PhotoView.onPageBuild]
|
||||
final ValueChanged<PhotoViewControllerBase>? onPageBuild;
|
||||
|
||||
/// Mirror to [PhotoView.scaleStateChangedCallback]
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
|
||||
|
|
@ -206,6 +220,7 @@ class PhotoViewGallery extends StatefulWidget {
|
|||
class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
||||
late final PageController _controller =
|
||||
widget.pageController ?? PageController();
|
||||
PhotoViewControllerCallback? _getController;
|
||||
|
||||
void scaleStateChangedCallback(PhotoViewScaleState scaleState) {
|
||||
if (widget.scaleStateChangedCallback != null) {
|
||||
|
|
@ -224,6 +239,14 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||
return widget.pageOptions!.length;
|
||||
}
|
||||
|
||||
void _getControllerCallbackBuilder(PhotoViewControllerCallback method) {
|
||||
_getController = method;
|
||||
}
|
||||
|
||||
void _onPageChange(int page) {
|
||||
widget.onPageChanged?.call(page, _getController?.call());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Enable corner hit test
|
||||
|
|
@ -232,7 +255,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||
child: PageView.builder(
|
||||
reverse: widget.reverse,
|
||||
controller: _controller,
|
||||
onPageChanged: widget.onPageChanged,
|
||||
onPageChanged: _onPageChange,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: _buildItem,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
|
|
@ -255,6 +278,8 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||
controller: pageOption.controller,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
onPageBuild: widget.onPageBuild,
|
||||
controllerCallbackBuilder: _getControllerCallbackBuilder,
|
||||
scaleStateChangedCallback: scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
initialScale: pageOption.initialScale,
|
||||
|
|
@ -273,7 +298,9 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||
filterQuality: pageOption.filterQuality,
|
||||
basePosition: pageOption.basePosition,
|
||||
disableGestures: pageOption.disableGestures,
|
||||
disableScaleGestures: pageOption.disableScaleGestures,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
child: pageOption.child,
|
||||
)
|
||||
: PhotoView(
|
||||
|
|
@ -282,8 +309,11 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||
imageProvider: pageOption.imageProvider,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
semanticLabel: pageOption.semanticLabel,
|
||||
wantKeepAlive: widget.wantKeepAlive,
|
||||
controller: pageOption.controller,
|
||||
onPageBuild: widget.onPageBuild,
|
||||
controllerCallbackBuilder: _getControllerCallbackBuilder,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
|
|
@ -305,6 +335,8 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||
filterQuality: pageOption.filterQuality,
|
||||
basePosition: pageOption.basePosition,
|
||||
disableGestures: pageOption.disableGestures,
|
||||
disableScaleGestures: pageOption.disableScaleGestures,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
errorBuilder: pageOption.errorBuilder,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
);
|
||||
|
|
@ -334,6 +366,7 @@ class PhotoViewGalleryPageOptions {
|
|||
Key? key,
|
||||
required this.imageProvider,
|
||||
this.heroAttributes,
|
||||
this.semanticLabel,
|
||||
this.minScale,
|
||||
this.maxScale,
|
||||
this.initialScale,
|
||||
|
|
@ -351,6 +384,7 @@ class PhotoViewGalleryPageOptions {
|
|||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableScaleGestures,
|
||||
this.disableGestures,
|
||||
this.errorBuilder,
|
||||
}) : child = null,
|
||||
|
|
@ -360,6 +394,7 @@ class PhotoViewGalleryPageOptions {
|
|||
const PhotoViewGalleryPageOptions.customChild({
|
||||
required this.child,
|
||||
this.childSize,
|
||||
this.semanticLabel,
|
||||
this.heroAttributes,
|
||||
this.minScale,
|
||||
this.maxScale,
|
||||
|
|
@ -378,6 +413,7 @@ class PhotoViewGalleryPageOptions {
|
|||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableScaleGestures,
|
||||
this.disableGestures,
|
||||
}) : errorBuilder = null,
|
||||
imageProvider = null;
|
||||
|
|
@ -388,6 +424,9 @@ class PhotoViewGalleryPageOptions {
|
|||
/// Mirror to [PhotoView.heroAttributes]
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
|
||||
/// Mirror to [PhotoView.semanticLabel]
|
||||
final String? semanticLabel;
|
||||
|
||||
/// Mirror to [PhotoView.minScale]
|
||||
final dynamic minScale;
|
||||
|
||||
|
|
@ -445,6 +484,9 @@ class PhotoViewGalleryPageOptions {
|
|||
/// Mirror to [PhotoView.disableGestures]
|
||||
final bool? disableGestures;
|
||||
|
||||
/// Mirror to [PhotoView.disableGestures]
|
||||
final bool? disableScaleGestures;
|
||||
|
||||
/// Quality levels for image filters.
|
||||
final FilterQuality? filterQuality;
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
|
|||
/// Closes streams and removes eventual listeners.
|
||||
void dispose();
|
||||
|
||||
void positionAnimationBuilder(void Function(Offset)? value);
|
||||
void scaleAnimationBuilder(void Function(double)? value);
|
||||
void rotationAnimationBuilder(void Function(double)? value);
|
||||
|
||||
/// Animates multiple fields of the state
|
||||
void animateMultiple({Offset? position, double? scale, double? rotation});
|
||||
|
||||
/// Add a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
|
|
@ -147,12 +154,31 @@ class PhotoViewController
|
|||
|
||||
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
||||
|
||||
late void Function(Offset)? _animatePosition;
|
||||
late void Function(double)? _animateScale;
|
||||
late void Function(double)? _animateRotation;
|
||||
|
||||
@override
|
||||
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
|
||||
|
||||
@override
|
||||
late PhotoViewControllerValue prevValue;
|
||||
|
||||
@override
|
||||
void positionAnimationBuilder(void Function(Offset)? value) {
|
||||
_animatePosition = value;
|
||||
}
|
||||
|
||||
@override
|
||||
void scaleAnimationBuilder(void Function(double)? value) {
|
||||
_animateScale = value;
|
||||
}
|
||||
|
||||
@override
|
||||
void rotationAnimationBuilder(void Function(double)? value) {
|
||||
_animateRotation = value;
|
||||
}
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
value = initial;
|
||||
|
|
@ -172,6 +198,21 @@ class PhotoViewController
|
|||
_valueNotifier.removeIgnorableListener(callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void animateMultiple({Offset? position, double? scale, double? rotation}) {
|
||||
if (position != null && _animatePosition != null) {
|
||||
_animatePosition!(position);
|
||||
}
|
||||
|
||||
if (scale != null && _animateScale != null) {
|
||||
_animateScale!(scale);
|
||||
}
|
||||
|
||||
if (rotation != null && _animateRotation != null) {
|
||||
_animateRotation!(rotation);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_outputCtrl.close();
|
||||
|
|
|
|||
|
|
@ -111,6 +111,16 @@ mixin PhotoViewControllerDelegate on State<PhotoViewCore> {
|
|||
);
|
||||
}
|
||||
|
||||
PhotoViewScaleState getScaleStateFromNewScale(double newScale) {
|
||||
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
|
||||
if (scale != scaleBoundaries.initialScale) {
|
||||
newScaleState = (newScale > scaleBoundaries.initialScale)
|
||||
? PhotoViewScaleState.zoomedIn
|
||||
: PhotoViewScaleState.zoomedOut;
|
||||
}
|
||||
return newScaleState;
|
||||
}
|
||||
|
||||
void updateScaleStateFromNewScale(double newScale) {
|
||||
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
|
||||
if (scale != scaleBoundaries.initialScale) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ class PhotoViewScaleStateController {
|
|||
StreamController<PhotoViewScaleState>.broadcast()
|
||||
..sink.add(PhotoViewScaleState.initial);
|
||||
|
||||
bool _hasZoomedOutManually = false;
|
||||
|
||||
/// The output for state/value updates
|
||||
Stream<PhotoViewScaleState> get outputScaleStateStream =>
|
||||
_outputScaleStateCtrl.stream;
|
||||
|
|
@ -42,10 +44,20 @@ class PhotoViewScaleStateController {
|
|||
return;
|
||||
}
|
||||
|
||||
if (newValue == PhotoViewScaleState.zoomedOut) {
|
||||
_hasZoomedOutManually = true;
|
||||
}
|
||||
|
||||
if (newValue == PhotoViewScaleState.initial) {
|
||||
_hasZoomedOutManually = false;
|
||||
}
|
||||
|
||||
prevScaleState = _scaleStateNotifier.value;
|
||||
_scaleStateNotifier.value = newValue;
|
||||
}
|
||||
|
||||
bool get hasZoomedOutManually => _hasZoomedOutManually;
|
||||
|
||||
/// Checks if its actual value is different than previousValue
|
||||
bool get hasChanged => prevScaleState != scaleState;
|
||||
|
||||
|
|
@ -71,6 +83,15 @@ class PhotoViewScaleStateController {
|
|||
if (_scaleStateNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (newValue == PhotoViewScaleState.zoomedOut) {
|
||||
_hasZoomedOutManually = true;
|
||||
}
|
||||
|
||||
if (newValue == PhotoViewScaleState.initial) {
|
||||
_hasZoomedOutManually = false;
|
||||
}
|
||||
|
||||
prevScaleState = _scaleStateNotifier.value;
|
||||
_scaleStateNotifier.updateIgnoring(newValue);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||
super.key,
|
||||
required this.imageProvider,
|
||||
required this.backgroundDecoration,
|
||||
required this.semanticLabel,
|
||||
required this.gaplessPlayback,
|
||||
required this.heroAttributes,
|
||||
required this.enableRotation,
|
||||
|
|
@ -48,6 +49,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.disableScaleGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : customChild = null;
|
||||
|
||||
|
|
@ -73,12 +75,15 @@ class PhotoViewCore extends StatefulWidget {
|
|||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.disableScaleGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : imageProvider = null,
|
||||
}) : semanticLabel = null,
|
||||
imageProvider = null,
|
||||
gaplessPlayback = false;
|
||||
|
||||
final Decoration? backgroundDecoration;
|
||||
final ImageProvider? imageProvider;
|
||||
final String? semanticLabel;
|
||||
final bool? gaplessPlayback;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final bool enableRotation;
|
||||
|
|
@ -103,6 +108,7 @@ class PhotoViewCore extends StatefulWidget {
|
|||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool tightMode;
|
||||
final bool disableGestures;
|
||||
final bool disableScaleGestures;
|
||||
final bool enablePanAlways;
|
||||
|
||||
final FilterQuality filterQuality;
|
||||
|
|
@ -120,6 +126,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
TickerProviderStateMixin,
|
||||
PhotoViewControllerDelegate,
|
||||
HitCornersDetector {
|
||||
Offset? _normalizedPosition;
|
||||
double? _scaleBefore;
|
||||
double? _rotationBefore;
|
||||
|
||||
|
|
@ -152,32 +159,33 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
void onScaleStart(ScaleStartDetails details) {
|
||||
_rotationBefore = controller.rotation;
|
||||
_scaleBefore = scale;
|
||||
_normalizedPosition = details.focalPoint - controller.position;
|
||||
_scaleAnimationController.stop();
|
||||
_positionAnimationController.stop();
|
||||
_rotationAnimationController.stop();
|
||||
}
|
||||
|
||||
bool _shouldAllowPanRotate() => switch (scaleStateController.scaleState) {
|
||||
PhotoViewScaleState.zoomedIn =>
|
||||
scaleStateController.hasZoomedOutManually,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
void onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final centeredFocalPoint = Offset(
|
||||
details.focalPoint.dx - scaleBoundaries.outerSize.width / 2,
|
||||
details.focalPoint.dy - scaleBoundaries.outerSize.height / 2,
|
||||
);
|
||||
final double newScale = _scaleBefore! * details.scale;
|
||||
final double scaleDelta = newScale / scale;
|
||||
final Offset newPosition =
|
||||
(controller.position + details.focalPointDelta) * scaleDelta -
|
||||
centeredFocalPoint * (scaleDelta - 1);
|
||||
Offset delta = details.focalPoint - _normalizedPosition!;
|
||||
|
||||
updateScaleStateFromNewScale(newScale);
|
||||
|
||||
final panEnabled = widget.enablePanAlways && _shouldAllowPanRotate();
|
||||
final rotationEnabled = widget.enableRotation && _shouldAllowPanRotate();
|
||||
|
||||
updateMultiple(
|
||||
scale: newScale,
|
||||
position: widget.enablePanAlways
|
||||
? newPosition
|
||||
: clampPosition(position: newPosition),
|
||||
rotation:
|
||||
widget.enableRotation ? _rotationBefore! + details.rotation : null,
|
||||
rotationFocusPoint: widget.enableRotation ? details.focalPoint : null,
|
||||
position:
|
||||
panEnabled ? delta : clampPosition(position: delta * details.scale),
|
||||
rotation: rotationEnabled ? _rotationBefore! + details.rotation : null,
|
||||
rotationFocusPoint: rotationEnabled ? details.focalPoint : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +197,16 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
|
||||
widget.onScaleEnd?.call(context, details, controller.value);
|
||||
|
||||
final scaleState = getScaleStateFromNewScale(scale);
|
||||
if (scaleState == PhotoViewScaleState.zoomedOut) {
|
||||
scaleStateController.scaleState = PhotoViewScaleState.originalSize;
|
||||
} else if (scaleState == PhotoViewScaleState.zoomedIn) {
|
||||
animateRotation(controller.rotation, 0);
|
||||
if (_shouldAllowPanRotate()) {
|
||||
animatePosition(controller.position, Offset.zero);
|
||||
}
|
||||
}
|
||||
|
||||
//animate back to maxScale if gesture exceeded the maxScale specified
|
||||
if (s > maxScale) {
|
||||
final double scaleComebackRatio = maxScale / s;
|
||||
|
|
@ -232,6 +250,9 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
}
|
||||
|
||||
void animateScale(double from, double to) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: from,
|
||||
end: to,
|
||||
|
|
@ -242,6 +263,9 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
}
|
||||
|
||||
void animatePosition(Offset from, Offset to) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_positionAnimation = Tween<Offset>(begin: from, end: to)
|
||||
.animate(_positionAnimationController);
|
||||
_positionAnimationController
|
||||
|
|
@ -250,6 +274,9 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
}
|
||||
|
||||
void animateRotation(double from, double to) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_rotationAnimation = Tween<double>(begin: from, end: to)
|
||||
.animate(_rotationAnimationController);
|
||||
_rotationAnimationController
|
||||
|
|
@ -271,11 +298,28 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
}
|
||||
}
|
||||
|
||||
void _animateControllerPosition(Offset position) {
|
||||
animatePosition(controller.position, position);
|
||||
}
|
||||
|
||||
void _animateControllerScale(double scale) {
|
||||
if (controller.scale != null) {
|
||||
animateScale(controller.scale!, scale);
|
||||
}
|
||||
}
|
||||
|
||||
void _animateControllerRotation(double rotation) {
|
||||
animateRotation(controller.rotation, rotation);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initDelegate();
|
||||
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
|
||||
controller.positionAnimationBuilder(_animateControllerPosition);
|
||||
controller.scaleAnimationBuilder(_animateControllerScale);
|
||||
controller.rotationAnimationBuilder(_animateControllerRotation);
|
||||
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
|
||||
|
|
@ -341,7 +385,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
basePosition,
|
||||
useImageScale,
|
||||
),
|
||||
child: _buildHero(),
|
||||
child: _buildHero(_buildChild()),
|
||||
);
|
||||
|
||||
final child = Container(
|
||||
|
|
@ -363,18 +407,29 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
}
|
||||
|
||||
return PhotoViewGestureDetector(
|
||||
onDoubleTap: nextScaleState,
|
||||
onScaleStart: onScaleStart,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
disableScaleGestures: widget.disableScaleGestures,
|
||||
onDoubleTap: widget.disableScaleGestures ? null : onDoubleTap,
|
||||
onScaleStart: widget.disableScaleGestures ? null : onScaleStart,
|
||||
onScaleUpdate: widget.disableScaleGestures ? null : onScaleUpdate,
|
||||
onScaleEnd: widget.disableScaleGestures ? null : onScaleEnd,
|
||||
onDragStart: widget.onDragStart != null
|
||||
? (details) => widget.onDragStart!(context, details, value)
|
||||
? (details) => widget.onDragStart!(
|
||||
context,
|
||||
details,
|
||||
widget.controller.value,
|
||||
widget.scaleStateController,
|
||||
)
|
||||
: null,
|
||||
onDragEnd: widget.onDragEnd != null
|
||||
? (details) => widget.onDragEnd!(context, details, value)
|
||||
? (details) =>
|
||||
widget.onDragEnd!(context, details, widget.controller.value)
|
||||
: null,
|
||||
onDragUpdate: widget.onDragUpdate != null
|
||||
? (details) => widget.onDragUpdate!(context, details, value)
|
||||
? (details) => widget.onDragUpdate!(
|
||||
context,
|
||||
details,
|
||||
widget.controller.value,
|
||||
)
|
||||
: null,
|
||||
hitDetector: this,
|
||||
onTapUp: widget.onTapUp != null
|
||||
|
|
@ -395,7 +450,7 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildHero() {
|
||||
Widget _buildHero(Widget child) {
|
||||
return heroAttributes != null
|
||||
? Hero(
|
||||
tag: heroAttributes!.tag,
|
||||
|
|
@ -403,16 +458,20 @@ class PhotoViewCoreState extends State<PhotoViewCore>
|
|||
flightShuttleBuilder: heroAttributes!.flightShuttleBuilder,
|
||||
placeholderBuilder: heroAttributes!.placeholderBuilder,
|
||||
transitionOnUserGestures: heroAttributes!.transitionOnUserGestures,
|
||||
child: _buildChild(),
|
||||
child: child,
|
||||
)
|
||||
: _buildChild();
|
||||
: child;
|
||||
}
|
||||
|
||||
Widget _buildChild() {
|
||||
return widget.hasCustomChild
|
||||
? widget.customChild!
|
||||
: Image(
|
||||
key: widget.heroAttributes?.tag != null
|
||||
? ObjectKey(widget.heroAttributes!.tag)
|
||||
: null,
|
||||
image: widget.imageProvider!,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
|
|
@ -442,6 +501,7 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
|
|||
|
||||
final double offsetX = halfWidth * (basePosition.x + 1);
|
||||
final double offsetY = halfHeight * (basePosition.y + 1);
|
||||
|
||||
return Offset(offsetX, offsetY);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.behavior,
|
||||
this.disableScaleGestures = false,
|
||||
});
|
||||
|
||||
final GestureDoubleTapCallback? onDoubleTap;
|
||||
|
|
@ -43,6 +44,8 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||
|
||||
final HitTestBehavior? behavior;
|
||||
|
||||
final bool disableScaleGestures;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = PhotoViewGestureDetectorScope.of(context);
|
||||
|
|
@ -96,9 +99,11 @@ class PhotoViewGestureDetector extends StatelessWidget {
|
|||
),
|
||||
(PhotoViewGestureRecognizer instance) {
|
||||
instance
|
||||
..dragStartBehavior = DragStartBehavior.start
|
||||
..onStart = onScaleStart
|
||||
..onUpdate = onScaleUpdate
|
||||
..onEnd = onScaleEnd;
|
||||
..onEnd = onScaleEnd
|
||||
..disableScaleGestures = disableScaleGestures;
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -124,10 +129,12 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
|
|||
this.validateAxis,
|
||||
this.touchSlopFactor = 1,
|
||||
PointerDeviceKind? kind,
|
||||
this.disableScaleGestures = false,
|
||||
}) : super(supportedDevices: null);
|
||||
final HitCornersDetector? hitDetector;
|
||||
final Axis? validateAxis;
|
||||
final double touchSlopFactor;
|
||||
bool disableScaleGestures;
|
||||
|
||||
Map<int, Offset> _pointerLocations = <int, Offset>{};
|
||||
|
||||
|
|
@ -155,7 +162,7 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
|
|||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (validateAxis != null) {
|
||||
if (validateAxis != null && !disableScaleGestures) {
|
||||
bool didChangeConfiguration = false;
|
||||
if (event is PointerMoveEvent) {
|
||||
if (!event.synthesized) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ class ImageWrapper extends StatefulWidget {
|
|||
required this.imageProvider,
|
||||
required this.loadingBuilder,
|
||||
required this.backgroundDecoration,
|
||||
required this.semanticLabel,
|
||||
required this.gaplessPlayback,
|
||||
required this.heroAttributes,
|
||||
required this.scaleStateChangedCallback,
|
||||
|
|
@ -34,6 +35,7 @@ class ImageWrapper extends StatefulWidget {
|
|||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
this.disableScaleGestures,
|
||||
required this.errorBuilder,
|
||||
required this.enablePanAlways,
|
||||
required this.index,
|
||||
|
|
@ -43,6 +45,7 @@ class ImageWrapper extends StatefulWidget {
|
|||
final LoadingBuilder? loadingBuilder;
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
final BoxDecoration backgroundDecoration;
|
||||
final String? semanticLabel;
|
||||
final bool gaplessPlayback;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
|
|
@ -66,6 +69,7 @@ class ImageWrapper extends StatefulWidget {
|
|||
final bool? tightMode;
|
||||
final FilterQuality? filterQuality;
|
||||
final bool? disableGestures;
|
||||
final bool? disableScaleGestures;
|
||||
final bool? enablePanAlways;
|
||||
final int index;
|
||||
|
||||
|
|
@ -193,6 +197,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||
return PhotoViewCore(
|
||||
imageProvider: widget.imageProvider,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
semanticLabel: widget.semanticLabel,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
enableRotation: widget.enableRotation,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
|
|
@ -212,6 +217,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
|
|||
tightMode: widget.tightMode ?? false,
|
||||
filterQuality: widget.filterQuality ?? FilterQuality.none,
|
||||
disableGestures: widget.disableGestures ?? false,
|
||||
disableScaleGestures: widget.disableScaleGestures ?? false,
|
||||
enablePanAlways: widget.enablePanAlways ?? false,
|
||||
);
|
||||
}
|
||||
|
|
@ -266,6 +272,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
this.disableScaleGestures,
|
||||
required this.enablePanAlways,
|
||||
});
|
||||
|
||||
|
|
@ -296,6 +303,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool? tightMode;
|
||||
final FilterQuality? filterQuality;
|
||||
final bool? disableScaleGestures;
|
||||
final bool? disableGestures;
|
||||
final bool? enablePanAlways;
|
||||
|
||||
|
|
@ -330,6 +338,7 @@ class CustomChildWrapper extends StatelessWidget {
|
|||
tightMode: tightMode ?? false,
|
||||
filterQuality: filterQuality ?? FilterQuality.none,
|
||||
disableGestures: disableGestures ?? false,
|
||||
disableScaleGestures: disableScaleGestures ?? false,
|
||||
enablePanAlways: enablePanAlways ?? false,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue