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
47 changed files with 1867 additions and 490 deletions
@@ -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,
);
}