mirror of
https://github.com/immich-app/immich.git
synced 2025-01-16 16:56:46 +01:00
(fix)mobile: Improve the gallery to improve scale, double tap, and swipe gesture detection (#1502)
* photoviewgallery * stiffer scrolling to react more like google photos * adds a dx threshhold for the swipe/up down from the original dropped point * stopped wrapping imageview in gallery viewer to avoid the double photoview issue. breaks imageview page pinch-to-zoom, so i need to fix that for other callers * refactors gallery view to use remoteimage directly and breaks imageviewpage * removed image_viewer_page * adds minscale * adds photo_view to repository * double tap to zoom out with hacked commit * double tapping! * got up and down swipe gestures working * fixed wrong cache and headers in image providers * fixed image quality and added videos back in * local loading asset image fix * precaches images * fixes lint errors * deleted remote_photo_view and more linters * fixes scale * load preview and load original * precache does original / preview as well * refactored image providers to nice functions and added JPEG thumbnail format to remote image thumbnail lookup * moved photo_view to shared/ui/ * three stage loading with webp and fixes some thumbnail fits * fixed local thumbnail * fixed paging in iOS
This commit is contained in:
parent
391bf052e4
commit
02f5a86ee9
22 changed files with 3460 additions and 448 deletions
|
@ -1,205 +0,0 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:photo_manager/photo_manager.dart'
|
||||
show AssetEntityImageProvider, ThumbnailSize;
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
enum _RemoteImageStatus { empty, thumbnail, preview, full }
|
||||
|
||||
class _RemotePhotoViewState extends State<RemotePhotoView> {
|
||||
late ImageProvider _imageProvider;
|
||||
_RemoteImageStatus _status = _RemoteImageStatus.empty;
|
||||
bool _zoomedIn = false;
|
||||
|
||||
late ImageProvider _fullProvider;
|
||||
late ImageProvider _previewProvider;
|
||||
late ImageProvider _thumbnailProvider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool forbidZoom = _status == _RemoteImageStatus.thumbnail;
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: forbidZoom,
|
||||
child: Listener(
|
||||
onPointerMove: handleSwipUpDown,
|
||||
child: PhotoView(
|
||||
imageProvider: _imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
enablePanAlways: false,
|
||||
scaleStateChangedCallback: _scaleStateChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void handleSwipUpDown(PointerMoveEvent details) {
|
||||
int sensitivity = 15;
|
||||
|
||||
if (_zoomedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.delta.dy > sensitivity) {
|
||||
widget.onSwipeDown();
|
||||
} else if (details.delta.dy < -sensitivity) {
|
||||
widget.onSwipeUp();
|
||||
}
|
||||
}
|
||||
|
||||
void _scaleStateChanged(PhotoViewScaleState state) {
|
||||
_zoomedIn = state != PhotoViewScaleState.initial;
|
||||
if (_zoomedIn) {
|
||||
widget.isZoomedListener.value = true;
|
||||
} else {
|
||||
widget.isZoomedListener.value = false;
|
||||
}
|
||||
widget.isZoomedFunction();
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(
|
||||
String url,
|
||||
String cacheKey,
|
||||
) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: {"Authorization": widget.authToken},
|
||||
cacheKey: cacheKey,
|
||||
);
|
||||
}
|
||||
|
||||
void _performStateTransition(
|
||||
_RemoteImageStatus newStatus,
|
||||
ImageProvider provider,
|
||||
) {
|
||||
if (_status == newStatus) return;
|
||||
|
||||
if (_status == _RemoteImageStatus.full &&
|
||||
newStatus == _RemoteImageStatus.thumbnail) return;
|
||||
|
||||
if (_status == _RemoteImageStatus.preview &&
|
||||
newStatus == _RemoteImageStatus.thumbnail) return;
|
||||
|
||||
if (_status == _RemoteImageStatus.full &&
|
||||
newStatus == _RemoteImageStatus.preview) return;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_status = newStatus;
|
||||
_imageProvider = provider;
|
||||
});
|
||||
}
|
||||
|
||||
void _loadImages() {
|
||||
if (widget.asset.isLocal) {
|
||||
_imageProvider = AssetEntityImageProvider(
|
||||
widget.asset.local!,
|
||||
isOriginal: false,
|
||||
thumbnailSize: const ThumbnailSize.square(250),
|
||||
);
|
||||
_fullProvider = AssetEntityImageProvider(widget.asset.local!);
|
||||
_fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo image, _) {
|
||||
_performStateTransition(
|
||||
_RemoteImageStatus.full,
|
||||
_fullProvider,
|
||||
);
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_thumbnailProvider = _authorizedImageProvider(
|
||||
getThumbnailUrl(widget.asset.remote!),
|
||||
getThumbnailCacheKey(widget.asset.remote!),
|
||||
);
|
||||
_imageProvider = _thumbnailProvider;
|
||||
|
||||
_thumbnailProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(
|
||||
_RemoteImageStatus.thumbnail,
|
||||
_thumbnailProvider,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (widget.loadPreview) {
|
||||
_previewProvider = _authorizedImageProvider(
|
||||
getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG),
|
||||
getThumbnailCacheKey(widget.asset.remote!, type: ThumbnailFormat.JPEG),
|
||||
);
|
||||
_previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.preview, _previewProvider);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.loadOriginal) {
|
||||
_fullProvider = _authorizedImageProvider(
|
||||
getImageUrl(widget.asset.remote!),
|
||||
getImageCacheKey(widget.asset.remote!),
|
||||
);
|
||||
_fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.full, _fullProvider);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadImages();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() async {
|
||||
super.dispose();
|
||||
|
||||
if (_status == _RemoteImageStatus.full) {
|
||||
await _fullProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.preview) {
|
||||
await _previewProvider.evict();
|
||||
} else if (_status == _RemoteImageStatus.thumbnail) {
|
||||
await _thumbnailProvider.evict();
|
||||
}
|
||||
|
||||
await _imageProvider.evict();
|
||||
}
|
||||
}
|
||||
|
||||
class RemotePhotoView extends StatefulWidget {
|
||||
const RemotePhotoView({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.authToken,
|
||||
required this.loadPreview,
|
||||
required this.loadOriginal,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.onSwipeDown,
|
||||
required this.onSwipeUp,
|
||||
}) : super(key: key);
|
||||
|
||||
final Asset asset;
|
||||
final String authToken;
|
||||
final bool loadPreview;
|
||||
final bool loadOriginal;
|
||||
final void Function() onSwipeDown;
|
||||
final void Function() onSwipeUp;
|
||||
final void Function() isZoomedFunction;
|
||||
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _RemotePhotoViewState();
|
||||
}
|
||||
}
|
|
@ -1,4 +1,7 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
@ -9,14 +12,21 @@ import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart';
|
|||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:openapi/api.dart' as api;
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class GalleryViewerPage extends HookConsumerWidget {
|
||||
|
@ -40,7 +50,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
final isZoomed = useState<bool>(false);
|
||||
final indexOfAsset = useState(assetList.indexOf(asset));
|
||||
final isPlayingMotionVideo = useState(false);
|
||||
ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
|
||||
late Offset localPosition;
|
||||
final authToken = 'Bearer ${box.get(accessTokenKey)}';
|
||||
|
||||
PageController controller =
|
||||
PageController(initialPage: assetList.indexOf(asset));
|
||||
|
@ -57,7 +68,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
[],
|
||||
);
|
||||
|
||||
getAssetExif() async {
|
||||
void getAssetExif() async {
|
||||
if (assetList[indexOfAsset.value].isRemote) {
|
||||
assetDetail = await ref
|
||||
.watch(assetServiceProvider)
|
||||
|
@ -68,27 +79,96 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
void showInfo() {
|
||||
showModalBottomSheet(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
/// Thumbnail image of a remote asset. Required asset.remote != null
|
||||
ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) {
|
||||
return CachedNetworkImageProvider(
|
||||
getThumbnailUrl(
|
||||
asset.remote!,
|
||||
type: type,
|
||||
),
|
||||
barrierColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
},
|
||||
cacheKey: getThumbnailCacheKey(
|
||||
asset.remote!,
|
||||
type: type,
|
||||
),
|
||||
headers: {"Authorization": authToken},
|
||||
);
|
||||
}
|
||||
|
||||
//make isZoomed listener call instead
|
||||
void isZoomedMethod() {
|
||||
if (isZoomedListener.value) {
|
||||
isZoomed.value = true;
|
||||
} else {
|
||||
isZoomed.value = false;
|
||||
/// Original (large) image of a remote asset. Required asset.remote != null
|
||||
ImageProvider originalImageProvider(Asset asset) {
|
||||
return CachedNetworkImageProvider(
|
||||
getImageUrl(asset.remote!),
|
||||
cacheKey: getImageCacheKey(asset.remote!),
|
||||
headers: {"Authorization": authToken},
|
||||
);
|
||||
}
|
||||
|
||||
/// Thumbnail image of a local asset. Required asset.local != null
|
||||
ImageProvider localThumbnailImageProvider(Asset asset) {
|
||||
return AssetEntityImageProvider(
|
||||
asset.local!,
|
||||
isOriginal: false,
|
||||
thumbnailSize: const ThumbnailSize.square(250),
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/// Original (large) image of a local asset. Required asset.local != null
|
||||
ImageProvider localImageProvider(Asset asset) {
|
||||
return AssetEntityImageProvider(asset.local!);
|
||||
}
|
||||
|
||||
void precacheNextImage(int index) {
|
||||
if (index < assetList.length && index > 0) {
|
||||
final asset = assetList[index];
|
||||
if (asset.isLocal) {
|
||||
// Preload the local asset
|
||||
precacheImage(localImageProvider(asset), context);
|
||||
} else {
|
||||
// Probably load WEBP either way
|
||||
precacheImage(
|
||||
remoteThumbnailImageProvider(
|
||||
asset,
|
||||
api.ThumbnailFormat.WEBP,
|
||||
),
|
||||
context,
|
||||
);
|
||||
if (isLoadPreview.value) {
|
||||
// Precache the JPEG thumbnail
|
||||
precacheImage(
|
||||
remoteThumbnailImageProvider(
|
||||
asset,
|
||||
api.ThumbnailFormat.JPEG,
|
||||
),
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (isLoadOriginal.value) {
|
||||
// Preload the original asset
|
||||
precacheImage(
|
||||
originalImageProvider(asset),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void showInfo() {
|
||||
if (assetList[indexOfAsset.value].isRemote) {
|
||||
showModalBottomSheet(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
barrierColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return ExifBottomSheet(assetDetail: assetDetail!);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -122,6 +202,28 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void handleSwipeUpDown(DragUpdateDetails details) {
|
||||
int sensitivity = 15;
|
||||
int dxThreshhold = 50;
|
||||
|
||||
if (isZoomed.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for delta from initial down point
|
||||
final d = details.localPosition - localPosition;
|
||||
// If the magnitude of the dx swipe is large, we probably didn't mean to go down
|
||||
if (d.dx.abs() > dxThreshhold) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.delta.dy > sensitivity) {
|
||||
AutoRouter.of(context).pop();
|
||||
} else if (details.delta.dy < -sensitivity) {
|
||||
showInfo();
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: TopControlAppBar(
|
||||
|
@ -150,61 +252,93 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: PageView.builder(
|
||||
controller: controller,
|
||||
pageSnapping: true,
|
||||
physics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const BouncingScrollPhysics(),
|
||||
child: PhotoViewGallery.builder(
|
||||
scaleStateChangedCallback: (state) => isZoomed.value = state != PhotoViewScaleState.initial,
|
||||
pageController: controller,
|
||||
scrollPhysics: isZoomed.value
|
||||
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
|
||||
: (Platform.isIOS
|
||||
? const BouncingScrollPhysics() // Use bouncing physics for iOS
|
||||
: const ImmichPageViewScrollPhysics() // Use heavy physics for Android
|
||||
),
|
||||
itemCount: assetList.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
onPageChanged: (value) {
|
||||
// Precache image
|
||||
if (indexOfAsset.value < value) {
|
||||
// Moving forwards, so precache the next asset
|
||||
precacheNextImage(value + 1);
|
||||
} else {
|
||||
// Moving backwards, so precache previous asset
|
||||
precacheNextImage(value - 1);
|
||||
}
|
||||
indexOfAsset.value = value;
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
getAssetExif();
|
||||
loadingBuilder: isLoadPreview.value ? (context, event) {
|
||||
final asset = assetList[indexOfAsset.value];
|
||||
if (!asset.isLocal) {
|
||||
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive
|
||||
// Three-Stage Loading (WEBP -> JPEG -> Original)
|
||||
final webPThumbnail = CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP),
|
||||
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP),
|
||||
httpHeaders: { 'Authorization': authToken },
|
||||
progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
|
||||
if (assetList[index].isImage) {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
return VideoViewerPage(
|
||||
asset: assetList[index],
|
||||
isMotionVideo: true,
|
||||
onVideoEnded: () {
|
||||
isPlayingMotionVideo.value = false;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return ImageViewerPage(
|
||||
authToken: 'Bearer ${box.get(accessTokenKey)}',
|
||||
isZoomedFunction: isZoomedMethod,
|
||||
isZoomedListener: isZoomedListener,
|
||||
asset: assetList[index],
|
||||
heroTag: assetList[index].id,
|
||||
loadPreview: isLoadPreview.value,
|
||||
loadOriginal: isLoadOriginal.value,
|
||||
showExifSheet: showInfo,
|
||||
);
|
||||
}
|
||||
return CachedNetworkImage(
|
||||
imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG),
|
||||
cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG),
|
||||
httpHeaders: { 'Authorization': authToken },
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, __) => webPThumbnail,
|
||||
);
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onVerticalDragUpdate: (details) {
|
||||
const int sensitivity = 15;
|
||||
if (details.delta.dy > sensitivity) {
|
||||
// swipe down
|
||||
AutoRouter.of(context).pop();
|
||||
} else if (details.delta.dy < -sensitivity) {
|
||||
// swipe up
|
||||
showInfo();
|
||||
}
|
||||
},
|
||||
child: Hero(
|
||||
tag: assetList[index].id,
|
||||
child: VideoViewerPage(
|
||||
asset: assetList[index],
|
||||
isMotionVideo: false,
|
||||
onVideoEnded: () {},
|
||||
),
|
||||
return Image(
|
||||
image: localThumbnailImageProvider(asset),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
} : null,
|
||||
builder: (context, index) {
|
||||
getAssetExif();
|
||||
if (assetList[index].isImage && !isPlayingMotionVideo.value) {
|
||||
// Show photo
|
||||
final ImageProvider provider;
|
||||
if (assetList[index].isLocal) {
|
||||
provider = localImageProvider(assetList[index]);
|
||||
} else {
|
||||
if (isLoadOriginal.value) {
|
||||
provider = originalImageProvider(assetList[index]);
|
||||
} else {
|
||||
provider = remoteThumbnailImageProvider(
|
||||
assetList[index],
|
||||
api.ThumbnailFormat.JPEG,
|
||||
);
|
||||
}
|
||||
}
|
||||
return PhotoViewGalleryPageOptions(
|
||||
onDragStart: (_, details, __) => localPosition = details.localPosition,
|
||||
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||
imageProvider: provider,
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
);
|
||||
} else {
|
||||
return PhotoViewGalleryPageOptions.customChild(
|
||||
onDragStart: (_, details, __) => localPosition = details.localPosition,
|
||||
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id),
|
||||
child: VideoViewerPage(
|
||||
asset: assetList[index],
|
||||
isMotionVideo: isPlayingMotionVideo.value,
|
||||
onVideoEnded: () {
|
||||
if (isPlayingMotionVideo.value) {
|
||||
isPlayingMotionVideo.value = false;
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -214,3 +348,19 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ImmichPageViewScrollPhysics extends ScrollPhysics {
|
||||
const ImmichPageViewScrollPhysics({super.parent});
|
||||
|
||||
@override
|
||||
ImmichPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return ImmichPageViewScrollPhysics(parent: buildParent(ancestor)!);
|
||||
}
|
||||
|
||||
@override
|
||||
SpringDescription get spring => const SpringDescription(
|
||||
mass: 100,
|
||||
stiffness: 100,
|
||||
damping: .90,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,84 +0,0 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class ImageViewerPage extends HookConsumerWidget {
|
||||
final String heroTag;
|
||||
final Asset asset;
|
||||
final String authToken;
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
final void Function() isZoomedFunction;
|
||||
final void Function()? showExifSheet;
|
||||
final bool loadPreview;
|
||||
final bool loadOriginal;
|
||||
|
||||
ImageViewerPage({
|
||||
Key? key,
|
||||
required this.heroTag,
|
||||
required this.asset,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.loadPreview,
|
||||
required this.loadOriginal,
|
||||
this.showExifSheet,
|
||||
}) : super(key: key);
|
||||
|
||||
Asset? assetDetail;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
|
||||
getAssetExif() async {
|
||||
if (asset.isRemote) {
|
||||
assetDetail =
|
||||
await ref.watch(assetServiceProvider).getAssetById(asset.id);
|
||||
} else {
|
||||
// TODO local exif parsing?
|
||||
assetDetail = asset;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
getAssetExif();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Hero(
|
||||
tag: heroTag,
|
||||
child: RemotePhotoView(
|
||||
asset: asset,
|
||||
authToken: authToken,
|
||||
loadPreview: loadPreview,
|
||||
loadOriginal: loadOriginal,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
onSwipeDown: () => AutoRouter.of(context).pop(),
|
||||
onSwipeUp: (asset.isRemote && showExifSheet != null) ? showExifSheet! : () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (downloadAssetStatus == DownloadAssetStatus.loading)
|
||||
const Center(
|
||||
child: ImmichLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -10,7 +10,6 @@ import 'package:immich_mobile/modules/album/views/select_additional_user_for_sha
|
|||
import 'package:immich_mobile/modules/album/views/select_user_for_sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/sharing_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/gallery_viewer.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
|
||||
|
@ -52,7 +51,6 @@ part 'router.gr.dart';
|
|||
transitionsBuilder: TransitionsBuilders.fadeIn,
|
||||
),
|
||||
AutoRoute(page: GalleryViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: ImageViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: VideoViewerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: BackupControllerPage, guards: [AuthGuard]),
|
||||
AutoRoute(page: SearchResultPage, guards: [AuthGuard]),
|
||||
|
|
|
@ -48,21 +48,6 @@ class _$AppRouter extends RootStackRouter {
|
|||
child: GalleryViewerPage(
|
||||
key: args.key, assetList: args.assetList, asset: args.asset));
|
||||
},
|
||||
ImageViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<ImageViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: ImageViewerPage(
|
||||
key: args.key,
|
||||
heroTag: args.heroTag,
|
||||
asset: args.asset,
|
||||
authToken: args.authToken,
|
||||
isZoomedFunction: args.isZoomedFunction,
|
||||
isZoomedListener: args.isZoomedListener,
|
||||
loadPreview: args.loadPreview,
|
||||
loadOriginal: args.loadOriginal,
|
||||
showExifSheet: args.showExifSheet));
|
||||
},
|
||||
VideoViewerRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<VideoViewerRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
|
@ -204,8 +189,6 @@ class _$AppRouter extends RootStackRouter {
|
|||
]),
|
||||
RouteConfig(GalleryViewerRoute.name,
|
||||
path: '/gallery-viewer-page', guards: [authGuard]),
|
||||
RouteConfig(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page', guards: [authGuard]),
|
||||
RouteConfig(VideoViewerRoute.name,
|
||||
path: '/video-viewer-page', guards: [authGuard]),
|
||||
RouteConfig(BackupControllerRoute.name,
|
||||
|
@ -299,71 +282,6 @@ class GalleryViewerRouteArgs {
|
|||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [ImageViewerPage]
|
||||
class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
|
||||
ImageViewerRoute(
|
||||
{Key? key,
|
||||
required String heroTag,
|
||||
required Asset asset,
|
||||
required String authToken,
|
||||
required void Function() isZoomedFunction,
|
||||
required ValueNotifier<bool> isZoomedListener,
|
||||
required bool loadPreview,
|
||||
required bool loadOriginal,
|
||||
void Function()? showExifSheet})
|
||||
: super(ImageViewerRoute.name,
|
||||
path: '/image-viewer-page',
|
||||
args: ImageViewerRouteArgs(
|
||||
key: key,
|
||||
heroTag: heroTag,
|
||||
asset: asset,
|
||||
authToken: authToken,
|
||||
isZoomedFunction: isZoomedFunction,
|
||||
isZoomedListener: isZoomedListener,
|
||||
loadPreview: loadPreview,
|
||||
loadOriginal: loadOriginal,
|
||||
showExifSheet: showExifSheet));
|
||||
|
||||
static const String name = 'ImageViewerRoute';
|
||||
}
|
||||
|
||||
class ImageViewerRouteArgs {
|
||||
const ImageViewerRouteArgs(
|
||||
{this.key,
|
||||
required this.heroTag,
|
||||
required this.asset,
|
||||
required this.authToken,
|
||||
required this.isZoomedFunction,
|
||||
required this.isZoomedListener,
|
||||
required this.loadPreview,
|
||||
required this.loadOriginal,
|
||||
this.showExifSheet});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String heroTag;
|
||||
|
||||
final Asset asset;
|
||||
|
||||
final String authToken;
|
||||
|
||||
final void Function() isZoomedFunction;
|
||||
|
||||
final ValueNotifier<bool> isZoomedListener;
|
||||
|
||||
final bool loadPreview;
|
||||
|
||||
final bool loadOriginal;
|
||||
|
||||
final void Function()? showExifSheet;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [VideoViewerPage]
|
||||
class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
|
||||
|
|
653
mobile/lib/shared/ui/photo_view/photo_view.dart
Normal file
653
mobile/lib/shared/ui/photo_view/photo_view.dart
Normal file
|
@ -0,0 +1,653 @@
|
|||
library photo_view;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_wrappers.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
export 'src/controller/photo_view_controller.dart';
|
||||
export 'src/controller/photo_view_scalestate_controller.dart';
|
||||
export 'src/core/photo_view_gesture_detector.dart'
|
||||
show PhotoViewGestureDetectorScope, PhotoViewPageViewScrollPhysics;
|
||||
export 'src/photo_view_computed_scale.dart';
|
||||
export 'src/photo_view_scale_state.dart';
|
||||
export 'src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
/// A [StatefulWidget] that contains all the photo view rendering elements.
|
||||
///
|
||||
/// Sample code to use within an image:
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView(
|
||||
/// imageProvider: imageProvider,
|
||||
/// loadingBuilder: (context, progress) => Center(
|
||||
/// child: Container(
|
||||
/// width: 20.0,
|
||||
/// height: 20.0,
|
||||
/// child: CircularProgressIndicator(
|
||||
/// value: _progress == null
|
||||
/// ? null
|
||||
/// : _progress.cumulativeBytesLoaded /
|
||||
/// _progress.expectedTotalBytes,
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// backgroundDecoration: BoxDecoration(color: Colors.black),
|
||||
/// gaplessPlayback: false,
|
||||
/// customSize: MediaQuery.of(context).size,
|
||||
/// heroAttributes: const HeroAttributes(
|
||||
/// tag: "someTag",
|
||||
/// transitionOnUserGestures: true,
|
||||
/// ),
|
||||
/// scaleStateChangedCallback: this.onScaleStateChanged,
|
||||
/// enableRotation: true,
|
||||
/// controller: controller,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained,
|
||||
/// basePosition: Alignment.center,
|
||||
/// scaleStateCycle: scaleStateCycle
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// You can customize to show an custom child instead of an image:
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView.customChild(
|
||||
/// child: Container(
|
||||
/// width: 220.0,
|
||||
/// height: 250.0,
|
||||
/// child: const Text(
|
||||
/// "Hello there, this is a text",
|
||||
/// )
|
||||
/// ),
|
||||
/// childSize: const Size(220.0, 250.0),
|
||||
/// backgroundDecoration: BoxDecoration(color: Colors.black),
|
||||
/// gaplessPlayback: false,
|
||||
/// customSize: MediaQuery.of(context).size,
|
||||
/// heroAttributes: const HeroAttributes(
|
||||
/// tag: "someTag",
|
||||
/// transitionOnUserGestures: true,
|
||||
/// ),
|
||||
/// scaleStateChangedCallback: this.onScaleStateChanged,
|
||||
/// enableRotation: true,
|
||||
/// controller: controller,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained,
|
||||
/// basePosition: Alignment.center,
|
||||
/// scaleStateCycle: scaleStateCycle
|
||||
/// );
|
||||
/// ```
|
||||
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
|
||||
///
|
||||
/// Sample using [maxScale], [minScale] and [initialScale]
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView(
|
||||
/// imageProvider: imageProvider,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained * 1.1,
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// [customSize] is used to define the viewPort size in which the image will be
|
||||
/// scaled to. This argument is rarely used. By default is the size that this widget assumes.
|
||||
///
|
||||
/// The argument [gaplessPlayback] 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`.
|
||||
///
|
||||
/// To use within an hero animation, specify [heroAttributes]. When
|
||||
/// [heroAttributes] is specified, the image provider retrieval process should
|
||||
/// be sync.
|
||||
///
|
||||
/// Sample using hero animation:
|
||||
/// ```
|
||||
/// // screen1
|
||||
/// ...
|
||||
/// Hero(
|
||||
/// tag: "someTag",
|
||||
/// child: Image.asset(
|
||||
/// "assets/large-image.jpg",
|
||||
/// width: 150.0
|
||||
/// ),
|
||||
/// )
|
||||
/// // screen2
|
||||
/// ...
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/large-image.jpg"),
|
||||
/// heroAttributes: const HeroAttributes(tag: "someTag"),
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// **Note: If you don't want to the zoomed image do not overlaps the size of the container, use [ClipRect](https://docs.flutter.io/flutter/widgets/ClipRect-class.html)**
|
||||
///
|
||||
/// ## Controllers
|
||||
///
|
||||
/// Controllers, when specified to PhotoView widget, enables the author(you) to listen for state updates through a `Stream` and change those values externally.
|
||||
///
|
||||
/// While [PhotoViewScaleStateController] is only responsible for the `scaleState`, [PhotoViewController] is responsible for all fields os [PhotoViewControllerValue].
|
||||
///
|
||||
/// To use them, pass a instance of those items on [controller] or [scaleStateController];
|
||||
///
|
||||
/// Since those follows the standard controller pattern found in widgets like [PageView] and [ScrollView], whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
/// Example of [controller] usage, only listening for state changes:
|
||||
///
|
||||
/// ```
|
||||
/// class _ExampleWidgetState extends State<ExampleWidget> {
|
||||
///
|
||||
/// PhotoViewController controller;
|
||||
/// double scaleCopy;
|
||||
///
|
||||
/// @override
|
||||
/// void initState() {
|
||||
/// super.initState();
|
||||
/// controller = PhotoViewController()
|
||||
/// ..outputStateStream.listen(listener);
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// void dispose() {
|
||||
/// controller.dispose();
|
||||
/// super.dispose();
|
||||
/// }
|
||||
///
|
||||
/// void listener(PhotoViewControllerValue value){
|
||||
/// setState((){
|
||||
/// scaleCopy = value.scale;
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return Stack(
|
||||
/// children: <Widget>[
|
||||
/// Positioned.fill(
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.png"),
|
||||
/// controller: controller,
|
||||
/// );
|
||||
/// ),
|
||||
/// Text("Scale applied: $scaleCopy")
|
||||
/// ],
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// An example of [scaleStateController] with state changes:
|
||||
/// ```
|
||||
/// class _ExampleWidgetState extends State<ExampleWidget> {
|
||||
///
|
||||
/// PhotoViewScaleStateController scaleStateController;
|
||||
///
|
||||
/// @override
|
||||
/// void initState() {
|
||||
/// super.initState();
|
||||
/// scaleStateController = PhotoViewScaleStateController();
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// void dispose() {
|
||||
/// scaleStateController.dispose();
|
||||
/// super.dispose();
|
||||
/// }
|
||||
///
|
||||
/// void goBack(){
|
||||
/// scaleStateController.scaleState = PhotoViewScaleState.originalSize;
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return Stack(
|
||||
/// children: <Widget>[
|
||||
/// Positioned.fill(
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.png"),
|
||||
/// scaleStateController: scaleStateController,
|
||||
/// );
|
||||
/// ),
|
||||
/// FlatButton(
|
||||
/// child: Text("Go to original size"),
|
||||
/// onPressed: goBack,
|
||||
/// );
|
||||
/// ],
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
class PhotoView extends StatefulWidget {
|
||||
/// Creates a widget that displays a zoomable image.
|
||||
///
|
||||
/// To show an image from the network or from an asset bundle, use their respective
|
||||
/// image providers, ie: [AssetImage] or [NetworkImage]
|
||||
///
|
||||
/// Internally, the image is rendered within an [Image] widget.
|
||||
const PhotoView({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
this.loadingBuilder,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.maxScale,
|
||||
this.minScale,
|
||||
this.initialScale,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.customSize,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.errorBuilder,
|
||||
this.enablePanAlways,
|
||||
}) : child = null,
|
||||
childSize = null,
|
||||
super(key: key);
|
||||
|
||||
/// Creates a widget that displays a zoomable child.
|
||||
///
|
||||
/// It has been created to resemble [PhotoView] behavior within widgets that aren't an image, such as [Container], [Text] or a svg.
|
||||
///
|
||||
/// Instead of a [imageProvider], this constructor will receive a [child] and a [childSize].
|
||||
///
|
||||
const PhotoView.customChild({
|
||||
Key? key,
|
||||
required this.child,
|
||||
this.childSize,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.maxScale,
|
||||
this.minScale,
|
||||
this.initialScale,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.customSize,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.enablePanAlways,
|
||||
}) : errorBuilder = null,
|
||||
imageProvider = null,
|
||||
gaplessPlayback = false,
|
||||
loadingBuilder = null,
|
||||
super(key: key);
|
||||
|
||||
/// Given a [imageProvider] it resolves into an zoomable image widget using. It
|
||||
/// is required
|
||||
final ImageProvider? imageProvider;
|
||||
|
||||
/// While [imageProvider] is not resolved, [loadingBuilder] is called by [PhotoView]
|
||||
/// into the screen, by default it is a centered [CircularProgressIndicator]
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
|
||||
/// Show loadFailedChild when the image failed to load
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
|
||||
/// Changes the background behind image, defaults to `Colors.black`.
|
||||
final BoxDecoration? backgroundDecoration;
|
||||
|
||||
/// This is used to keep the state of an image in the gallery (e.g. scale state).
|
||||
/// `false` -> resets the state (default)
|
||||
/// `true` -> keeps the state
|
||||
final bool wantKeepAlive;
|
||||
|
||||
/// 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`.
|
||||
final bool gaplessPlayback;
|
||||
|
||||
/// Attributes that are going to be passed to [PhotoViewCore]'s
|
||||
/// [Hero]. Leave this property undefined if you don't want a hero animation.
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
|
||||
/// Defines the size of the scaling base of the image inside [PhotoView],
|
||||
/// by default it is `MediaQuery.of(context).size`.
|
||||
final Size? customSize;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// A flag that enables the rotation gesture support
|
||||
final bool enableRotation;
|
||||
|
||||
/// The specified custom child to be shown instead of a image
|
||||
final Widget? child;
|
||||
|
||||
/// The size of the custom [child]. [PhotoView] uses this value to compute the relation between the child and the container's size to calculate the scale value.
|
||||
final Size? childSize;
|
||||
|
||||
/// Defines the maximum size in which the image will be allowed to assume, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic maxScale;
|
||||
|
||||
/// Defines the minimum size in which the image will be allowed to assume, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic minScale;
|
||||
|
||||
/// Defines the initial size in which the image will be assume in the mounting of the component, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic initialScale;
|
||||
|
||||
/// A way to control PhotoView transformation factors externally and listen to its updates
|
||||
final PhotoViewControllerBase? controller;
|
||||
|
||||
/// A way to control PhotoViewScaleState value externally and listen to its updates
|
||||
final PhotoViewScaleStateController? scaleStateController;
|
||||
|
||||
/// The alignment of the scale origin in relation to the widget size. Default is [Alignment.center]
|
||||
final Alignment? basePosition;
|
||||
|
||||
/// Defines de next [PhotoViewScaleState] given the actual one. Default is [defaultScaleStateCycle]
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
|
||||
/// A pointer that will trigger a tap has stopped contacting the screen at a
|
||||
/// particular location.
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
/// A pointer that will trigger a scale has stopped contacting the screen at a
|
||||
/// particular location.
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
/// [HitTestBehavior] to be passed to the internal gesture detector.
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
|
||||
/// Enables tight mode, making background container assume the size of the image/child.
|
||||
/// Useful when inside a [Dialog]
|
||||
final bool? tightMode;
|
||||
|
||||
/// Quality levels for image filters.
|
||||
final FilterQuality? filterQuality;
|
||||
|
||||
// Removes gesture detector if `true`.
|
||||
// Useful when custom gesture detector is used in child widget.
|
||||
final bool? disableGestures;
|
||||
|
||||
/// 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;
|
||||
|
||||
bool get _isCustomChild {
|
||||
return child != null;
|
||||
}
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _PhotoViewState();
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoViewState extends State<PhotoView>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
// image retrieval
|
||||
|
||||
// controller
|
||||
late bool _controlledController;
|
||||
late PhotoViewControllerBase _controller;
|
||||
late bool _controlledScaleStateController;
|
||||
late PhotoViewScaleStateController _scaleStateController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.controller == null) {
|
||||
_controlledController = true;
|
||||
_controller = PhotoViewController();
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
|
||||
if (widget.scaleStateController == null) {
|
||||
_controlledScaleStateController = true;
|
||||
_scaleStateController = PhotoViewScaleStateController();
|
||||
} else {
|
||||
_controlledScaleStateController = false;
|
||||
_scaleStateController = widget.scaleStateController!;
|
||||
}
|
||||
|
||||
_scaleStateController.outputScaleStateStream.listen(scaleStateListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PhotoView oldWidget) {
|
||||
if (widget.controller == null) {
|
||||
if (!_controlledController) {
|
||||
_controlledController = true;
|
||||
_controller = PhotoViewController();
|
||||
}
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
|
||||
if (widget.scaleStateController == null) {
|
||||
if (!_controlledScaleStateController) {
|
||||
_controlledScaleStateController = true;
|
||||
_scaleStateController = PhotoViewScaleStateController();
|
||||
}
|
||||
} else {
|
||||
_controlledScaleStateController = false;
|
||||
_scaleStateController = widget.scaleStateController!;
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_controlledController) {
|
||||
_controller.dispose();
|
||||
}
|
||||
if (_controlledScaleStateController) {
|
||||
_scaleStateController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void scaleStateListener(PhotoViewScaleState scaleState) {
|
||||
if (widget.scaleStateChangedCallback != null) {
|
||||
widget.scaleStateChangedCallback!(_scaleStateController.scaleState);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return LayoutBuilder(
|
||||
builder: (
|
||||
BuildContext context,
|
||||
BoxConstraints constraints,
|
||||
) {
|
||||
final computedOuterSize = widget.customSize ?? constraints.biggest;
|
||||
final backgroundDecoration = widget.backgroundDecoration ??
|
||||
const BoxDecoration(color: Colors.black);
|
||||
|
||||
return widget._isCustomChild
|
||||
? CustomChildWrapper(
|
||||
childSize: widget.childSize,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
controller: _controller,
|
||||
scaleStateController: _scaleStateController,
|
||||
maxScale: widget.maxScale,
|
||||
minScale: widget.minScale,
|
||||
initialScale: widget.initialScale,
|
||||
basePosition: widget.basePosition,
|
||||
scaleStateCycle: widget.scaleStateCycle,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
outerSize: computedOuterSize,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode,
|
||||
filterQuality: widget.filterQuality,
|
||||
disableGestures: widget.disableGestures,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
child: widget.child,
|
||||
)
|
||||
: ImageWrapper(
|
||||
imageProvider: widget.imageProvider!,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
controller: _controller,
|
||||
scaleStateController: _scaleStateController,
|
||||
maxScale: widget.maxScale,
|
||||
minScale: widget.minScale,
|
||||
initialScale: widget.initialScale,
|
||||
basePosition: widget.basePosition,
|
||||
scaleStateCycle: widget.scaleStateCycle,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
outerSize: computedOuterSize,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode,
|
||||
filterQuality: widget.filterQuality,
|
||||
disableGestures: widget.disableGestures,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => widget.wantKeepAlive;
|
||||
}
|
||||
|
||||
/// The default [ScaleStateCycle]
|
||||
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) {
|
||||
switch (actual) {
|
||||
case PhotoViewScaleState.initial:
|
||||
return PhotoViewScaleState.covering;
|
||||
case PhotoViewScaleState.covering:
|
||||
return PhotoViewScaleState.originalSize;
|
||||
case PhotoViewScaleState.originalSize:
|
||||
return PhotoViewScaleState.initial;
|
||||
case PhotoViewScaleState.zoomedIn:
|
||||
case PhotoViewScaleState.zoomedOut:
|
||||
return PhotoViewScaleState.initial;
|
||||
default:
|
||||
return PhotoViewScaleState.initial;
|
||||
}
|
||||
}
|
||||
|
||||
/// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one
|
||||
/// It is used internally to walk in the "doubletap gesture cycle".
|
||||
/// It is passed to [PhotoView.scaleStateCycle]
|
||||
typedef ScaleStateCycle = PhotoViewScaleState Function(
|
||||
PhotoViewScaleState actual,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps up the photoview region
|
||||
typedef PhotoViewImageTapUpCallback = Function(
|
||||
BuildContext context,
|
||||
TapUpDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps down the photoview region
|
||||
typedef PhotoViewImageTapDownCallback = Function(
|
||||
BuildContext context,
|
||||
TapDownDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user drags up
|
||||
typedef PhotoViewImageDragStartCallback = Function(
|
||||
BuildContext context,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user drags
|
||||
typedef PhotoViewImageDragUpdateCallback = Function(
|
||||
BuildContext context,
|
||||
DragUpdateDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps down the photoview region
|
||||
typedef PhotoViewImageDragEndCallback = Function(
|
||||
BuildContext context,
|
||||
DragEndDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when a user finished scale
|
||||
typedef PhotoViewImageScaleEndCallback = Function(
|
||||
BuildContext context,
|
||||
ScaleEndDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback to show a widget while the image is loading, a [ImageChunkEvent] is passed to inform progress
|
||||
typedef LoadingBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ImageChunkEvent? event,
|
||||
);
|
446
mobile/lib/shared/ui/photo_view/photo_view_gallery.dart
Normal file
446
mobile/lib/shared/ui/photo_view/photo_view_gallery.dart
Normal file
|
@ -0,0 +1,446 @@
|
|||
library photo_view_gallery;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
|
||||
show
|
||||
LoadingBuilder,
|
||||
PhotoView,
|
||||
PhotoViewImageTapDownCallback,
|
||||
PhotoViewImageTapUpCallback,
|
||||
PhotoViewImageDragStartCallback,
|
||||
PhotoViewImageDragEndCallback,
|
||||
PhotoViewImageDragUpdateCallback,
|
||||
PhotoViewImageScaleEndCallback,
|
||||
ScaleStateCycle;
|
||||
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/shared/ui/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);
|
||||
|
||||
/// 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({
|
||||
Key? key,
|
||||
required this.pageOptions,
|
||||
this.loadingBuilder,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.reverse = false,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.scrollPhysics,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.customSize,
|
||||
this.allowImplicitScrolling = false,
|
||||
}) : itemCount = null,
|
||||
builder = null,
|
||||
super(key: key);
|
||||
|
||||
/// Construct a gallery with dynamic items.
|
||||
///
|
||||
/// The builder must return a [PhotoViewGalleryPageOptions].
|
||||
const PhotoViewGallery.builder({
|
||||
Key? 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.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.scrollPhysics,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.customSize,
|
||||
this.allowImplicitScrolling = false,
|
||||
}) : pageOptions = null,
|
||||
assert(itemCount != null),
|
||||
assert(builder != null),
|
||||
super(key: key);
|
||||
|
||||
/// 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.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.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();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Enable corner hit test
|
||||
return PhotoViewGestureDetectorScope(
|
||||
axis: widget.scrollDirection,
|
||||
child: PageView.builder(
|
||||
reverse: widget.reverse,
|
||||
controller: _controller,
|
||||
onPageChanged: widget.onPageChanged,
|
||||
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: ObjectKey(index),
|
||||
childSize: pageOption.childSize,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
wantKeepAlive: widget.wantKeepAlive,
|
||||
controller: pageOption.controller,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
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,
|
||||
onScaleEnd: pageOption.onScaleEnd,
|
||||
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
||||
tightMode: pageOption.tightMode,
|
||||
filterQuality: pageOption.filterQuality,
|
||||
basePosition: pageOption.basePosition,
|
||||
disableGestures: pageOption.disableGestures,
|
||||
child: pageOption.child,
|
||||
)
|
||||
: PhotoView(
|
||||
key: ObjectKey(index),
|
||||
imageProvider: pageOption.imageProvider,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
wantKeepAlive: widget.wantKeepAlive,
|
||||
controller: pageOption.controller,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
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,
|
||||
onScaleEnd: pageOption.onScaleEnd,
|
||||
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
||||
tightMode: pageOption.tightMode,
|
||||
filterQuality: pageOption.filterQuality,
|
||||
basePosition: pageOption.basePosition,
|
||||
disableGestures: pageOption.disableGestures,
|
||||
errorBuilder: pageOption.errorBuilder,
|
||||
);
|
||||
|
||||
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({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
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.onScaleEnd,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.errorBuilder,
|
||||
}) : child = null,
|
||||
childSize = null,
|
||||
assert(imageProvider != null);
|
||||
|
||||
PhotoViewGalleryPageOptions.customChild({
|
||||
required this.child,
|
||||
this.childSize,
|
||||
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.onScaleEnd,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
}) : errorBuilder = null,
|
||||
imageProvider = null;
|
||||
|
||||
/// Mirror to [PhotoView.imageProvider]
|
||||
final ImageProvider? imageProvider;
|
||||
|
||||
/// Mirror to [PhotoView.heroAttributes]
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
|
||||
/// 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.onDraUpdate]
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
/// Mirror to [PhotoView.onTapDown]
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
|
||||
/// Mirror to [PhotoView.onScaleEnd]
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
/// Mirror to [PhotoView.gestureDetectorBehavior]
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
|
||||
/// Mirror to [PhotoView.tightMode]
|
||||
final bool? tightMode;
|
||||
|
||||
/// Mirror to [PhotoView.disableGestures]
|
||||
final bool? disableGestures;
|
||||
|
||||
/// Quality levels for image filters.
|
||||
final FilterQuality? filterQuality;
|
||||
|
||||
/// Mirror to [PhotoView.errorBuilder]
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
}
|
|
@ -0,0 +1,291 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
|
||||
/// The interface in which controllers will be implemented.
|
||||
///
|
||||
/// It concerns storing the state ([PhotoViewControllerValue]) and streaming its updates.
|
||||
/// [PhotoViewImageWrapper] will respond to user gestures setting thew fields in the instance of a controller.
|
||||
///
|
||||
/// Any instance of a controller must be disposed after unmount. So if you instantiate a [PhotoViewController] or your custom implementation, do not forget to dispose it when not using it anymore.
|
||||
///
|
||||
/// The controller exposes value fields like [scale] or [rotationFocus]. Usually those fields will be only getters and setters serving as hooks to the internal [PhotoViewControllerValue].
|
||||
///
|
||||
/// The default implementation used by [PhotoView] is [PhotoViewController].
|
||||
///
|
||||
/// This was created to allow customization (you can create your own controller class)
|
||||
///
|
||||
/// Previously it controlled `scaleState` as well, but duw to some [concerns](https://github.com/renancaraujo/photo_view/issues/127)
|
||||
/// [ScaleStateListener is responsible for tat value now
|
||||
///
|
||||
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
|
||||
/// The output for state/value updates. Usually a broadcast [Stream]
|
||||
Stream<T> get outputStateStream;
|
||||
|
||||
/// The state value before the last change or the initial state if the state has not been changed.
|
||||
late T prevValue;
|
||||
|
||||
/// The actual state value
|
||||
late T value;
|
||||
|
||||
/// Resets the state to the initial value;
|
||||
void reset();
|
||||
|
||||
/// Closes streams and removes eventual listeners.
|
||||
void dispose();
|
||||
|
||||
/// 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
|
||||
/// listener. Prefer [outputStateStream]
|
||||
void addIgnorableListener(VoidCallback callback);
|
||||
|
||||
/// Remove a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputStateStream]
|
||||
void removeIgnorableListener(VoidCallback callback);
|
||||
|
||||
/// The position of the image in the screen given its offset after pan gestures.
|
||||
late Offset position;
|
||||
|
||||
/// The scale factor to transform the child (image or a customChild).
|
||||
late double? scale;
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
void setScaleInvisibly(double? scale);
|
||||
|
||||
/// The rotation factor to transform the child (image or a customChild).
|
||||
late double rotation;
|
||||
|
||||
/// The center of the rotation transformation. It is a coordinate referring to the absolute dimensions of the image.
|
||||
Offset? rotationFocusPoint;
|
||||
|
||||
/// Update multiple fields of the state with only one update streamed.
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
});
|
||||
}
|
||||
|
||||
/// The state value stored and streamed by [PhotoViewController].
|
||||
@immutable
|
||||
class PhotoViewControllerValue {
|
||||
const PhotoViewControllerValue({
|
||||
required this.position,
|
||||
required this.scale,
|
||||
required this.rotation,
|
||||
required this.rotationFocusPoint,
|
||||
});
|
||||
|
||||
final Offset position;
|
||||
final double? scale;
|
||||
final double rotation;
|
||||
final Offset? rotationFocusPoint;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PhotoViewControllerValue &&
|
||||
runtimeType == other.runtimeType &&
|
||||
position == other.position &&
|
||||
scale == other.scale &&
|
||||
rotation == other.rotation &&
|
||||
rotationFocusPoint == other.rotationFocusPoint;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
position.hashCode ^
|
||||
scale.hashCode ^
|
||||
rotation.hashCode ^
|
||||
rotationFocusPoint.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PhotoViewControllerValue{position: $position, scale: $scale, rotation: $rotation, rotationFocusPoint: $rotationFocusPoint}';
|
||||
}
|
||||
}
|
||||
|
||||
/// The default implementation of [PhotoViewControllerBase].
|
||||
///
|
||||
/// Containing a [ValueNotifier] it stores the state in the [value] field and streams
|
||||
/// updates via [outputStateStream].
|
||||
///
|
||||
/// For details of fields and methods, check [PhotoViewControllerBase].
|
||||
///
|
||||
class PhotoViewController
|
||||
implements PhotoViewControllerBase<PhotoViewControllerValue> {
|
||||
PhotoViewController({
|
||||
Offset initialPosition = Offset.zero,
|
||||
double initialRotation = 0.0,
|
||||
double? initialScale,
|
||||
}) : _valueNotifier = IgnorableValueNotifier(
|
||||
PhotoViewControllerValue(
|
||||
position: initialPosition,
|
||||
rotation: initialRotation,
|
||||
scale: initialScale,
|
||||
rotationFocusPoint: null,
|
||||
),
|
||||
),
|
||||
super() {
|
||||
initial = value;
|
||||
prevValue = initial;
|
||||
|
||||
_valueNotifier.addListener(_changeListener);
|
||||
_outputCtrl = StreamController<PhotoViewControllerValue>.broadcast();
|
||||
_outputCtrl.sink.add(initial);
|
||||
}
|
||||
|
||||
final IgnorableValueNotifier<PhotoViewControllerValue> _valueNotifier;
|
||||
|
||||
late PhotoViewControllerValue initial;
|
||||
|
||||
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
||||
|
||||
@override
|
||||
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
|
||||
|
||||
@override
|
||||
late PhotoViewControllerValue prevValue;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
value = initial;
|
||||
}
|
||||
|
||||
void _changeListener() {
|
||||
_outputCtrl.sink.add(value);
|
||||
}
|
||||
|
||||
@override
|
||||
void addIgnorableListener(VoidCallback callback) {
|
||||
_valueNotifier.addIgnorableListener(callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeIgnorableListener(VoidCallback callback) {
|
||||
_valueNotifier.removeIgnorableListener(callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_outputCtrl.close();
|
||||
_valueNotifier.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
set position(Offset position) {
|
||||
if (value.position == position) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset get position => value.position;
|
||||
|
||||
@override
|
||||
set scale(double? scale) {
|
||||
if (value.scale == scale) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double? get scale => value.scale;
|
||||
|
||||
@override
|
||||
void setScaleInvisibly(double? scale) {
|
||||
if (value.scale == scale) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
_valueNotifier.updateIgnoring(
|
||||
PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
set rotation(double rotation) {
|
||||
if (value.rotation == rotation) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double get rotation => value.rotation;
|
||||
|
||||
@override
|
||||
set rotationFocusPoint(Offset? rotationFocusPoint) {
|
||||
if (value.rotationFocusPoint == rotationFocusPoint) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset? get rotationFocusPoint => value.rotationFocusPoint;
|
||||
|
||||
@override
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
}) {
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position ?? value.position,
|
||||
scale: scale ?? value.scale,
|
||||
rotation: rotation ?? value.rotation,
|
||||
rotationFocusPoint: rotationFocusPoint ?? value.rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
PhotoViewControllerValue get value => _valueNotifier.value;
|
||||
|
||||
@override
|
||||
set value(PhotoViewControllerValue newValue) {
|
||||
if (_valueNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
_valueNotifier.value = newValue;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,214 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
|
||||
show
|
||||
PhotoViewControllerBase,
|
||||
PhotoViewScaleState,
|
||||
PhotoViewScaleStateController,
|
||||
ScaleStateCycle;
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_core.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
/// A class to hold internal layout logic to sync both controller states
|
||||
///
|
||||
/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers.
|
||||
mixin PhotoViewControllerDelegate on State<PhotoViewCore> {
|
||||
PhotoViewControllerBase get controller => widget.controller;
|
||||
|
||||
PhotoViewScaleStateController get scaleStateController =>
|
||||
widget.scaleStateController;
|
||||
|
||||
ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries;
|
||||
|
||||
ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle;
|
||||
|
||||
Alignment get basePosition => widget.basePosition;
|
||||
Function(double prevScale, double nextScale)? _animateScale;
|
||||
|
||||
/// Mark if scale need recalculation, useful for scale boundaries changes.
|
||||
bool markNeedsScaleRecalc = true;
|
||||
|
||||
void initDelegate() {
|
||||
controller.addIgnorableListener(_blindScaleListener);
|
||||
scaleStateController.addIgnorableListener(_blindScaleStateListener);
|
||||
}
|
||||
|
||||
void _blindScaleStateListener() {
|
||||
if (!scaleStateController.hasChanged) {
|
||||
return;
|
||||
}
|
||||
if (_animateScale == null || scaleStateController.isZooming) {
|
||||
controller.setScaleInvisibly(scale);
|
||||
return;
|
||||
}
|
||||
final double prevScale = controller.scale ??
|
||||
getScaleForScaleState(
|
||||
scaleStateController.prevScaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
final double nextScale = getScaleForScaleState(
|
||||
scaleStateController.scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
_animateScale!(prevScale, nextScale);
|
||||
}
|
||||
|
||||
void addAnimateOnScaleStateUpdate(
|
||||
void Function(double prevScale, double nextScale) animateScale,
|
||||
) {
|
||||
_animateScale = animateScale;
|
||||
}
|
||||
|
||||
void _blindScaleListener() {
|
||||
if (!widget.enablePanAlways) {
|
||||
controller.position = clampPosition();
|
||||
}
|
||||
if (controller.scale == controller.prevValue.scale) {
|
||||
return;
|
||||
}
|
||||
final PhotoViewScaleState newScaleState =
|
||||
(scale > scaleBoundaries.initialScale)
|
||||
? PhotoViewScaleState.zoomedIn
|
||||
: PhotoViewScaleState.zoomedOut;
|
||||
|
||||
scaleStateController.setInvisibly(newScaleState);
|
||||
}
|
||||
|
||||
Offset get position => controller.position;
|
||||
|
||||
double get scale {
|
||||
// for figuring out initial scale
|
||||
final needsRecalc = markNeedsScaleRecalc &&
|
||||
!scaleStateController.scaleState.isScaleStateZooming;
|
||||
|
||||
final scaleExistsOnController = controller.scale != null;
|
||||
if (needsRecalc || !scaleExistsOnController) {
|
||||
final newScale = getScaleForScaleState(
|
||||
scaleStateController.scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
markNeedsScaleRecalc = false;
|
||||
scale = newScale;
|
||||
return newScale;
|
||||
}
|
||||
return controller.scale!;
|
||||
}
|
||||
|
||||
set scale(double scale) => controller.setScaleInvisibly(scale);
|
||||
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
}) {
|
||||
controller.updateMultiple(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
void updateScaleStateFromNewScale(double newScale) {
|
||||
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
|
||||
if (scale != scaleBoundaries.initialScale) {
|
||||
newScaleState = (newScale > scaleBoundaries.initialScale)
|
||||
? PhotoViewScaleState.zoomedIn
|
||||
: PhotoViewScaleState.zoomedOut;
|
||||
}
|
||||
scaleStateController.setInvisibly(newScaleState);
|
||||
}
|
||||
|
||||
void nextScaleState() {
|
||||
final PhotoViewScaleState scaleState = scaleStateController.scaleState;
|
||||
if (scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.zoomedOut) {
|
||||
scaleStateController.scaleState = scaleStateCycle(scaleState);
|
||||
return;
|
||||
}
|
||||
final double originalScale = getScaleForScaleState(
|
||||
scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
double prevScale = originalScale;
|
||||
PhotoViewScaleState prevScaleState = scaleState;
|
||||
double nextScale = originalScale;
|
||||
PhotoViewScaleState nextScaleState = scaleState;
|
||||
|
||||
do {
|
||||
prevScale = nextScale;
|
||||
prevScaleState = nextScaleState;
|
||||
nextScaleState = scaleStateCycle(prevScaleState);
|
||||
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
|
||||
} while (prevScale == nextScale && scaleState != nextScaleState);
|
||||
|
||||
if (originalScale == nextScale) {
|
||||
return;
|
||||
}
|
||||
scaleStateController.scaleState = nextScaleState;
|
||||
}
|
||||
|
||||
CornersRange cornersX({double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
|
||||
final double computedWidth = scaleBoundaries.childSize.width * s;
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
|
||||
final double positionX = basePosition.x;
|
||||
final double widthDiff = computedWidth - screenWidth;
|
||||
|
||||
final double minX = ((positionX - 1).abs() / 2) * widthDiff * -1;
|
||||
final double maxX = ((positionX + 1).abs() / 2) * widthDiff;
|
||||
return CornersRange(minX, maxX);
|
||||
}
|
||||
|
||||
CornersRange cornersY({double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
|
||||
final double computedHeight = scaleBoundaries.childSize.height * s;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
|
||||
final double positionY = basePosition.y;
|
||||
final double heightDiff = computedHeight - screenHeight;
|
||||
|
||||
final double minY = ((positionY - 1).abs() / 2) * heightDiff * -1;
|
||||
final double maxY = ((positionY + 1).abs() / 2) * heightDiff;
|
||||
return CornersRange(minY, maxY);
|
||||
}
|
||||
|
||||
Offset clampPosition({Offset? position, double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
final Offset p = position ?? this.position;
|
||||
|
||||
final double computedWidth = scaleBoundaries.childSize.width * s;
|
||||
final double computedHeight = scaleBoundaries.childSize.height * s;
|
||||
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
|
||||
double finalX = 0.0;
|
||||
if (screenWidth < computedWidth) {
|
||||
final cornersX = this.cornersX(scale: s);
|
||||
finalX = p.dx.clamp(cornersX.min, cornersX.max);
|
||||
}
|
||||
|
||||
double finalY = 0.0;
|
||||
if (screenHeight < computedHeight) {
|
||||
final cornersY = this.cornersY(scale: s);
|
||||
finalY = p.dy.clamp(cornersY.min, cornersY.max);
|
||||
}
|
||||
|
||||
return Offset(finalX, finalY);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animateScale = null;
|
||||
controller.removeIgnorableListener(_blindScaleListener);
|
||||
scaleStateController.removeIgnorableListener(_blindScaleStateListener);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/widgets.dart' show VoidCallback;
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
|
||||
typedef ScaleStateListener = void Function(double prevScale, double nextScale);
|
||||
|
||||
/// A controller responsible only by [scaleState].
|
||||
///
|
||||
/// Scale state is a common value with represents the step in which the [PhotoView.scaleStateCycle] is.
|
||||
/// This cycle is triggered by the "doubleTap" gesture.
|
||||
///
|
||||
/// Any change in its [scaleState] should animate the scale of image/content.
|
||||
///
|
||||
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
/// The updates should be done via [scaleState] setter and the updated listened via [outputScaleStateStream]
|
||||
///
|
||||
class PhotoViewScaleStateController {
|
||||
late final IgnorableValueNotifier<PhotoViewScaleState> _scaleStateNotifier =
|
||||
IgnorableValueNotifier(PhotoViewScaleState.initial)
|
||||
..addListener(_scaleStateChangeListener);
|
||||
final StreamController<PhotoViewScaleState> _outputScaleStateCtrl =
|
||||
StreamController<PhotoViewScaleState>.broadcast()
|
||||
..sink.add(PhotoViewScaleState.initial);
|
||||
|
||||
/// The output for state/value updates
|
||||
Stream<PhotoViewScaleState> get outputScaleStateStream =>
|
||||
_outputScaleStateCtrl.stream;
|
||||
|
||||
/// The state value before the last change or the initial state if the state has not been changed.
|
||||
PhotoViewScaleState prevScaleState = PhotoViewScaleState.initial;
|
||||
|
||||
/// The actual state value
|
||||
PhotoViewScaleState get scaleState => _scaleStateNotifier.value;
|
||||
|
||||
/// Updates scaleState and notify all listeners (and the stream)
|
||||
set scaleState(PhotoViewScaleState newValue) {
|
||||
if (_scaleStateNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevScaleState = _scaleStateNotifier.value;
|
||||
_scaleStateNotifier.value = newValue;
|
||||
}
|
||||
|
||||
/// Checks if its actual value is different than previousValue
|
||||
bool get hasChanged => prevScaleState != scaleState;
|
||||
|
||||
/// Check if is `zoomedIn` & `zoomedOut`
|
||||
bool get isZooming =>
|
||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.zoomedOut;
|
||||
|
||||
/// Resets the state to the initial value;
|
||||
void reset() {
|
||||
prevScaleState = scaleState;
|
||||
scaleState = PhotoViewScaleState.initial;
|
||||
}
|
||||
|
||||
/// Closes streams and removes eventual listeners
|
||||
void dispose() {
|
||||
_outputScaleStateCtrl.close();
|
||||
_scaleStateNotifier.dispose();
|
||||
}
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
/// Seriously: It is used to change scale state without trigging updates on the []
|
||||
void setInvisibly(PhotoViewScaleState newValue) {
|
||||
if (_scaleStateNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
prevScaleState = _scaleStateNotifier.value;
|
||||
_scaleStateNotifier.updateIgnoring(newValue);
|
||||
}
|
||||
|
||||
void _scaleStateChangeListener() {
|
||||
_outputScaleStateCtrl.sink.add(scaleState);
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// listener. Prefer [outputScaleStateStream]
|
||||
void addIgnorableListener(VoidCallback callback) {
|
||||
_scaleStateNotifier.addIgnorableListener(callback);
|
||||
}
|
||||
|
||||
/// Remove a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputScaleStateStream]
|
||||
void removeIgnorableListener(VoidCallback callback) {
|
||||
_scaleStateNotifier.removeIgnorableListener(callback);
|
||||
}
|
||||
}
|
461
mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart
Normal file
461
mobile/lib/shared/ui/photo_view/src/core/photo_view_core.dart
Normal file
|
@ -0,0 +1,461 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/photo_view.dart'
|
||||
show
|
||||
PhotoViewScaleState,
|
||||
PhotoViewHeroAttributes,
|
||||
PhotoViewImageTapDownCallback,
|
||||
PhotoViewImageTapUpCallback,
|
||||
PhotoViewImageScaleEndCallback,
|
||||
PhotoViewImageDragEndCallback,
|
||||
PhotoViewImageDragStartCallback,
|
||||
PhotoViewImageDragUpdateCallback,
|
||||
ScaleStateCycle;
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_gesture_detector.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/core/photo_view_hit_corners.dart';
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
const _defaultDecoration = BoxDecoration(
|
||||
color: Color.fromRGBO(0, 0, 0, 1.0),
|
||||
);
|
||||
|
||||
/// Internal widget in which controls all animations lifecycle, core responses
|
||||
/// to user gestures, updates to the controller state and mounts the entire PhotoView Layout
|
||||
class PhotoViewCore extends StatefulWidget {
|
||||
const PhotoViewCore({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
required this.backgroundDecoration,
|
||||
required this.gaplessPlayback,
|
||||
required this.heroAttributes,
|
||||
required this.enableRotation,
|
||||
required this.onTapUp,
|
||||
required this.onTapDown,
|
||||
required this.onDragStart,
|
||||
required this.onDragEnd,
|
||||
required this.onDragUpdate,
|
||||
required this.onScaleEnd,
|
||||
required this.gestureDetectorBehavior,
|
||||
required this.controller,
|
||||
required this.scaleBoundaries,
|
||||
required this.scaleStateCycle,
|
||||
required this.scaleStateController,
|
||||
required this.basePosition,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : customChild = null,
|
||||
super(key: key);
|
||||
|
||||
const PhotoViewCore.customChild({
|
||||
Key? key,
|
||||
required this.customChild,
|
||||
required this.backgroundDecoration,
|
||||
this.heroAttributes,
|
||||
required this.enableRotation,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.gestureDetectorBehavior,
|
||||
required this.controller,
|
||||
required this.scaleBoundaries,
|
||||
required this.scaleStateCycle,
|
||||
required this.scaleStateController,
|
||||
required this.basePosition,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : imageProvider = null,
|
||||
gaplessPlayback = false,
|
||||
super(key: key);
|
||||
|
||||
final Decoration? backgroundDecoration;
|
||||
final ImageProvider? imageProvider;
|
||||
final bool? gaplessPlayback;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final bool enableRotation;
|
||||
final Widget? customChild;
|
||||
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
final ScaleBoundaries scaleBoundaries;
|
||||
final ScaleStateCycle scaleStateCycle;
|
||||
final Alignment basePosition;
|
||||
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool tightMode;
|
||||
final bool disableGestures;
|
||||
final bool enablePanAlways;
|
||||
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return PhotoViewCoreState();
|
||||
}
|
||||
|
||||
bool get hasCustomChild => customChild != null;
|
||||
}
|
||||
|
||||
class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
with
|
||||
TickerProviderStateMixin,
|
||||
PhotoViewControllerDelegate,
|
||||
HitCornersDetector {
|
||||
Offset? _normalizedPosition;
|
||||
double? _scaleBefore;
|
||||
double? _rotationBefore;
|
||||
|
||||
late final AnimationController _scaleAnimationController;
|
||||
Animation<double>? _scaleAnimation;
|
||||
|
||||
late final AnimationController _positionAnimationController;
|
||||
Animation<Offset>? _positionAnimation;
|
||||
|
||||
late final AnimationController _rotationAnimationController =
|
||||
AnimationController(vsync: this)..addListener(handleRotationAnimation);
|
||||
Animation<double>? _rotationAnimation;
|
||||
|
||||
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
|
||||
|
||||
late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
|
||||
void handleScaleAnimation() {
|
||||
scale = _scaleAnimation!.value;
|
||||
}
|
||||
|
||||
void handlePositionAnimate() {
|
||||
controller.position = _positionAnimation!.value;
|
||||
}
|
||||
|
||||
void handleRotationAnimation() {
|
||||
controller.rotation = _rotationAnimation!.value;
|
||||
}
|
||||
|
||||
void onScaleStart(ScaleStartDetails details) {
|
||||
_rotationBefore = controller.rotation;
|
||||
_scaleBefore = scale;
|
||||
_normalizedPosition = details.focalPoint - controller.position;
|
||||
_scaleAnimationController.stop();
|
||||
_positionAnimationController.stop();
|
||||
_rotationAnimationController.stop();
|
||||
}
|
||||
|
||||
void onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final double newScale = _scaleBefore! * details.scale;
|
||||
final Offset delta = details.focalPoint - _normalizedPosition!;
|
||||
|
||||
updateScaleStateFromNewScale(newScale);
|
||||
|
||||
updateMultiple(
|
||||
scale: newScale,
|
||||
position: widget.enablePanAlways
|
||||
? delta
|
||||
: clampPosition(position: delta * details.scale),
|
||||
rotation:
|
||||
widget.enableRotation ? _rotationBefore! + details.rotation : null,
|
||||
rotationFocusPoint: widget.enableRotation ? details.focalPoint : null,
|
||||
);
|
||||
}
|
||||
|
||||
void onScaleEnd(ScaleEndDetails details) {
|
||||
final double s = scale;
|
||||
final Offset p = controller.position;
|
||||
final double maxScale = scaleBoundaries.maxScale;
|
||||
final double minScale = scaleBoundaries.minScale;
|
||||
|
||||
widget.onScaleEnd?.call(context, details, controller.value);
|
||||
|
||||
//animate back to maxScale if gesture exceeded the maxScale specified
|
||||
if (s > maxScale) {
|
||||
final double scaleComebackRatio = maxScale / s;
|
||||
animateScale(s, maxScale);
|
||||
final Offset clampedPosition = clampPosition(
|
||||
position: p * scaleComebackRatio,
|
||||
scale: maxScale,
|
||||
);
|
||||
animatePosition(p, clampedPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
//animate back to minScale if gesture fell smaller than the minScale specified
|
||||
if (s < minScale) {
|
||||
final double scaleComebackRatio = minScale / s;
|
||||
animateScale(s, minScale);
|
||||
animatePosition(
|
||||
p,
|
||||
clampPosition(
|
||||
position: p * scaleComebackRatio,
|
||||
scale: minScale,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// get magnitude from gesture velocity
|
||||
final double magnitude = details.velocity.pixelsPerSecond.distance;
|
||||
|
||||
// animate velocity only if there is no scale change and a significant magnitude
|
||||
if (_scaleBefore! / s == 1.0 && magnitude >= 400.0) {
|
||||
final Offset direction = details.velocity.pixelsPerSecond / magnitude;
|
||||
animatePosition(
|
||||
p,
|
||||
clampPosition(position: p + direction * 100.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onDoubleTap() {
|
||||
nextScaleState();
|
||||
}
|
||||
|
||||
void animateScale(double from, double to) {
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: from,
|
||||
end: to,
|
||||
).animate(_scaleAnimationController);
|
||||
_scaleAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void animatePosition(Offset from, Offset to) {
|
||||
_positionAnimation = Tween<Offset>(begin: from, end: to)
|
||||
.animate(_positionAnimationController);
|
||||
_positionAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void animateRotation(double from, double to) {
|
||||
_rotationAnimation = Tween<double>(begin: from, end: to)
|
||||
.animate(_rotationAnimationController);
|
||||
_rotationAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void onAnimationStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
onAnimationStatusCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if scale is equal to initial after scale animation update
|
||||
void onAnimationStatusCompleted() {
|
||||
if (scaleStateController.scaleState != PhotoViewScaleState.initial &&
|
||||
scale == scaleBoundaries.initialScale) {
|
||||
scaleStateController.setInvisibly(PhotoViewScaleState.initial);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initDelegate();
|
||||
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
|
||||
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
|
||||
_scaleAnimationController = AnimationController(vsync: this)
|
||||
..addListener(handleScaleAnimation)
|
||||
..addStatusListener(onAnimationStatus);
|
||||
_positionAnimationController = AnimationController(vsync: this)
|
||||
..addListener(handlePositionAnimate);
|
||||
}
|
||||
|
||||
void animateOnScaleStateUpdate(double prevScale, double nextScale) {
|
||||
animateScale(prevScale, nextScale);
|
||||
animatePosition(controller.position, Offset.zero);
|
||||
animateRotation(controller.rotation, 0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleAnimationController.removeStatusListener(onAnimationStatus);
|
||||
_scaleAnimationController.dispose();
|
||||
_positionAnimationController.dispose();
|
||||
_rotationAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onTapUp(TapUpDetails details) {
|
||||
widget.onTapUp?.call(context, details, controller.value);
|
||||
}
|
||||
|
||||
void onTapDown(TapDownDetails details) {
|
||||
widget.onTapDown?.call(context, details, controller.value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if we need a recalc on the scale
|
||||
if (widget.scaleBoundaries != cachedScaleBoundaries) {
|
||||
markNeedsScaleRecalc = true;
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
}
|
||||
|
||||
return StreamBuilder(
|
||||
stream: controller.outputStateStream,
|
||||
initialData: controller.prevValue,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
AsyncSnapshot<PhotoViewControllerValue> snapshot,
|
||||
) {
|
||||
if (snapshot.hasData) {
|
||||
final PhotoViewControllerValue value = snapshot.data!;
|
||||
final useImageScale = widget.filterQuality != FilterQuality.none;
|
||||
|
||||
final computedScale = useImageScale ? 1.0 : scale;
|
||||
|
||||
final matrix = Matrix4.identity()
|
||||
..translate(value.position.dx, value.position.dy)
|
||||
..scale(computedScale)
|
||||
..rotateZ(value.rotation);
|
||||
|
||||
final Widget customChildLayout = CustomSingleChildLayout(
|
||||
delegate: _CenterWithOriginalSizeDelegate(
|
||||
scaleBoundaries.childSize,
|
||||
basePosition,
|
||||
useImageScale,
|
||||
),
|
||||
child: _buildHero(),
|
||||
);
|
||||
|
||||
final child = Container(
|
||||
constraints: widget.tightMode
|
||||
? BoxConstraints.tight(scaleBoundaries.childSize * scale)
|
||||
: null,
|
||||
decoration: widget.backgroundDecoration ?? _defaultDecoration,
|
||||
child: Center(
|
||||
child: Transform(
|
||||
transform: matrix,
|
||||
alignment: basePosition,
|
||||
child: customChildLayout,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.disableGestures) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return PhotoViewGestureDetector(
|
||||
onDoubleTap: nextScaleState,
|
||||
onScaleStart: onScaleStart,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
onDragStart: widget.onDragStart != null
|
||||
? (details) => widget.onDragStart!(context, details, value)
|
||||
: null,
|
||||
onDragEnd: widget.onDragEnd != null
|
||||
? (details) => widget.onDragEnd!(context, details, value)
|
||||
: null,
|
||||
onDragUpdate: widget.onDragUpdate != null
|
||||
? (details) => widget.onDragUpdate!(context, details, value)
|
||||
: null,
|
||||
hitDetector: this,
|
||||
onTapUp: widget.onTapUp != null
|
||||
? (details) => widget.onTapUp!(context, details, value)
|
||||
: null,
|
||||
onTapDown: widget.onTapDown != null
|
||||
? (details) => widget.onTapDown!(context, details, value)
|
||||
: null,
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHero() {
|
||||
return heroAttributes != null
|
||||
? Hero(
|
||||
tag: heroAttributes!.tag,
|
||||
createRectTween: heroAttributes!.createRectTween,
|
||||
flightShuttleBuilder: heroAttributes!.flightShuttleBuilder,
|
||||
placeholderBuilder: heroAttributes!.placeholderBuilder,
|
||||
transitionOnUserGestures: heroAttributes!.transitionOnUserGestures,
|
||||
child: _buildChild(),
|
||||
)
|
||||
: _buildChild();
|
||||
}
|
||||
|
||||
Widget _buildChild() {
|
||||
return widget.hasCustomChild
|
||||
? widget.customChild!
|
||||
: Image(
|
||||
image: widget.imageProvider!,
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
|
||||
const _CenterWithOriginalSizeDelegate(
|
||||
this.subjectSize,
|
||||
this.basePosition,
|
||||
this.useImageScale,
|
||||
);
|
||||
|
||||
final Size subjectSize;
|
||||
final Alignment basePosition;
|
||||
final bool useImageScale;
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final childWidth = useImageScale ? childSize.width : subjectSize.width;
|
||||
final childHeight = useImageScale ? childSize.height : subjectSize.height;
|
||||
|
||||
final halfWidth = (size.width - childWidth) / 2;
|
||||
final halfHeight = (size.height - childHeight) / 2;
|
||||
|
||||
final double offsetX = halfWidth * (basePosition.x + 1);
|
||||
final double offsetY = halfHeight * (basePosition.y + 1);
|
||||
return Offset(offsetX, offsetY);
|
||||
}
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
return useImageScale
|
||||
? const BoxConstraints()
|
||||
: BoxConstraints.tight(subjectSize);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
|
||||
return oldDelegate != this;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is _CenterWithOriginalSizeDelegate &&
|
||||
runtimeType == other.runtimeType &&
|
||||
subjectSize == other.subjectSize &&
|
||||
basePosition == other.basePosition &&
|
||||
useImageScale == other.useImageScale;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
subjectSize.hashCode ^ basePosition.hashCode ^ useImageScale.hashCode;
|
||||
}
|
|
@ -0,0 +1,293 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'photo_view_hit_corners.dart';
|
||||
|
||||
/// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c)
|
||||
/// for the gist
|
||||
class PhotoViewGestureDetector extends StatelessWidget {
|
||||
const PhotoViewGestureDetector({
|
||||
Key? key,
|
||||
this.hitDetector,
|
||||
this.onScaleStart,
|
||||
this.onScaleUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onDoubleTap,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.child,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.behavior,
|
||||
}) : super(key: key);
|
||||
|
||||
final GestureDoubleTapCallback? onDoubleTap;
|
||||
final HitCornersDetector? hitDetector;
|
||||
|
||||
final GestureScaleStartCallback? onScaleStart;
|
||||
final GestureScaleUpdateCallback? onScaleUpdate;
|
||||
final GestureScaleEndCallback? onScaleEnd;
|
||||
|
||||
final GestureDragEndCallback? onDragEnd;
|
||||
final GestureDragStartCallback? onDragStart;
|
||||
final GestureDragUpdateCallback? onDragUpdate;
|
||||
|
||||
final GestureTapUpCallback? onTapUp;
|
||||
final GestureTapDownCallback? onTapDown;
|
||||
|
||||
final Widget? child;
|
||||
|
||||
final HitTestBehavior? behavior;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = PhotoViewGestureDetectorScope.of(context);
|
||||
|
||||
final Axis? axis = scope?.axis;
|
||||
final touchSlopFactor = scope?.touchSlopFactor ?? 2;
|
||||
|
||||
final Map<Type, GestureRecognizerFactory> gestures =
|
||||
<Type, GestureRecognizerFactory>{};
|
||||
|
||||
if (onTapDown != null || onTapUp != null) {
|
||||
gestures[TapGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(debugOwner: this),
|
||||
(TapGestureRecognizer instance) {
|
||||
instance
|
||||
..onTapDown = onTapDown
|
||||
..onTapUp = onTapUp;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (onDragStart != null || onDragEnd != null || onDragUpdate != null) {
|
||||
gestures[VerticalDragGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
|
||||
() => VerticalDragGestureRecognizer(debugOwner: this),
|
||||
(VerticalDragGestureRecognizer instance) {
|
||||
instance
|
||||
..onStart = onDragStart
|
||||
..onUpdate = onDragUpdate
|
||||
..onEnd = onDragEnd;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
gestures[DoubleTapGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(debugOwner: this),
|
||||
(DoubleTapGestureRecognizer instance) {
|
||||
instance.onDoubleTap = onDoubleTap;
|
||||
},
|
||||
);
|
||||
|
||||
gestures[PhotoViewGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<PhotoViewGestureRecognizer>(
|
||||
() => PhotoViewGestureRecognizer(
|
||||
hitDetector: hitDetector,
|
||||
debugOwner: this,
|
||||
validateAxis: axis,
|
||||
touchSlopFactor: touchSlopFactor,
|
||||
),
|
||||
(PhotoViewGestureRecognizer instance) {
|
||||
instance
|
||||
..onStart = onScaleStart
|
||||
..onUpdate = onScaleUpdate
|
||||
..onEnd = onScaleEnd;
|
||||
},
|
||||
);
|
||||
|
||||
return RawGestureDetector(
|
||||
behavior: behavior,
|
||||
gestures: gestures,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
|
||||
PhotoViewGestureRecognizer({
|
||||
this.hitDetector,
|
||||
Object? debugOwner,
|
||||
this.validateAxis,
|
||||
this.touchSlopFactor = 1,
|
||||
PointerDeviceKind? kind,
|
||||
}) : super(debugOwner: debugOwner, supportedDevices: null);
|
||||
final HitCornersDetector? hitDetector;
|
||||
final Axis? validateAxis;
|
||||
final double touchSlopFactor;
|
||||
|
||||
Map<int, Offset> _pointerLocations = <int, Offset>{};
|
||||
|
||||
Offset? _initialFocalPoint;
|
||||
Offset? _currentFocalPoint;
|
||||
double? _initialSpan;
|
||||
double? _currentSpan;
|
||||
|
||||
bool ready = true;
|
||||
|
||||
@override
|
||||
void addAllowedPointer(PointerDownEvent event) {
|
||||
if (ready) {
|
||||
ready = false;
|
||||
_pointerLocations = <int, Offset>{};
|
||||
}
|
||||
super.addAllowedPointer(event);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStopTrackingLastPointer(int pointer) {
|
||||
ready = true;
|
||||
super.didStopTrackingLastPointer(pointer);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (validateAxis != null) {
|
||||
bool didChangeConfiguration = false;
|
||||
if (event is PointerMoveEvent) {
|
||||
if (!event.synthesized) {
|
||||
_pointerLocations[event.pointer] = event.position;
|
||||
}
|
||||
} else if (event is PointerDownEvent) {
|
||||
_pointerLocations[event.pointer] = event.position;
|
||||
didChangeConfiguration = true;
|
||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_pointerLocations.remove(event.pointer);
|
||||
didChangeConfiguration = true;
|
||||
}
|
||||
|
||||
_updateDistances();
|
||||
|
||||
if (didChangeConfiguration) {
|
||||
// cf super._reconfigure
|
||||
_initialFocalPoint = _currentFocalPoint;
|
||||
_initialSpan = _currentSpan;
|
||||
}
|
||||
|
||||
_decideIfWeAcceptEvent(event);
|
||||
}
|
||||
super.handleEvent(event);
|
||||
}
|
||||
|
||||
void _updateDistances() {
|
||||
// cf super._update
|
||||
final int count = _pointerLocations.keys.length;
|
||||
|
||||
// Compute the focal point
|
||||
Offset focalPoint = Offset.zero;
|
||||
for (final int pointer in _pointerLocations.keys) {
|
||||
focalPoint += _pointerLocations[pointer]!;
|
||||
}
|
||||
_currentFocalPoint =
|
||||
count > 0 ? focalPoint / count.toDouble() : Offset.zero;
|
||||
|
||||
// Span is the average deviation from focal point. Horizontal and vertical
|
||||
// spans are the average deviations from the focal point's horizontal and
|
||||
// vertical coordinates, respectively.
|
||||
double totalDeviation = 0.0;
|
||||
for (final int pointer in _pointerLocations.keys) {
|
||||
totalDeviation +=
|
||||
(_currentFocalPoint! - _pointerLocations[pointer]!).distance;
|
||||
}
|
||||
_currentSpan = count > 0 ? totalDeviation / count : 0.0;
|
||||
}
|
||||
|
||||
void _decideIfWeAcceptEvent(PointerEvent event) {
|
||||
final move = _initialFocalPoint! - _currentFocalPoint!;
|
||||
final bool shouldMove = validateAxis == Axis.vertical
|
||||
? hitDetector!.shouldMove(move, Axis.vertical)
|
||||
: hitDetector!.shouldMove(move, Axis.horizontal);
|
||||
if (shouldMove || _pointerLocations.keys.length > 1) {
|
||||
final double spanDelta = (_currentSpan! - _initialSpan!).abs();
|
||||
final double focalPointDelta =
|
||||
(_currentFocalPoint! - _initialFocalPoint!).distance;
|
||||
// warning: do not compare `focalPointDelta` to `kPanSlop`
|
||||
// `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop`
|
||||
// and PhotoView recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView`
|
||||
// setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0`
|
||||
// setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView`
|
||||
if (spanDelta > kScaleSlop ||
|
||||
focalPointDelta > kTouchSlop * touchSlopFactor) {
|
||||
acceptGesture(event.pointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An [InheritedWidget] responsible to give a axis aware scope to [PhotoViewGestureRecognizer].
|
||||
///
|
||||
/// When using this, PhotoView will test if the content zoomed has hit edge every time user pinches,
|
||||
/// if so, it will let parent gesture detectors win the gesture arena
|
||||
///
|
||||
/// Useful when placing PhotoView inside a gesture sensitive context,
|
||||
/// such as [PageView], [Dismissible], [BottomSheet].
|
||||
///
|
||||
/// Usage example:
|
||||
/// ```
|
||||
/// PhotoViewGestureDetectorScope(
|
||||
/// axis: Axis.vertical,
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.jpg"),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
class PhotoViewGestureDetectorScope extends InheritedWidget {
|
||||
const PhotoViewGestureDetectorScope({
|
||||
super.key,
|
||||
this.axis,
|
||||
this.touchSlopFactor = .2,
|
||||
required Widget child,
|
||||
}) : super(child: child);
|
||||
|
||||
static PhotoViewGestureDetectorScope? of(BuildContext context) {
|
||||
final PhotoViewGestureDetectorScope? scope = context
|
||||
.dependOnInheritedWidgetOfExactType<PhotoViewGestureDetectorScope>();
|
||||
return scope;
|
||||
}
|
||||
|
||||
final Axis? axis;
|
||||
|
||||
// in [0, 1[
|
||||
// 0: most reactive but will not let tap recognizers accept gestures
|
||||
// <1: less reactive but gives the most leeway to other recognizers
|
||||
// 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree
|
||||
final double touchSlopFactor;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(PhotoViewGestureDetectorScope oldWidget) {
|
||||
return axis != oldWidget.axis && touchSlopFactor != oldWidget.touchSlopFactor;
|
||||
}
|
||||
}
|
||||
|
||||
// `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer`
|
||||
// this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop`
|
||||
// we cannot change that, but we can prevent the scrollable from panning until this threshold is reached
|
||||
// and let other recognizers accept the gesture instead
|
||||
class PhotoViewPageViewScrollPhysics extends ScrollPhysics {
|
||||
const PhotoViewPageViewScrollPhysics({
|
||||
this.touchSlopFactor = 0.1,
|
||||
ScrollPhysics? parent,
|
||||
}) : super(parent: parent);
|
||||
|
||||
|
||||
// in [0, 1]
|
||||
// 0: most reactive but will not let PhotoView recognizers accept gestures
|
||||
// 1: less reactive but gives the most leeway to PhotoView recognizers
|
||||
final double touchSlopFactor;
|
||||
|
||||
|
||||
@override
|
||||
PhotoViewPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return PhotoViewPageViewScrollPhysics(
|
||||
touchSlopFactor: touchSlopFactor,
|
||||
parent: buildParent(ancestor),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor;
|
||||
}
|
|
@ -0,0 +1,77 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/controller/photo_view_controller_delegate.dart'
|
||||
show PhotoViewControllerDelegate;
|
||||
|
||||
mixin HitCornersDetector on PhotoViewControllerDelegate {
|
||||
HitCorners _hitCornersX() {
|
||||
final double childWidth = scaleBoundaries.childSize.width * scale;
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
if (screenWidth >= childWidth) {
|
||||
return const HitCorners(true, true);
|
||||
}
|
||||
final x = -position.dx;
|
||||
final cornersX = this.cornersX();
|
||||
return HitCorners(x <= cornersX.min, x >= cornersX.max);
|
||||
}
|
||||
|
||||
HitCorners _hitCornersY() {
|
||||
final double childHeight = scaleBoundaries.childSize.height * scale;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
if (screenHeight >= childHeight) {
|
||||
return const HitCorners(true, true);
|
||||
}
|
||||
final y = -position.dy;
|
||||
final cornersY = this.cornersY();
|
||||
return HitCorners(y <= cornersY.min, y >= cornersY.max);
|
||||
}
|
||||
|
||||
bool _shouldMoveAxis(HitCorners hitCorners, double mainAxisMove, double crossAxisMove) {
|
||||
if (mainAxisMove == 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hitCorners.hasHitAny) {
|
||||
return true;
|
||||
}
|
||||
final axisBlocked = hitCorners.hasHitBoth ||
|
||||
(hitCorners.hasHitMax ? mainAxisMove > 0 : mainAxisMove < 0);
|
||||
if (axisBlocked) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _shouldMoveX(Offset move) {
|
||||
final hitCornersX = _hitCornersX();
|
||||
final mainAxisMove = move.dx;
|
||||
final crossAxisMove = move.dy;
|
||||
|
||||
return _shouldMoveAxis(hitCornersX, mainAxisMove, crossAxisMove);
|
||||
}
|
||||
|
||||
bool _shouldMoveY(Offset move) {
|
||||
final hitCornersY = _hitCornersY();
|
||||
final mainAxisMove = move.dy;
|
||||
final crossAxisMove = move.dx;
|
||||
|
||||
return _shouldMoveAxis(hitCornersY, mainAxisMove, crossAxisMove);
|
||||
}
|
||||
|
||||
bool shouldMove(Offset move, Axis mainAxis) {
|
||||
if (mainAxis == Axis.vertical) {
|
||||
return _shouldMoveY(move);
|
||||
}
|
||||
return _shouldMoveX(move);
|
||||
}
|
||||
}
|
||||
|
||||
class HitCorners {
|
||||
const HitCorners(this.hasHitMin, this.hasHitMax);
|
||||
|
||||
final bool hasHitMin;
|
||||
final bool hasHitMax;
|
||||
|
||||
bool get hasHitAny => hasHitMin || hasHitMax;
|
||||
|
||||
bool get hasHitBoth => hasHitMin && hasHitMax;
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/// A class that work as a enum. It overloads the operator `*` saving the double as a multiplier.
|
||||
///
|
||||
/// ```
|
||||
/// PhotoViewComputedScale.contained * 2
|
||||
/// ```
|
||||
///
|
||||
class PhotoViewComputedScale {
|
||||
const PhotoViewComputedScale._internal(this._value, [this.multiplier = 1.0]);
|
||||
|
||||
final String _value;
|
||||
final double multiplier;
|
||||
|
||||
@override
|
||||
String toString() => 'Enum.$_value';
|
||||
|
||||
static const contained = PhotoViewComputedScale._internal('contained');
|
||||
static const covered = PhotoViewComputedScale._internal('covered');
|
||||
|
||||
PhotoViewComputedScale operator *(double multiplier) {
|
||||
return PhotoViewComputedScale._internal(_value, multiplier);
|
||||
}
|
||||
|
||||
PhotoViewComputedScale operator /(double divider) {
|
||||
return PhotoViewComputedScale._internal(_value, 1 / divider);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PhotoViewComputedScale &&
|
||||
runtimeType == other.runtimeType &&
|
||||
_value == other._value;
|
||||
|
||||
@override
|
||||
int get hashCode => _value.hashCode;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class PhotoViewDefaultError extends StatelessWidget {
|
||||
const PhotoViewDefaultError({Key? key, required this.decoration})
|
||||
: super(key: key);
|
||||
|
||||
final BoxDecoration decoration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: decoration,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.broken_image,
|
||||
color: Colors.grey[400],
|
||||
size: 40.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoViewDefaultLoading extends StatelessWidget {
|
||||
const PhotoViewDefaultLoading({Key? key, this.event}) : super(key: key);
|
||||
|
||||
final ImageChunkEvent? event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expectedBytes = event?.expectedTotalBytes;
|
||||
final loadedBytes = event?.cumulativeBytesLoaded;
|
||||
final value = loadedBytes != null && expectedBytes != null
|
||||
? loadedBytes / expectedBytes
|
||||
: null;
|
||||
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(value: value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
/// A way to represent the step of the "doubletap gesture cycle" in which PhotoView is.
|
||||
enum PhotoViewScaleState {
|
||||
initial,
|
||||
covering,
|
||||
originalSize,
|
||||
zoomedIn,
|
||||
zoomedOut;
|
||||
|
||||
bool get isScaleStateZooming =>
|
||||
this == PhotoViewScaleState.zoomedIn ||
|
||||
this == PhotoViewScaleState.zoomedOut;
|
||||
}
|
327
mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart
Normal file
327
mobile/lib/shared/ui/photo_view/src/photo_view_wrappers.dart
Normal file
|
@ -0,0 +1,327 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../photo_view.dart';
|
||||
import 'core/photo_view_core.dart';
|
||||
import 'photo_view_default_widgets.dart';
|
||||
import 'utils/photo_view_utils.dart';
|
||||
|
||||
class ImageWrapper extends StatefulWidget {
|
||||
const ImageWrapper({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
required this.loadingBuilder,
|
||||
required this.backgroundDecoration,
|
||||
required this.gaplessPlayback,
|
||||
required this.heroAttributes,
|
||||
required this.scaleStateChangedCallback,
|
||||
required this.enableRotation,
|
||||
required this.controller,
|
||||
required this.scaleStateController,
|
||||
required this.maxScale,
|
||||
required this.minScale,
|
||||
required this.initialScale,
|
||||
required this.basePosition,
|
||||
required this.scaleStateCycle,
|
||||
required this.onTapUp,
|
||||
required this.onTapDown,
|
||||
required this.onDragStart,
|
||||
required this.onDragEnd,
|
||||
required this.onDragUpdate,
|
||||
required this.onScaleEnd,
|
||||
required this.outerSize,
|
||||
required this.gestureDetectorBehavior,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.errorBuilder,
|
||||
required this.enablePanAlways,
|
||||
}) : super(key: key);
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
final BoxDecoration backgroundDecoration;
|
||||
final bool gaplessPlayback;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
final bool enableRotation;
|
||||
final dynamic maxScale;
|
||||
final dynamic minScale;
|
||||
final dynamic initialScale;
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
final Alignment? basePosition;
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
final Size outerSize;
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool? tightMode;
|
||||
final FilterQuality? filterQuality;
|
||||
final bool? disableGestures;
|
||||
final bool? enablePanAlways;
|
||||
|
||||
@override
|
||||
createState() => _ImageWrapperState();
|
||||
}
|
||||
|
||||
class _ImageWrapperState extends State<ImageWrapper> {
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
ImageStream? _imageStream;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
ImageInfo? _imageInfo;
|
||||
bool _loading = true;
|
||||
Size? _imageSize;
|
||||
Object? _lastException;
|
||||
StackTrace? _lastStack;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_stopImageStream();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_resolveImage();
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ImageWrapper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.imageProvider != oldWidget.imageProvider) {
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
// retrieve image from the provider
|
||||
void _resolveImage() {
|
||||
final ImageStream newStream = widget.imageProvider.resolve(
|
||||
const ImageConfiguration(),
|
||||
);
|
||||
_updateSourceStream(newStream);
|
||||
}
|
||||
|
||||
ImageStreamListener _getOrCreateListener() {
|
||||
void handleImageChunk(ImageChunkEvent event) {
|
||||
setState(() {
|
||||
_loadingProgress = event;
|
||||
_lastException = null;
|
||||
});
|
||||
}
|
||||
|
||||
void handleImageFrame(ImageInfo info, bool synchronousCall) {
|
||||
setupCB() {
|
||||
_imageSize = Size(
|
||||
info.image.width.toDouble(),
|
||||
info.image.height.toDouble(),
|
||||
);
|
||||
_loading = false;
|
||||
_imageInfo = _imageInfo;
|
||||
|
||||
_loadingProgress = null;
|
||||
_lastException = null;
|
||||
_lastStack = null;
|
||||
}
|
||||
synchronousCall ? setupCB() : setState(setupCB);
|
||||
}
|
||||
|
||||
void handleError(dynamic error, StackTrace? stackTrace) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_lastException = error;
|
||||
_lastStack = stackTrace;
|
||||
});
|
||||
assert(() {
|
||||
if (widget.errorBuilder == null) {
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
handleImageFrame,
|
||||
onChunk: handleImageChunk,
|
||||
onError: handleError,
|
||||
);
|
||||
|
||||
return _imageStreamListener!;
|
||||
}
|
||||
|
||||
void _updateSourceStream(ImageStream newStream) {
|
||||
if (_imageStream?.key == newStream.key) {
|
||||
return;
|
||||
}
|
||||
_imageStream?.removeListener(_imageStreamListener!);
|
||||
_imageStream = newStream;
|
||||
_imageStream!.addListener(_getOrCreateListener());
|
||||
}
|
||||
|
||||
void _stopImageStream() {
|
||||
_imageStream?.removeListener(_imageStreamListener!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return _buildLoading(context);
|
||||
}
|
||||
|
||||
if (_lastException != null) {
|
||||
return _buildError(context);
|
||||
}
|
||||
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
|
||||
return PhotoViewCore(
|
||||
imageProvider: widget.imageProvider,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
enableRotation: widget.enableRotation,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
basePosition: widget.basePosition ?? Alignment.center,
|
||||
controller: widget.controller,
|
||||
scaleStateController: widget.scaleStateController,
|
||||
scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle,
|
||||
scaleBoundaries: scaleBoundaries,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode ?? false,
|
||||
filterQuality: widget.filterQuality ?? FilterQuality.none,
|
||||
disableGestures: widget.disableGestures ?? false,
|
||||
enablePanAlways: widget.enablePanAlways ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading(BuildContext context) {
|
||||
if (widget.loadingBuilder != null) {
|
||||
return widget.loadingBuilder!(context, _loadingProgress);
|
||||
}
|
||||
|
||||
return PhotoViewDefaultLoading(
|
||||
event: _loadingProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(
|
||||
BuildContext context,
|
||||
) {
|
||||
if (widget.errorBuilder != null) {
|
||||
return widget.errorBuilder!(context, _lastException!, _lastStack);
|
||||
}
|
||||
return PhotoViewDefaultError(
|
||||
decoration: widget.backgroundDecoration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomChildWrapper extends StatelessWidget {
|
||||
const CustomChildWrapper({
|
||||
Key? key,
|
||||
this.child,
|
||||
required this.childSize,
|
||||
required this.backgroundDecoration,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
required this.enableRotation,
|
||||
required this.controller,
|
||||
required this.scaleStateController,
|
||||
required this.maxScale,
|
||||
required this.minScale,
|
||||
required this.initialScale,
|
||||
required this.basePosition,
|
||||
required this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
required this.outerSize,
|
||||
this.gestureDetectorBehavior,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget? child;
|
||||
final Size? childSize;
|
||||
final Decoration backgroundDecoration;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
final bool enableRotation;
|
||||
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
|
||||
final dynamic maxScale;
|
||||
final dynamic minScale;
|
||||
final dynamic initialScale;
|
||||
|
||||
final Alignment? basePosition;
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
final Size outerSize;
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool? tightMode;
|
||||
final FilterQuality? filterQuality;
|
||||
final bool? disableGestures;
|
||||
final bool? enablePanAlways;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
minScale ?? 0.0,
|
||||
maxScale ?? double.infinity,
|
||||
initialScale ?? PhotoViewComputedScale.contained,
|
||||
outerSize,
|
||||
childSize ?? outerSize,
|
||||
);
|
||||
|
||||
return PhotoViewCore.customChild(
|
||||
customChild: child,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
enableRotation: enableRotation,
|
||||
heroAttributes: heroAttributes,
|
||||
controller: controller,
|
||||
scaleStateController: scaleStateController,
|
||||
scaleStateCycle: scaleStateCycle ?? defaultScaleStateCycle,
|
||||
basePosition: basePosition ?? Alignment.center,
|
||||
scaleBoundaries: scaleBoundaries,
|
||||
onTapUp: onTapUp,
|
||||
onTapDown: onTapDown,
|
||||
onDragStart: onDragStart,
|
||||
onDragEnd: onDragEnd,
|
||||
onDragUpdate: onDragUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
gestureDetectorBehavior: gestureDetectorBehavior,
|
||||
tightMode: tightMode ?? false,
|
||||
filterQuality: filterQuality ?? FilterQuality.none,
|
||||
disableGestures: disableGestures ?? false,
|
||||
enablePanAlways: enablePanAlways ?? false,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// A [ChangeNotifier] that has a second collection of listeners: the ignorable ones
|
||||
///
|
||||
/// Those listeners will be fired when [notifyListeners] fires and will be ignored
|
||||
/// when [notifySomeListeners] fires.
|
||||
///
|
||||
/// The common collection of listeners inherited from [ChangeNotifier] will be fired
|
||||
/// every time.
|
||||
class IgnorableChangeNotifier extends ChangeNotifier {
|
||||
ObserverList<VoidCallback>? _ignorableListeners =
|
||||
ObserverList<VoidCallback>();
|
||||
|
||||
bool _debugAssertNotDisposed() {
|
||||
assert(() {
|
||||
if (_ignorableListeners == null) {
|
||||
AssertionError([
|
||||
'A $runtimeType was used after being disposed.',
|
||||
'Once you have called dispose() on a $runtimeType, it can no longer be used.'
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
bool get hasListeners {
|
||||
return super.hasListeners || (_ignorableListeners?.isNotEmpty ?? false);
|
||||
}
|
||||
|
||||
void addIgnorableListener(listener) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
_ignorableListeners!.add(listener);
|
||||
}
|
||||
|
||||
void removeIgnorableListener(listener) {
|
||||
assert(_debugAssertNotDisposed());
|
||||
_ignorableListeners!.remove(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ignorableListeners = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@protected
|
||||
@override
|
||||
@visibleForTesting
|
||||
void notifyListeners() {
|
||||
super.notifyListeners();
|
||||
if (_ignorableListeners != null) {
|
||||
final List<VoidCallback> localListeners =
|
||||
List<VoidCallback>.from(_ignorableListeners!);
|
||||
for (VoidCallback listener in localListeners) {
|
||||
try {
|
||||
if (_ignorableListeners!.contains(listener)) {
|
||||
listener();
|
||||
}
|
||||
} catch (exception, stack) {
|
||||
FlutterError.reportError(
|
||||
FlutterErrorDetails(
|
||||
exception: exception,
|
||||
stack: stack,
|
||||
library: 'Photoview library',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ignores the ignoreables
|
||||
@protected
|
||||
void notifySomeListeners() {
|
||||
super.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Just like [ValueNotifier] except it extends [IgnorableChangeNotifier] which has
|
||||
/// listeners that wont fire when [updateIgnoring] is called.
|
||||
class IgnorableValueNotifier<T> extends IgnorableChangeNotifier
|
||||
implements ValueListenable<T> {
|
||||
IgnorableValueNotifier(this._value);
|
||||
|
||||
@override
|
||||
T get value => _value;
|
||||
T _value;
|
||||
|
||||
set value(T newValue) {
|
||||
if (_value == newValue) {
|
||||
return;
|
||||
}
|
||||
_value = newValue;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateIgnoring(T newValue) {
|
||||
if (_value == newValue) {
|
||||
return;
|
||||
}
|
||||
_value = newValue;
|
||||
notifySomeListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => '${describeIdentity(this)}($value)';
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Data class that holds the attributes that are going to be passed to
|
||||
/// [PhotoViewImageWrapper]'s [Hero].
|
||||
class PhotoViewHeroAttributes {
|
||||
const PhotoViewHeroAttributes({
|
||||
required this.tag,
|
||||
this.createRectTween,
|
||||
this.flightShuttleBuilder,
|
||||
this.placeholderBuilder,
|
||||
this.transitionOnUserGestures = false,
|
||||
});
|
||||
|
||||
/// Mirror to [Hero.tag]
|
||||
final Object tag;
|
||||
|
||||
/// Mirror to [Hero.createRectTween]
|
||||
final CreateRectTween? createRectTween;
|
||||
|
||||
/// Mirror to [Hero.flightShuttleBuilder]
|
||||
final HeroFlightShuttleBuilder? flightShuttleBuilder;
|
||||
|
||||
/// Mirror to [Hero.placeholderBuilder]
|
||||
final HeroPlaceholderBuilder? placeholderBuilder;
|
||||
|
||||
/// Mirror to [Hero.transitionOnUserGestures]
|
||||
final bool transitionOnUserGestures;
|
||||
}
|
145
mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart
Normal file
145
mobile/lib/shared/ui/photo_view/src/utils/photo_view_utils.dart
Normal file
|
@ -0,0 +1,145 @@
|
|||
import 'dart:math' as math;
|
||||
import 'dart:ui' show Size;
|
||||
|
||||
import "package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart";
|
||||
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_scale_state.dart';
|
||||
|
||||
/// Given a [PhotoViewScaleState], returns a scale value considering [scaleBoundaries].
|
||||
double getScaleForScaleState(
|
||||
PhotoViewScaleState scaleState,
|
||||
ScaleBoundaries scaleBoundaries,
|
||||
) {
|
||||
switch (scaleState) {
|
||||
case PhotoViewScaleState.initial:
|
||||
case PhotoViewScaleState.zoomedIn:
|
||||
case PhotoViewScaleState.zoomedOut:
|
||||
return _clampSize(scaleBoundaries.initialScale, scaleBoundaries);
|
||||
case PhotoViewScaleState.covering:
|
||||
return _clampSize(
|
||||
_scaleForCovering(
|
||||
scaleBoundaries.outerSize,
|
||||
scaleBoundaries.childSize,
|
||||
),
|
||||
scaleBoundaries,
|
||||
);
|
||||
case PhotoViewScaleState.originalSize:
|
||||
return _clampSize(1.0, scaleBoundaries);
|
||||
// Will never be reached
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal class to wraps custom scale boundaries (min, max and initial)
|
||||
/// Also, stores values regarding the two sizes: the container and teh child.
|
||||
class ScaleBoundaries {
|
||||
const ScaleBoundaries(
|
||||
this._minScale,
|
||||
this._maxScale,
|
||||
this._initialScale,
|
||||
this.outerSize,
|
||||
this.childSize,
|
||||
);
|
||||
|
||||
final dynamic _minScale;
|
||||
final dynamic _maxScale;
|
||||
final dynamic _initialScale;
|
||||
final Size outerSize;
|
||||
final Size childSize;
|
||||
|
||||
double get minScale {
|
||||
assert(_minScale is double || _minScale is PhotoViewComputedScale);
|
||||
if (_minScale == PhotoViewComputedScale.contained) {
|
||||
return _scaleForContained(outerSize, childSize) *
|
||||
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
|
||||
}
|
||||
if (_minScale == PhotoViewComputedScale.covered) {
|
||||
return _scaleForCovering(outerSize, childSize) *
|
||||
(_minScale as PhotoViewComputedScale).multiplier; // ignore: avoid_as
|
||||
}
|
||||
assert(_minScale >= 0.0);
|
||||
return _minScale;
|
||||
}
|
||||
|
||||
double get maxScale {
|
||||
assert(_maxScale is double || _maxScale is PhotoViewComputedScale);
|
||||
if (_maxScale == PhotoViewComputedScale.contained) {
|
||||
return (_scaleForContained(outerSize, childSize) *
|
||||
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
|
||||
.multiplier)
|
||||
.clamp(minScale, double.infinity);
|
||||
}
|
||||
if (_maxScale == PhotoViewComputedScale.covered) {
|
||||
return (_scaleForCovering(outerSize, childSize) *
|
||||
(_maxScale as PhotoViewComputedScale) // ignore: avoid_as
|
||||
.multiplier)
|
||||
.clamp(minScale, double.infinity);
|
||||
}
|
||||
return _maxScale.clamp(minScale, double.infinity);
|
||||
}
|
||||
|
||||
double get initialScale {
|
||||
assert(_initialScale is double || _initialScale is PhotoViewComputedScale);
|
||||
if (_initialScale == PhotoViewComputedScale.contained) {
|
||||
return _scaleForContained(outerSize, childSize) *
|
||||
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
|
||||
.multiplier;
|
||||
}
|
||||
if (_initialScale == PhotoViewComputedScale.covered) {
|
||||
return _scaleForCovering(outerSize, childSize) *
|
||||
(_initialScale as PhotoViewComputedScale) // ignore: avoid_as
|
||||
.multiplier;
|
||||
}
|
||||
return _initialScale.clamp(minScale, maxScale);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is ScaleBoundaries &&
|
||||
runtimeType == other.runtimeType &&
|
||||
_minScale == other._minScale &&
|
||||
_maxScale == other._maxScale &&
|
||||
_initialScale == other._initialScale &&
|
||||
outerSize == other.outerSize &&
|
||||
childSize == other.childSize;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
_minScale.hashCode ^
|
||||
_maxScale.hashCode ^
|
||||
_initialScale.hashCode ^
|
||||
outerSize.hashCode ^
|
||||
childSize.hashCode;
|
||||
}
|
||||
|
||||
double _scaleForContained(Size size, Size childSize) {
|
||||
final double imageWidth = childSize.width;
|
||||
final double imageHeight = childSize.height;
|
||||
|
||||
final double screenWidth = size.width;
|
||||
final double screenHeight = size.height;
|
||||
|
||||
return math.min(screenWidth / imageWidth, screenHeight / imageHeight);
|
||||
}
|
||||
|
||||
double _scaleForCovering(Size size, Size childSize) {
|
||||
final double imageWidth = childSize.width;
|
||||
final double imageHeight = childSize.height;
|
||||
|
||||
final double screenWidth = size.width;
|
||||
final double screenHeight = size.height;
|
||||
|
||||
return math.max(screenWidth / imageWidth, screenHeight / imageHeight);
|
||||
}
|
||||
|
||||
double _clampSize(double size, ScaleBoundaries scaleBoundaries) {
|
||||
return size.clamp(scaleBoundaries.minScale, scaleBoundaries.maxScale);
|
||||
}
|
||||
|
||||
/// Simple class to store a min and a max value
|
||||
class CornersRange {
|
||||
const CornersRange(this.min, this.max);
|
||||
final double min;
|
||||
final double max;
|
||||
}
|
|
@ -239,6 +239,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
easy_image_viewer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_image_viewer
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
easy_localization:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -757,13 +764,6 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
photo_view:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: photo_view
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.14.0"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -23,7 +23,6 @@ dependencies:
|
|||
video_player: ^2.2.18
|
||||
chewie: ^1.3.5
|
||||
badges: ^2.0.2
|
||||
photo_view: ^0.14.0
|
||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||
flutter_map: ^0.14.0
|
||||
flutter_udid: ^2.0.0
|
||||
|
@ -41,6 +40,7 @@ dependencies:
|
|||
collection: ^1.16.0
|
||||
http_parser: ^4.0.1
|
||||
flutter_web_auth: ^0.5.0
|
||||
easy_image_viewer: ^1.2.0
|
||||
|
||||
openapi:
|
||||
path: openapi
|
||||
|
|
Loading…
Reference in a new issue