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:
shenlong 2025-07-02 23:54:37 +05:30 committed by GitHub
parent ec603a008c
commit 7855974a29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1867 additions and 490 deletions

View file

@ -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

View file

@ -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;

View file

@ -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();

View file

@ -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) {

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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) {

View file

@ -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,
);
}