Files
immich/mobile/ios/Runner/Images/Viewer/NativeImageView.swift
mertalev 56caf6a133 hdr image viewer
update xcode project

use existing asset fetch

pinch zoom

fix xcode

debug scaling
2025-09-09 00:22:29 -04:00

220 lines
7.0 KiB
Swift

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()
}
}