mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 09:38:43 +03:00
The existing implementation for showing asset details uses a bottom sheet, and is not in sync with the preview or scroll intent. Other apps use inline details, which is much cleaner and feels better to use.
494 lines
17 KiB
Dart
494 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'
|
|
show
|
|
LoadingBuilder,
|
|
PhotoView,
|
|
PhotoViewControllerCallback,
|
|
PhotoViewImageDragEndCallback,
|
|
PhotoViewImageDragStartCallback,
|
|
PhotoViewImageDragUpdateCallback,
|
|
PhotoViewImageLongPressStartCallback,
|
|
PhotoViewImageScaleEndCallback,
|
|
PhotoViewImageTapDownCallback,
|
|
PhotoViewImageTapUpCallback,
|
|
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';
|
|
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_gesture_detector.dart';
|
|
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, PhotoViewControllerBase? controller);
|
|
|
|
/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
|
|
typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(BuildContext context, int index);
|
|
|
|
/// A [StatefulWidget] that shows multiple [PhotoView] widgets in a [PageView]
|
|
///
|
|
/// Some of [PhotoView] constructor options are passed direct to [PhotoViewGallery] constructor. Those options will affect the gallery in a whole.
|
|
///
|
|
/// Some of the options may be defined to each image individually, such as `initialScale` or `PhotoViewHeroAttributes`. Those must be passed via each [PhotoViewGalleryPageOptions].
|
|
///
|
|
/// Example of usage as a list of options:
|
|
/// ```
|
|
/// PhotoViewGallery(
|
|
/// pageOptions: <PhotoViewGalleryPageOptions>[
|
|
/// PhotoViewGalleryPageOptions(
|
|
/// imageProvider: AssetImage("assets/gallery1.jpg"),
|
|
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag1"),
|
|
/// ),
|
|
/// PhotoViewGalleryPageOptions(
|
|
/// imageProvider: AssetImage("assets/gallery2.jpg"),
|
|
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag2"),
|
|
/// maxScale: PhotoViewComputedScale.contained * 0.3
|
|
/// ),
|
|
/// PhotoViewGalleryPageOptions(
|
|
/// imageProvider: AssetImage("assets/gallery3.jpg"),
|
|
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
|
/// maxScale: PhotoViewComputedScale.covered * 1.1,
|
|
/// heroAttributes: const HeroAttributes(tag: "tag3"),
|
|
/// ),
|
|
/// ],
|
|
/// loadingBuilder: (context, progress) => Center(
|
|
/// child: Container(
|
|
/// width: 20.0,
|
|
/// height: 20.0,
|
|
/// child: CircularProgressIndicator(
|
|
/// value: _progress == null
|
|
/// ? null
|
|
/// : _progress.cumulativeBytesLoaded /
|
|
/// _progress.expectedTotalBytes,
|
|
/// ),
|
|
/// ),
|
|
/// ),
|
|
/// backgroundDecoration: widget.backgroundDecoration,
|
|
/// pageController: widget.pageController,
|
|
/// onPageChanged: onPageChanged,
|
|
/// )
|
|
/// ```
|
|
///
|
|
/// Example of usage with builder pattern:
|
|
/// ```
|
|
/// PhotoViewGallery.builder(
|
|
/// scrollPhysics: const BouncingScrollPhysics(),
|
|
/// builder: (BuildContext context, int index) {
|
|
/// return PhotoViewGalleryPageOptions(
|
|
/// imageProvider: AssetImage(widget.galleryItems[index].image),
|
|
/// initialScale: PhotoViewComputedScale.contained * 0.8,
|
|
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
|
/// maxScale: PhotoViewComputedScale.covered * 1.1,
|
|
/// heroAttributes: HeroAttributes(tag: galleryItems[index].id),
|
|
/// );
|
|
/// },
|
|
/// itemCount: galleryItems.length,
|
|
/// loadingBuilder: (context, progress) => Center(
|
|
/// child: Container(
|
|
/// width: 20.0,
|
|
/// height: 20.0,
|
|
/// child: CircularProgressIndicator(
|
|
/// value: _progress == null
|
|
/// ? null
|
|
/// : _progress.cumulativeBytesLoaded /
|
|
/// _progress.expectedTotalBytes,
|
|
/// ),
|
|
/// ),
|
|
/// ),
|
|
/// backgroundDecoration: widget.backgroundDecoration,
|
|
/// pageController: widget.pageController,
|
|
/// onPageChanged: onPageChanged,
|
|
/// )
|
|
/// ```
|
|
class PhotoViewGallery extends StatefulWidget {
|
|
/// Construct a gallery with static items through a list of [PhotoViewGalleryPageOptions].
|
|
const PhotoViewGallery({
|
|
super.key,
|
|
required this.pageOptions,
|
|
this.loadingBuilder,
|
|
this.backgroundDecoration,
|
|
this.wantKeepAlive = false,
|
|
this.gaplessPlayback = false,
|
|
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;
|
|
|
|
/// Construct a gallery with dynamic items.
|
|
///
|
|
/// The builder must return a [PhotoViewGalleryPageOptions].
|
|
const PhotoViewGallery.builder({
|
|
super.key,
|
|
required this.itemCount,
|
|
required this.builder,
|
|
this.loadingBuilder,
|
|
this.backgroundDecoration,
|
|
this.wantKeepAlive = false,
|
|
this.gaplessPlayback = false,
|
|
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);
|
|
|
|
/// A list of options to describe the items in the gallery
|
|
final List<PhotoViewGalleryPageOptions>? pageOptions;
|
|
|
|
/// The count of items in the gallery, only used when constructed via [PhotoViewGallery.builder]
|
|
final int? itemCount;
|
|
|
|
/// Called to build items for the gallery when using [PhotoViewGallery.builder]
|
|
final PhotoViewGalleryBuilder? builder;
|
|
|
|
/// [ScrollPhysics] for the internal [PageView]
|
|
final ScrollPhysics? scrollPhysics;
|
|
|
|
/// Mirror to [PhotoView.loadingBuilder]
|
|
final LoadingBuilder? loadingBuilder;
|
|
|
|
/// Mirror to [PhotoView.backgroundDecoration]
|
|
final BoxDecoration? backgroundDecoration;
|
|
|
|
/// Mirror to [PhotoView.wantKeepAlive]
|
|
final bool wantKeepAlive;
|
|
|
|
/// Mirror to [PhotoView.enablePanAlways]
|
|
final bool enablePanAlways;
|
|
|
|
/// Mirror to [PhotoView.gaplessPlayback]
|
|
final bool gaplessPlayback;
|
|
|
|
/// Mirror to [PageView.reverse]
|
|
final bool reverse;
|
|
|
|
/// An object that controls the [PageView] inside [PhotoViewGallery]
|
|
final PageController? pageController;
|
|
|
|
/// 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;
|
|
|
|
/// Mirror to [PhotoView.enableRotation]
|
|
final bool enableRotation;
|
|
|
|
/// Mirror to [PhotoView.customSize]
|
|
final Size? customSize;
|
|
|
|
/// The axis along which the [PageView] scrolls. Mirror to [PageView.scrollDirection]
|
|
final Axis scrollDirection;
|
|
|
|
/// When user attempts to move it to the next element, focus will traverse to the next page in the page view.
|
|
final bool allowImplicitScrolling;
|
|
|
|
bool get _isBuilder => builder != null;
|
|
|
|
@override
|
|
State<StatefulWidget> createState() {
|
|
return _PhotoViewGalleryState();
|
|
}
|
|
}
|
|
|
|
class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|
late final PageController _controller = widget.pageController ?? PageController();
|
|
PhotoViewControllerCallback? _getController;
|
|
|
|
void scaleStateChangedCallback(PhotoViewScaleState scaleState) {
|
|
if (widget.scaleStateChangedCallback != null) {
|
|
widget.scaleStateChangedCallback!(scaleState);
|
|
}
|
|
}
|
|
|
|
int get actualPage {
|
|
return _controller.hasClients ? _controller.page!.floor() : 0;
|
|
}
|
|
|
|
int get itemCount {
|
|
if (widget._isBuilder) {
|
|
return widget.itemCount!;
|
|
}
|
|
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
|
|
return PhotoViewGestureDetectorScope(
|
|
axis: widget.scrollDirection,
|
|
child: PageView.builder(
|
|
reverse: widget.reverse,
|
|
controller: _controller,
|
|
onPageChanged: _onPageChange,
|
|
itemCount: itemCount,
|
|
itemBuilder: _buildItem,
|
|
scrollDirection: widget.scrollDirection,
|
|
physics: widget.scrollPhysics,
|
|
allowImplicitScrolling: widget.allowImplicitScrolling,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildItem(BuildContext context, int index) {
|
|
final pageOption = _buildPageOption(context, index);
|
|
final isCustomChild = pageOption.child != null;
|
|
|
|
final PhotoView photoView = isCustomChild
|
|
? PhotoView.customChild(
|
|
key: pageOption.key ?? ObjectKey(index),
|
|
childSize: pageOption.childSize,
|
|
backgroundDecoration: widget.backgroundDecoration,
|
|
wantKeepAlive: false,
|
|
controller: pageOption.controller,
|
|
scaleStateController: pageOption.scaleStateController,
|
|
customSize: widget.customSize,
|
|
onPageBuild: widget.onPageBuild,
|
|
controllerCallbackBuilder: _getControllerCallbackBuilder,
|
|
scaleStateChangedCallback: scaleStateChangedCallback,
|
|
enableRotation: widget.enableRotation,
|
|
initialScale: pageOption.initialScale,
|
|
minScale: pageOption.minScale,
|
|
maxScale: pageOption.maxScale,
|
|
scaleStateCycle: pageOption.scaleStateCycle,
|
|
onTapUp: pageOption.onTapUp,
|
|
onTapDown: pageOption.onTapDown,
|
|
onDragStart: pageOption.onDragStart,
|
|
onDragEnd: pageOption.onDragEnd,
|
|
onDragUpdate: pageOption.onDragUpdate,
|
|
onDragCancel: pageOption.onDragCancel,
|
|
onScaleEnd: pageOption.onScaleEnd,
|
|
onLongPressStart: pageOption.onLongPressStart,
|
|
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
|
tightMode: pageOption.tightMode,
|
|
filterQuality: pageOption.filterQuality,
|
|
basePosition: pageOption.basePosition,
|
|
disableGestures: pageOption.disableGestures,
|
|
disableScaleGestures: pageOption.disableScaleGestures,
|
|
heroAttributes: pageOption.heroAttributes,
|
|
enablePanAlways: widget.enablePanAlways,
|
|
child: pageOption.child,
|
|
)
|
|
: PhotoView(
|
|
key: pageOption.key ?? ObjectKey(index),
|
|
index: index,
|
|
imageProvider: pageOption.imageProvider,
|
|
loadingBuilder: widget.loadingBuilder,
|
|
backgroundDecoration: widget.backgroundDecoration,
|
|
semanticLabel: pageOption.semanticLabel,
|
|
wantKeepAlive: false,
|
|
controller: pageOption.controller,
|
|
onPageBuild: widget.onPageBuild,
|
|
controllerCallbackBuilder: _getControllerCallbackBuilder,
|
|
scaleStateController: pageOption.scaleStateController,
|
|
customSize: widget.customSize,
|
|
gaplessPlayback: widget.gaplessPlayback,
|
|
scaleStateChangedCallback: scaleStateChangedCallback,
|
|
enableRotation: widget.enableRotation,
|
|
initialScale: pageOption.initialScale,
|
|
minScale: pageOption.minScale,
|
|
maxScale: pageOption.maxScale,
|
|
scaleStateCycle: pageOption.scaleStateCycle,
|
|
onTapUp: pageOption.onTapUp,
|
|
onTapDown: pageOption.onTapDown,
|
|
onDragStart: pageOption.onDragStart,
|
|
onDragEnd: pageOption.onDragEnd,
|
|
onDragUpdate: pageOption.onDragUpdate,
|
|
onDragCancel: pageOption.onDragCancel,
|
|
onScaleEnd: pageOption.onScaleEnd,
|
|
onLongPressStart: pageOption.onLongPressStart,
|
|
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
|
tightMode: pageOption.tightMode,
|
|
filterQuality: pageOption.filterQuality,
|
|
basePosition: pageOption.basePosition,
|
|
disableGestures: pageOption.disableGestures,
|
|
disableScaleGestures: pageOption.disableScaleGestures,
|
|
enablePanAlways: widget.enablePanAlways,
|
|
errorBuilder: pageOption.errorBuilder,
|
|
heroAttributes: pageOption.heroAttributes,
|
|
);
|
|
|
|
return ClipRect(child: photoView);
|
|
}
|
|
|
|
PhotoViewGalleryPageOptions _buildPageOption(BuildContext context, int index) {
|
|
if (widget._isBuilder) {
|
|
return widget.builder!(context, index);
|
|
}
|
|
return widget.pageOptions![index];
|
|
}
|
|
}
|
|
|
|
/// A helper class that wraps individual options of a page in [PhotoViewGallery]
|
|
///
|
|
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
|
|
///
|
|
class PhotoViewGalleryPageOptions {
|
|
PhotoViewGalleryPageOptions({
|
|
this.key,
|
|
required this.imageProvider,
|
|
this.heroAttributes,
|
|
this.semanticLabel,
|
|
this.minScale,
|
|
this.maxScale,
|
|
this.initialScale,
|
|
this.controller,
|
|
this.scaleStateController,
|
|
this.basePosition,
|
|
this.scaleStateCycle,
|
|
this.onTapUp,
|
|
this.onTapDown,
|
|
this.onDragStart,
|
|
this.onDragEnd,
|
|
this.onDragUpdate,
|
|
this.onDragCancel,
|
|
this.onScaleEnd,
|
|
this.onLongPressStart,
|
|
this.gestureDetectorBehavior,
|
|
this.tightMode,
|
|
this.filterQuality,
|
|
this.disableScaleGestures,
|
|
this.disableGestures,
|
|
this.errorBuilder,
|
|
}) : child = null,
|
|
childSize = null,
|
|
assert(imageProvider != null);
|
|
|
|
const PhotoViewGalleryPageOptions.customChild({
|
|
this.key,
|
|
required this.child,
|
|
this.childSize,
|
|
this.semanticLabel,
|
|
this.heroAttributes,
|
|
this.minScale,
|
|
this.maxScale,
|
|
this.initialScale,
|
|
this.controller,
|
|
this.scaleStateController,
|
|
this.basePosition,
|
|
this.scaleStateCycle,
|
|
this.onTapUp,
|
|
this.onTapDown,
|
|
this.onDragStart,
|
|
this.onDragEnd,
|
|
this.onDragUpdate,
|
|
this.onDragCancel,
|
|
this.onScaleEnd,
|
|
this.onLongPressStart,
|
|
this.gestureDetectorBehavior,
|
|
this.tightMode,
|
|
this.filterQuality,
|
|
this.disableScaleGestures,
|
|
this.disableGestures,
|
|
}) : errorBuilder = null,
|
|
imageProvider = null;
|
|
|
|
final Key? key;
|
|
|
|
/// Mirror to [PhotoView.imageProvider]
|
|
final ImageProvider? imageProvider;
|
|
|
|
/// Mirror to [PhotoView.heroAttributes]
|
|
final PhotoViewHeroAttributes? heroAttributes;
|
|
|
|
/// Mirror to [PhotoView.semanticLabel]
|
|
final String? semanticLabel;
|
|
|
|
/// Mirror to [PhotoView.minScale]
|
|
final dynamic minScale;
|
|
|
|
/// Mirror to [PhotoView.maxScale]
|
|
final dynamic maxScale;
|
|
|
|
/// Mirror to [PhotoView.initialScale]
|
|
final dynamic initialScale;
|
|
|
|
/// Mirror to [PhotoView.controller]
|
|
final PhotoViewController? controller;
|
|
|
|
/// Mirror to [PhotoView.scaleStateController]
|
|
final PhotoViewScaleStateController? scaleStateController;
|
|
|
|
/// Mirror to [PhotoView.basePosition]
|
|
final Alignment? basePosition;
|
|
|
|
/// Mirror to [PhotoView.child]
|
|
final Widget? child;
|
|
|
|
/// Mirror to [PhotoView.childSize]
|
|
final Size? childSize;
|
|
|
|
/// Mirror to [PhotoView.scaleStateCycle]
|
|
final ScaleStateCycle? scaleStateCycle;
|
|
|
|
/// Mirror to [PhotoView.onTapUp]
|
|
final PhotoViewImageTapUpCallback? onTapUp;
|
|
|
|
/// Mirror to [PhotoView.onDragUp]
|
|
final PhotoViewImageDragStartCallback? onDragStart;
|
|
|
|
/// Mirror to [PhotoView.onDragDown]
|
|
final PhotoViewImageDragEndCallback? onDragEnd;
|
|
|
|
/// Mirror to [PhotoView.onDragUpdate]
|
|
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
|
|
|
/// Mirror to [PhotoView.onDragCancel]
|
|
final VoidCallback? onDragCancel;
|
|
|
|
/// Mirror to [PhotoView.onTapDown]
|
|
final PhotoViewImageTapDownCallback? onTapDown;
|
|
|
|
/// Mirror to [PhotoView.onScaleEnd]
|
|
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
|
|
|
/// Mirror to [PhotoView.onLongPressStart]
|
|
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
|
|
|
/// Mirror to [PhotoView.gestureDetectorBehavior]
|
|
final HitTestBehavior? gestureDetectorBehavior;
|
|
|
|
/// Mirror to [PhotoView.tightMode]
|
|
final bool? tightMode;
|
|
|
|
/// Mirror to [PhotoView.disableGestures]
|
|
final bool? disableGestures;
|
|
|
|
/// Mirror to [PhotoView.disableGestures]
|
|
final bool? disableScaleGestures;
|
|
|
|
/// Quality levels for image filters.
|
|
final FilterQuality? filterQuality;
|
|
|
|
/// Mirror to [PhotoView.errorBuilder]
|
|
final ImageErrorWidgetBuilder? errorBuilder;
|
|
}
|