1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-06 03:46:47 +01:00

feat(mobile): image caching & viewer improvements (#4095)

This commit is contained in:
Fynn Petersen-Frey 2023-09-18 05:57:05 +02:00 committed by GitHub
parent 63b6a71ebd
commit 1c02e1dadf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 283 additions and 212 deletions

View file

@ -13,6 +13,7 @@ import 'package:immich_mobile/modules/backup/models/backup_album.model.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/cache/widgets_binding.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/android_device_asset.dart'; import 'package:immich_mobile/shared/models/android_device_asset.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
@ -37,7 +38,7 @@ import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); ImmichWidgetsBinding();
final db = await loadDb(); final db = await loadDb();
await initApp(); await initApp();

View file

@ -18,6 +18,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart'; import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/shared/cache/original_image_provider.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart'; import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
@ -31,8 +32,7 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:openapi/api.dart' show ThumbnailFormat;
import 'package:openapi/api.dart' as api;
// ignore: must_be_immutable // ignore: must_be_immutable
class GalleryViewerPage extends HookConsumerWidget { class GalleryViewerPage extends HookConsumerWidget {
@ -51,6 +51,9 @@ class GalleryViewerPage extends HookConsumerWidget {
final PageController controller; final PageController controller;
static const jpeg = ThumbnailFormat.JPEG;
static const webp = ThumbnailFormat.WEBP;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider); final settings = ref.watch(appSettingsServiceProvider);
@ -59,9 +62,9 @@ class GalleryViewerPage extends HookConsumerWidget {
final isZoomed = useState<bool>(false); final isZoomed = useState<bool>(false);
final isPlayingMotionVideo = useState(false); final isPlayingMotionVideo = useState(false);
final isPlayingVideo = useState(false); final isPlayingVideo = useState(false);
final progressValue = useState(0.0);
Offset? localPosition; Offset? localPosition;
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
final header = {"Authorization": authToken};
final currentIndex = useState(initialIndex); final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value); final currentAsset = loadAsset(currentIndex.value);
@ -83,93 +86,52 @@ class GalleryViewerPage extends HookConsumerWidget {
.watch(assetProvider.notifier) .watch(assetProvider.notifier)
.toggleFavorite([asset], !asset.isFavorite); .toggleFavorite([asset], !asset.isFavorite);
/// Thumbnail image of a remote asset. Required asset.isRemote
ImageProvider remoteThumbnailImageProvider(
Asset asset,
api.ThumbnailFormat type,
) {
return CachedNetworkImageProvider(
getThumbnailUrl(
asset,
type: type,
),
cacheKey: getThumbnailCacheKey(
asset,
type: type,
),
headers: {"Authorization": authToken},
);
}
/// Original (large) image of a remote asset. Required asset.isRemote /// Original (large) image of a remote asset. Required asset.isRemote
ImageProvider originalImageProvider(Asset asset) { ImageProvider remoteOriginalProvider(Asset asset) =>
return CachedNetworkImageProvider( CachedNetworkImageProvider(
getImageUrl(asset), getImageUrl(asset),
cacheKey: getImageCacheKey(asset), cacheKey: getImageCacheKey(asset),
headers: {"Authorization": authToken}, headers: header,
); );
}
/// Thumbnail image of a local asset. Required asset.isLocal
ImageProvider localThumbnailImageProvider(Asset asset) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: ThumbnailSize(
MediaQuery.of(context).size.width.floor(),
MediaQuery.of(context).size.height.floor(),
),
);
}
/// Original (large) image of a local asset. Required asset.isLocal /// Original (large) image of a local asset. Required asset.isLocal
ImageProvider localImageProvider(Asset asset) { ImageProvider localOriginalProvider(Asset asset) =>
return AssetEntityImageProvider( OriginalImageProvider(asset);
isOriginal: true,
asset.local!, ImageProvider finalImageProvider(Asset asset) {
); if (ImmichImage.useLocal(asset)) {
return localOriginalProvider(asset);
} else if (isLoadOriginal.value) {
return remoteOriginalProvider(asset);
} else if (isLoadPreview.value) {
return ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
}
return ImmichImage.remoteThumbnailProvider(asset, webp, header);
}
Iterable<ImageProvider> allImageProviders(Asset asset) sync* {
if (ImmichImage.useLocal(asset)) {
yield ImmichImage.localThumbnailProvider(asset);
yield localOriginalProvider(asset);
} else {
yield ImmichImage.remoteThumbnailProvider(asset, webp, header);
if (isLoadPreview.value) {
yield ImmichImage.remoteThumbnailProvider(asset, jpeg, header);
}
if (isLoadOriginal.value) {
yield remoteOriginalProvider(asset);
}
}
} }
void precacheNextImage(int index) { void precacheNextImage(int index) {
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
}
if (index < totalAssets && index >= 0) { if (index < totalAssets && index >= 0) {
final asset = loadAsset(index); final asset = loadAsset(index);
for (final imageProvider in allImageProviders(asset)) {
if (!asset.isRemote || precacheImage(imageProvider, context, onError: onError);
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) {
// Preload the local asset
precacheImage(localImageProvider(asset), context);
} else {
onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
}
// Probably load WEBP either way
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.WEBP,
),
context,
onError: onError,
);
if (isLoadPreview.value) {
// Precache the JPEG thumbnail
precacheImage(
remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.JPEG,
),
context,
onError: onError,
);
}
if (isLoadOriginal.value) {
// Preload the original asset
precacheImage(
originalImageProvider(asset),
context,
onError: onError,
);
}
} }
} }
} }
@ -346,7 +308,6 @@ class GalleryViewerPage extends HookConsumerWidget {
activeColor: Colors.white, activeColor: Colors.white,
inactiveColor: Colors.white.withOpacity(0.75), inactiveColor: Colors.white.withOpacity(0.75),
onChanged: (position) { onChanged: (position) {
progressValue.value = position;
ref.read(videoPlayerControlsProvider.notifier).position = position; ref.read(videoPlayerControlsProvider.notifier).position = position;
}, },
), ),
@ -485,27 +446,6 @@ class GalleryViewerPage extends HookConsumerWidget {
} }
}); });
ImageProvider imageProvider(Asset asset) {
if (!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false)) {
return localImageProvider(asset);
} else {
if (isLoadOriginal.value) {
return originalImageProvider(asset);
} else if (isLoadPreview.value) {
return remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.JPEG,
);
} else {
return remoteThumbnailImageProvider(
asset,
api.ThumbnailFormat.WEBP,
);
}
}
}
return Scaffold( return Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: WillPopScope( body: WillPopScope(
@ -531,79 +471,51 @@ class GalleryViewerPage extends HookConsumerWidget {
itemCount: totalAssets, itemCount: totalAssets,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
onPageChanged: (value) { onPageChanged: (value) {
// Precache image final next = currentIndex.value < value ? value + 1 : value - 1;
if (currentIndex.value < value) { precacheNextImage(next);
// Moving forwards, so precache the next asset
precacheNextImage(value + 1);
} else {
// Moving backwards, so precache previous asset
precacheNextImage(value - 1);
}
currentIndex.value = value; currentIndex.value = value;
progressValue.value = 0.0;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
}, },
loadingBuilder: isLoadPreview.value loadingBuilder: (context, event, index) {
? (context, event) { final a = loadAsset(index);
final a = asset(); if (ImmichImage.useLocal(a)) {
if (!a.isLocal || return Image(
(a.isRemote && image: ImmichImage.localThumbnailProvider(a),
Store.get(StoreKey.preferRemoteImage, false))) { fit: BoxFit.contain,
// Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve );
// Three-Stage Loading (WEBP -> JPEG -> Original) }
final webPThumbnail = CachedNetworkImage( // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to achieve
imageUrl: getThumbnailUrl( // Three-Stage Loading (WEBP -> JPEG -> Original)
a, final webPThumbnail = CachedNetworkImage(
type: api.ThumbnailFormat.WEBP, imageUrl: getThumbnailUrl(a, type: webp),
), cacheKey: getThumbnailCacheKey(a, type: webp),
cacheKey: getThumbnailCacheKey( httpHeaders: header,
a, progressIndicatorBuilder: (_, __, ___) => const Center(
type: api.ThumbnailFormat.WEBP, child: ImmichLoadingIndicator(),
), ),
httpHeaders: {'Authorization': authToken}, fadeInDuration: const Duration(milliseconds: 0),
progressIndicatorBuilder: (_, __, ___) => fit: BoxFit.contain,
const Center( errorWidget: (context, url, error) =>
child: ImmichLoadingIndicator(), const Icon(Icons.image_not_supported_outlined),
), );
fadeInDuration: const Duration(milliseconds: 0),
fit: BoxFit.contain,
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
);
if (isLoadOriginal.value) { // loading the preview in the loadingBuilder only
// loading the preview in the loadingBuilder only // makes sense if the original is loaded in the builder
// makes sense if the original is loaded in the builder return isLoadPreview.value && isLoadOriginal.value
return CachedNetworkImage( ? CachedNetworkImage(
imageUrl: getThumbnailUrl( imageUrl: getThumbnailUrl(a, type: jpeg),
a, cacheKey: getThumbnailCacheKey(a, type: jpeg),
type: api.ThumbnailFormat.JPEG, httpHeaders: header,
), fit: BoxFit.contain,
cacheKey: getThumbnailCacheKey( fadeInDuration: const Duration(milliseconds: 0),
a, placeholder: (_, __) => webPThumbnail,
type: api.ThumbnailFormat.JPEG, errorWidget: (_, __, ___) => webPThumbnail,
), )
httpHeaders: {'Authorization': authToken}, : webPThumbnail;
fit: BoxFit.contain, },
fadeInDuration: const Duration(milliseconds: 0),
placeholder: (_, __) => webPThumbnail,
errorWidget: (_, __, ___) => webPThumbnail,
);
} else {
return webPThumbnail;
}
} else {
return Image(
image: localThumbnailImageProvider(a),
fit: BoxFit.contain,
);
}
}
: null,
builder: (context, index) { builder: (context, index) {
final asset = loadAsset(index); final asset = loadAsset(index);
final ImageProvider provider = imageProvider(asset); final ImageProvider provider = finalImageProvider(asset);
if (asset.isImage && !isPlayingMotionVideo.value) { if (asset.isImage && !isPlayingMotionVideo.value) {
return PhotoViewGalleryPageOptions( return PhotoViewGalleryPageOptions(

View file

@ -0,0 +1,69 @@
import 'package:flutter/painting.dart';
import 'original_image_provider.dart';
/// [ImageCache] that uses two caches for small and large images
/// so that a single large image does not evict all small iamges
final class CustomImageCache implements ImageCache {
final _small = ImageCache();
final _large = ImageCache();
@override
int get maximumSize => _small.maximumSize + _large.maximumSize;
@override
int get maximumSizeBytes => _small.maximumSizeBytes + _large.maximumSizeBytes;
@override
set maximumSize(int value) => _small.maximumSize = value;
@override
set maximumSizeBytes(int value) => _small.maximumSize = value;
@override
void clear() {
_small.clear();
_large.clear();
}
@override
void clearLiveImages() {
_small.clearLiveImages();
_large.clearLiveImages();
}
@override
bool containsKey(Object key) =>
(key is OriginalImageProvider ? _large : _small).containsKey(key);
@override
int get currentSize => _small.currentSize + _large.currentSize;
@override
int get currentSizeBytes => _small.currentSizeBytes + _large.currentSizeBytes;
@override
bool evict(Object key, {bool includeLive = true}) =>
(key is OriginalImageProvider ? _large : _small)
.evict(key, includeLive: includeLive);
@override
int get liveImageCount => _small.liveImageCount + _large.liveImageCount;
@override
int get pendingImageCount =>
_small.pendingImageCount + _large.pendingImageCount;
@override
ImageStreamCompleter? putIfAbsent(
Object key,
ImageStreamCompleter Function() loader, {
ImageErrorListener? onError,
}) =>
(key is OriginalImageProvider ? _large : _small)
.putIfAbsent(key, loader, onError: onError);
@override
ImageCacheStatus statusForKey(Object key) =>
(key is OriginalImageProvider ? _large : _small).statusForKey(key);
}

View file

@ -0,0 +1,73 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/shared/models/asset.dart';
/// Loads the original image for local assets
@immutable
final class OriginalImageProvider extends ImageProvider<OriginalImageProvider> {
final Asset asset;
const OriginalImageProvider(this.asset);
@override
Future<OriginalImageProvider> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture<OriginalImageProvider>(this);
@override
ImageStreamCompleter loadImage(
OriginalImageProvider key,
ImageDecoderCallback decode,
) =>
MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode),
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription(asset.fileName);
},
);
Future<ui.Codec> _loadAsync(
OriginalImageProvider key,
ImageDecoderCallback decode,
) async {
final ui.ImmutableBuffer buffer;
if (asset.isImage) {
final File? file = await asset.local?.originFile;
if (file == null) {
throw StateError("Opening file for asset ${asset.fileName} failed");
}
try {
buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
} catch (error) {
throw StateError("Loading asset ${asset.fileName} failed");
}
} else {
final thumbBytes = await asset.local?.thumbnailData;
if (thumbBytes == null) {
throw StateError("Loading thumb for video ${asset.fileName} failed");
}
buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
}
try {
final codec = await decode(buffer);
debugPrint("Decoded image ${asset.fileName}");
return codec;
} catch (error) {
throw StateError("Decoding asset ${asset.fileName} failed");
}
}
@override
bool operator ==(Object other) {
if (other is! OriginalImageProvider) return false;
if (identical(this, other)) return true;
return asset == other.asset;
}
@override
int get hashCode => asset.hashCode;
}

View file

@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
import 'custom_image_cache.dart';
final class ImmichWidgetsBinding extends WidgetsFlutterBinding {
@override
ImageCache createImageCache() => CustomImageCache();
}

View file

@ -178,6 +178,7 @@ class Asset {
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Asset) return false; if (other is! Asset) return false;
if (identical(this, other)) return true;
return id == other.id && return id == other.id &&
checksum == other.checksum && checksum == other.checksum &&
remoteId == other.remoteId && remoteId == other.remoteId &&

View file

@ -45,14 +45,9 @@ class ImmichImage extends StatelessWidget {
); );
} }
final Asset asset = this.asset!; final Asset asset = this.asset!;
if (!asset.isRemote || if (useLocal(asset)) {
(asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false))) {
return Image( return Image(
image: AssetEntityImageProvider( image: localThumbnailProvider(asset),
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
),
width: width, width: width,
height: height, height: height,
fit: fit, fit: fit,
@ -148,45 +143,44 @@ class ImmichImage extends StatelessWidget {
); );
} }
static AssetEntityImageProvider localThumbnailProvider(Asset asset) =>
AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250),
);
static CachedNetworkImageProvider remoteThumbnailProvider(
Asset asset,
api.ThumbnailFormat type,
Map<String, String> authHeader,
) =>
CachedNetworkImageProvider(
getThumbnailUrl(asset, type: type),
cacheKey: getThumbnailCacheKey(asset, type: type),
headers: authHeader,
);
/// Precaches this asset for instant load the next time it is shown /// Precaches this asset for instant load the next time it is shown
static Future<void> precacheAsset( static Future<void> precacheAsset(
Asset asset, Asset asset,
BuildContext context, { BuildContext context, {
type = api.ThumbnailFormat.WEBP, type = api.ThumbnailFormat.WEBP,
}) { }) {
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}'; if (useLocal(asset)) {
// Precache the local image
if (type == api.ThumbnailFormat.WEBP) { return precacheImage(localThumbnailProvider(asset), context);
final thumbnailUrl = getThumbnailUrl(asset);
final thumbnailCacheKey = getThumbnailCacheKey(asset);
final thumbnailProvider = CachedNetworkImageProvider(
thumbnailUrl,
cacheKey: thumbnailCacheKey,
headers: {"Authorization": authToken},
);
return precacheImage(thumbnailProvider, context);
}
// Precache the local image
if (!asset.isRemote &&
(asset.isLocal || !Store.get(StoreKey.preferRemoteImage, false))) {
final provider = AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
);
return precacheImage(provider, context);
} else { } else {
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
// Precache the remote image since we are not using local images // Precache the remote image since we are not using local images
final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG); return precacheImage(
final cacheKey = remoteThumbnailProvider(asset, type, {"Authorization": authToken}),
getThumbnailCacheKey(asset, type: api.ThumbnailFormat.JPEG); context,
final provider = CachedNetworkImageProvider(
url,
cacheKey: cacheKey,
headers: {"Authorization": authToken},
); );
return precacheImage(provider, context);
} }
} }
static bool useLocal(Asset asset) =>
!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
} }

View file

@ -235,6 +235,7 @@ class PhotoView extends StatefulWidget {
const PhotoView({ const PhotoView({
Key? key, Key? key,
required this.imageProvider, required this.imageProvider,
required this.index,
this.loadingBuilder, this.loadingBuilder,
this.backgroundDecoration, this.backgroundDecoration,
this.wantKeepAlive = false, this.wantKeepAlive = false,
@ -304,6 +305,7 @@ class PhotoView extends StatefulWidget {
imageProvider = null, imageProvider = null,
gaplessPlayback = false, gaplessPlayback = false,
loadingBuilder = null, loadingBuilder = null,
index = 0,
super(key: key); super(key: key);
/// Given a [imageProvider] it resolves into an zoomable image widget using. It /// Given a [imageProvider] it resolves into an zoomable image widget using. It
@ -419,6 +421,8 @@ class PhotoView extends StatefulWidget {
/// Useful when you want to drag a widget without restrictions. /// Useful when you want to drag a widget without restrictions.
final bool? enablePanAlways; final bool? enablePanAlways;
final int index;
bool get _isCustomChild { bool get _isCustomChild {
return child != null; return child != null;
} }
@ -571,6 +575,7 @@ class _PhotoViewState extends State<PhotoView>
disableGestures: widget.disableGestures, disableGestures: widget.disableGestures,
errorBuilder: widget.errorBuilder, errorBuilder: widget.errorBuilder,
enablePanAlways: widget.enablePanAlways, enablePanAlways: widget.enablePanAlways,
index: widget.index,
); );
}, },
); );
@ -625,7 +630,7 @@ typedef PhotoViewImageDragStartCallback = Function(
PhotoViewControllerValue controllerValue, PhotoViewControllerValue controllerValue,
); );
/// A type definition for a callback when the user drags /// A type definition for a callback when the user drags
typedef PhotoViewImageDragUpdateCallback = Function( typedef PhotoViewImageDragUpdateCallback = Function(
BuildContext context, BuildContext context,
DragUpdateDetails details, DragUpdateDetails details,
@ -650,4 +655,5 @@ typedef PhotoViewImageScaleEndCallback = Function(
typedef LoadingBuilder = Widget Function( typedef LoadingBuilder = Widget Function(
BuildContext context, BuildContext context,
ImageChunkEvent? event, ImageChunkEvent? event,
int index,
); );

View file

@ -281,6 +281,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
) )
: PhotoView( : PhotoView(
key: ObjectKey(index), key: ObjectKey(index),
index: index,
imageProvider: pageOption.imageProvider, imageProvider: pageOption.imageProvider,
loadingBuilder: widget.loadingBuilder, loadingBuilder: widget.loadingBuilder,
backgroundDecoration: widget.backgroundDecoration, backgroundDecoration: widget.backgroundDecoration,
@ -315,7 +316,10 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
); );
} }
PhotoViewGalleryPageOptions _buildPageOption(BuildContext context, int index) { PhotoViewGalleryPageOptions _buildPageOption(
BuildContext context,
int index,
) {
if (widget._isBuilder) { if (widget._isBuilder) {
return widget.builder!(context, index); return widget.builder!(context, index);
} }

View file

@ -35,6 +35,7 @@ class ImageWrapper extends StatefulWidget {
required this.disableGestures, required this.disableGestures,
required this.errorBuilder, required this.errorBuilder,
required this.enablePanAlways, required this.enablePanAlways,
required this.index,
}) : super(key: key); }) : super(key: key);
final ImageProvider imageProvider; final ImageProvider imageProvider;
@ -64,6 +65,7 @@ class ImageWrapper extends StatefulWidget {
final FilterQuality? filterQuality; final FilterQuality? filterQuality;
final bool? disableGestures; final bool? disableGestures;
final bool? enablePanAlways; final bool? enablePanAlways;
final int index;
@override @override
createState() => _ImageWrapperState(); createState() => _ImageWrapperState();
@ -128,6 +130,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
_lastException = null; _lastException = null;
_lastStack = null; _lastStack = null;
} }
synchronousCall ? setupCB() : setState(setupCB); synchronousCall ? setupCB() : setState(setupCB);
} }
@ -212,7 +215,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
Widget _buildLoading(BuildContext context) { Widget _buildLoading(BuildContext context) {
if (widget.loadingBuilder != null) { if (widget.loadingBuilder != null) {
return widget.loadingBuilder!(context, _loadingProgress); return widget.loadingBuilder!(context, _loadingProgress, widget.index);
} }
return PhotoViewDefaultLoading( return PhotoViewDefaultLoading(