From 56caf6a133bfc088e7e0dd14c70f75f0b7b502a6 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 8 Sep 2025 14:14:21 -0400 Subject: [PATCH] hdr image viewer update xcode project use existing asset fetch pinch zoom fix xcode debug scaling --- mobile/ios/Runner.xcodeproj/project.pbxproj | 48 ++-- mobile/ios/Runner/AppDelegate.swift | 5 +- mobile/ios/Runner/Images/ThumbnailsImpl.swift | 4 +- .../Images/Viewer/NativeImageView.swift | 219 ++++++++++++++++++ .../Viewer/NativeImageViewFactory.swift | 29 +++ .../asset_viewer/asset_viewer.page.dart | 25 +- .../widgets/images/native_image.widget.dart | 40 ++++ 7 files changed, 336 insertions(+), 34 deletions(-) create mode 100644 mobile/ios/Runner/Images/Viewer/NativeImageView.swift create mode 100644 mobile/ios/Runner/Images/Viewer/NativeImageViewFactory.swift create mode 100644 mobile/lib/presentation/widgets/images/native_image.widget.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 4e68390113..a7c2f43cfc 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -27,9 +27,11 @@ FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; - FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; }; - FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; }; - FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; }; + FE619FDB2E6F5D0600D0B708 /* NativeImageViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE619FD52E6F5D0600D0B708 /* NativeImageViewFactory.swift */; }; + FE619FDC2E6F5D0600D0B708 /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE619FD72E6F5D0600D0B708 /* Thumbhash.swift */; }; + FE619FDD2E6F5D0600D0B708 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE619FD82E6F5D0600D0B708 /* Thumbnails.g.swift */; }; + FE619FDE2E6F5D0600D0B708 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE619FD92E6F5D0600D0B708 /* ThumbnailsImpl.swift */; }; + FE619FDF2E6F5D0600D0B708 /* NativeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE619FD42E6F5D0600D0B708 /* NativeImageView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -111,9 +113,11 @@ FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = ""; }; FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; - FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = ""; }; - FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = ""; }; - FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = ""; }; + FE619FD42E6F5D0600D0B708 /* NativeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageView.swift; sourceTree = ""; }; + FE619FD52E6F5D0600D0B708 /* NativeImageViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeImageViewFactory.swift; sourceTree = ""; }; + FE619FD72E6F5D0600D0B708 /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = ""; }; + FE619FD82E6F5D0600D0B708 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = ""; }; + FE619FD92E6F5D0600D0B708 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -129,8 +133,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Sync; sourceTree = ""; }; @@ -243,6 +245,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + FE619FDA2E6F5D0600D0B708 /* Images */, B21E34A62E5AF9760031FDB9 /* Background */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */, FA9973382CF6DF4B000EF859 /* Runner.entitlements */, @@ -256,7 +259,6 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - FED3B1952E253E9B0030FD97 /* Images */, ); path = Runner; sourceTree = ""; @@ -282,12 +284,22 @@ path = ShareExtension; sourceTree = ""; }; - FED3B1952E253E9B0030FD97 /* Images */ = { + FE619FD62E6F5D0600D0B708 /* Viewer */ = { isa = PBXGroup; children = ( - FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */, - FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */, - FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */, + FE619FD42E6F5D0600D0B708 /* NativeImageView.swift */, + FE619FD52E6F5D0600D0B708 /* NativeImageViewFactory.swift */, + ); + path = Viewer; + sourceTree = ""; + }; + FE619FDA2E6F5D0600D0B708 /* Images */ = { + isa = PBXGroup; + children = ( + FE619FD62E6F5D0600D0B708 /* Viewer */, + FE619FD72E6F5D0600D0B708 /* Thumbhash.swift */, + FE619FD82E6F5D0600D0B708 /* Thumbnails.g.swift */, + FE619FD92E6F5D0600D0B708 /* ThumbnailsImpl.swift */, ); path = Images; sourceTree = ""; @@ -558,12 +570,14 @@ 65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, - FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */, - FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */, B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */, - FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */, + FE619FDB2E6F5D0600D0B708 /* NativeImageViewFactory.swift in Sources */, + FE619FDC2E6F5D0600D0B708 /* Thumbhash.swift in Sources */, + FE619FDD2E6F5D0600D0B708 /* Thumbnails.g.swift in Sources */, + FE619FDE2E6F5D0600D0B708 /* ThumbnailsImpl.swift in Sources */, + FE619FDF2E6F5D0600D0B708 /* NativeImageView.swift in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 3476030923..b9228f8e1b 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -15,7 +15,7 @@ import UIKit ) -> Bool { // Required for flutter_local_notification if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate } GeneratedPluginRegistrant.register(with: self) @@ -47,6 +47,9 @@ import UIKit FPPNetworkInfoPlusPlugin.register(with: registry.registrar(forPlugin: "org.cocoapods.network-info-plus")!) } } + + let factory = NativeImageViewFactory(messenger: controller.binaryMessenger) + registrar(forPlugin: "NativeImageView")!.register(factory, withId: NativeImageViewFactory.id) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/ThumbnailsImpl.swift index d1ea2cc0e0..aba2086e34 100644 --- a/mobile/ios/Runner/Images/ThumbnailsImpl.swift +++ b/mobile/ios/Runner/Images/ThumbnailsImpl.swift @@ -193,12 +193,12 @@ class ThumbnailApiImpl: ThumbnailApi { } } - private static func requestAsset(assetId: String) -> PHAsset? { + static func requestAsset(assetId: String) -> PHAsset? { var asset: PHAsset? assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) } if asset != nil { return asset } - guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: fetchOptions).firstObject else { return nil } assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } return asset diff --git a/mobile/ios/Runner/Images/Viewer/NativeImageView.swift b/mobile/ios/Runner/Images/Viewer/NativeImageView.swift new file mode 100644 index 0000000000..976f8c6ee1 --- /dev/null +++ b/mobile/ios/Runner/Images/Viewer/NativeImageView.swift @@ -0,0 +1,219 @@ +import Flutter +import UIKit +import Photos + +// TODO: the bounds this uses for scaling can change with the hero animation, +// so it doesn't display the image correctly until swiping to the next asset and back +class NativeImageView: NSObject, FlutterPlatformView { + private var _containerView: UIView + private var _scrollView: UIScrollView + private var _imageView: UIImageView + private var _image: UIImage? + private var _hasSetupZoom = false + + private static let imageManager = PHImageManager.default() + private static let fetchOptions = { + let fetchOptions = PHFetchOptions() + fetchOptions.fetchLimit = 1 + fetchOptions.wantsIncrementalChangeDetails = false + return fetchOptions + }() + private static let requestOptions = { + let requestOptions = PHImageRequestOptions() + requestOptions.isNetworkAccessAllowed = true + requestOptions.deliveryMode = .opportunistic + requestOptions.resizeMode = .none + requestOptions.isSynchronous = false + requestOptions.version = .current + return requestOptions + }() + + init( + frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any?, + binaryMessenger messenger: FlutterBinaryMessenger + ) { + _containerView = UIView(frame: frame) + _scrollView = UIScrollView() + _imageView = UIImageView() + super.init() + + setupViews() + + guard let arguments = args as? [String: Any], + let assetId = arguments["assetId"] as? String else { + print("Asset ID not provided") + return + } + + guard let asset = ThumbnailApiImpl.requestAsset(assetId: assetId) else { + print("Asset not found for identifier: \(assetId)") + return + } + + loadImage(from: asset) + } + + func view() -> UIView { + return _containerView + } + + private func setupViews() { + // Configure image view + _imageView.contentMode = .scaleAspectFit + _imageView.preferredImageDynamicRange = .high + if #available(iOS 17.0, *) { + _imageView.layer.wantsExtendedDynamicRangeContent = true + _imageView.layer.contentsFormat = .RGBA16Float + } + + // Configure scroll view + _scrollView.delegate = self + _scrollView.showsVerticalScrollIndicator = false + _scrollView.showsHorizontalScrollIndicator = false + _scrollView.bouncesZoom = true + _scrollView.decelerationRate = .fast + _scrollView.contentInsetAdjustmentBehavior = .never + _scrollView.backgroundColor = .clear + + // Add double tap gesture + let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) + doubleTapGesture.numberOfTapsRequired = 2 + _scrollView.addGestureRecognizer(doubleTapGesture) + + // Setup view hierarchy + _scrollView.addSubview(_imageView) + _containerView.addSubview(_scrollView) + + // Setup constraints + _scrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + _scrollView.topAnchor.constraint(equalTo: _containerView.topAnchor), + _scrollView.leadingAnchor.constraint(equalTo: _containerView.leadingAnchor), + _scrollView.trailingAnchor.constraint(equalTo: _containerView.trailingAnchor), + _scrollView.bottomAnchor.constraint(equalTo: _containerView.bottomAnchor) + ]) + + // Observe bounds changes + _containerView.addObserver(self, forKeyPath: "bounds", options: [.new], context: nil) + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + if keyPath == "bounds", let image = _image { + DispatchQueue.main.async { [weak self] in + self?.setupZoomScale(for: image) + } + } + } + + deinit { + _containerView.removeObserver(self, forKeyPath: "bounds") + } + + private func loadImage(from asset: PHAsset) { + Self.imageManager.requestImageDataAndOrientation( + for: asset, + options: Self.requestOptions + ) { [weak self] data, uti, orientation, info in + guard let data = data else { return } + + if #available(iOS 17.0, *) { + var config = UIImageReader.Configuration() + config.prefersHighDynamicRange = true + let imageReader = UIImageReader(configuration: config) + guard let image = imageReader.image(data: data) else { return } + DispatchQueue.main.async { + self?.setImage(image) + } + } else { + guard let image = UIImage(data: data) else { return } + DispatchQueue.main.async { + self?.setImage(image) + } + } + } + } + + private func setImage(_ image: UIImage) { + _image = image + _imageView.image = image + + // Wait for next run loop to ensure layout is complete + DispatchQueue.main.async { [weak self] in + self?.setupZoomScale(for: image) + } + } + + private func setupZoomScale(for image: UIImage) { + guard _scrollView.bounds.size.width > 0, _scrollView.bounds.size.height > 0 else { + // View not laid out yet + return + } + + // Set image view size to match image + _imageView.frame = CGRect(origin: .zero, size: image.size) + _scrollView.contentSize = image.size + + // Calculate zoom scales + let scrollViewSize = _scrollView.bounds.size + let widthScale = scrollViewSize.width / image.size.width + let heightScale = scrollViewSize.height / image.size.height + let minScale = min(widthScale, heightScale) + + _scrollView.minimumZoomScale = minScale + _scrollView.maximumZoomScale = max(2.0, minScale * 5.0) + _scrollView.zoomScale = minScale + + centerImageView() + _hasSetupZoom = true + } + + private func centerImageView() { + let scrollViewSize = _scrollView.bounds.size + let imageViewSize = _imageView.frame.size + + let horizontalInset = max(0, (scrollViewSize.width - imageViewSize.width) / 2) + let verticalInset = max(0, (scrollViewSize.height - imageViewSize.height) / 2) + + _scrollView.contentInset = UIEdgeInsets( + top: verticalInset, + left: horizontalInset, + bottom: verticalInset, + right: horizontalInset + ) + } + + @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard _hasSetupZoom else { return } + + if _scrollView.zoomScale > _scrollView.minimumZoomScale { + _scrollView.setZoomScale(_scrollView.minimumZoomScale, animated: true) + } else { + let tapLocation = gesture.location(in: _imageView) + let zoomScale = min(_scrollView.maximumZoomScale, _scrollView.minimumZoomScale * 3.0) + let zoomRect = zoomRectForScale(zoomScale, center: tapLocation) + _scrollView.zoom(to: zoomRect, animated: true) + } + } + + private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect { + var zoomRect = CGRect.zero + zoomRect.size.width = _scrollView.frame.size.width / scale + zoomRect.size.height = _scrollView.frame.size.height / scale + zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0) + zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0) + return zoomRect + } +} + +// MARK: - UIScrollViewDelegate +extension NativeImageView: UIScrollViewDelegate { + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return _imageView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + centerImageView() + } +} diff --git a/mobile/ios/Runner/Images/Viewer/NativeImageViewFactory.swift b/mobile/ios/Runner/Images/Viewer/NativeImageViewFactory.swift new file mode 100644 index 0000000000..da67d7fe3a --- /dev/null +++ b/mobile/ios/Runner/Images/Viewer/NativeImageViewFactory.swift @@ -0,0 +1,29 @@ +import Foundation +import Flutter + +public class NativeImageViewFactory: NSObject, FlutterPlatformViewFactory { + public static let id = "native_image_view" + + private let messenger: FlutterBinaryMessenger + + init(messenger: FlutterBinaryMessenger) { + self.messenger = messenger + } + + public func create( + withFrame frame: CGRect, + viewIdentifier viewId: Int64, + arguments args: Any? + ) -> FlutterPlatformView { + NativeImageView( + frame: frame, + viewIdentifier: viewId, + arguments: args, + binaryMessenger: messenger + ) + } + + public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol { + return FlutterStandardMessageCodec.sharedInstance() + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart index 899b6ed545..74d06645e7 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.wid import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart'; import 'package:immich_mobile/presentation/widgets/asset_viewer/video_viewer.widget.dart'; import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; -import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/presentation/widgets/images/native_image.widget.dart'; import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart'; import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart'; @@ -533,24 +533,21 @@ class _AssetViewerState extends ConsumerState { } PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) { - final size = ctx.sizeData; - return PhotoViewGalleryPageOptions( - key: ValueKey(asset.heroTag), - imageProvider: getFullImageProvider(asset, size: size), - heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), - filterQuality: FilterQuality.high, - tightMode: true, - disableScaleGestures: showingBottomSheet, + return PhotoViewGalleryPageOptions.customChild( + disableScaleGestures: true, onDragStart: _onDragStart, onDragUpdate: _onDragUpdate, onDragEnd: _onDragEnd, onTapDown: _onTapDown, onLongPressStart: asset.isMotionPhoto ? _onLongPress : null, - errorBuilder: (_, __, ___) => Container( - width: size.width, - height: size.height, - color: backgroundColor, - child: Thumbnail.fromAsset(asset: asset, fit: BoxFit.contain), + heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'), + filterQuality: FilterQuality.high, + basePosition: Alignment.center, + child: NativeImageView( + key: _getVideoPlayerKey(asset.heroTag), + assetId: (asset as LocalAsset).id, + width: ctx.width, + height: ctx.height, ), ); } diff --git a/mobile/lib/presentation/widgets/images/native_image.widget.dart b/mobile/lib/presentation/widgets/images/native_image.widget.dart new file mode 100644 index 0000000000..cf67782b92 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/native_image.widget.dart @@ -0,0 +1,40 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class NativeImageView extends StatelessWidget { + final String assetId; + final double width; + final double height; + + const NativeImageView({super.key, required this.assetId, required this.width, required this.height}); + + @override + Widget build(BuildContext context) { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return Container( + width: width, + height: height, + color: Colors.grey, + child: const Center(child: Text('PHAsset view only available on iOS')), + ); + } + + return SizedBox( + width: width, + height: height, + child: UiKitView( + viewType: 'native_image_view', + layoutDirection: TextDirection.ltr, + creationParams: {'assetId': assetId}, + creationParamsCodec: const StandardMessageCodec(), + gestureRecognizers: >{ + Factory(() => VerticalDragGestureRecognizer()), + Factory(() => HorizontalDragGestureRecognizer()), + Factory(() => ScaleGestureRecognizer()), + }, + ), + ); + } +}