summaryrefslogtreecommitdiff
path: root/Sora/Views/NativeInteractiveImageScrollView.swift
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-18 16:23:25 -0800
committerFuwn <[email protected]>2026-02-18 16:27:23 -0800
commite079d76aefd3207c0957a3191d575dae9b0c7f65 (patch)
tree594bd81e55a5188ef03d722cb5ae05d748dd279d /Sora/Views/NativeInteractiveImageScrollView.swift
parentfix: prevent thumbnail grid launch crash from column and data drift (diff)
downloadsora-testing-e079d76aefd3207c0957a3191d575dae9b0c7f65.tar.xz
sora-testing-e079d76aefd3207c0957a3191d575dae9b0c7f65.zip
feat: implement native iOS photos-style image zoom interactions
Diffstat (limited to 'Sora/Views/NativeInteractiveImageScrollView.swift')
-rw-r--r--Sora/Views/NativeInteractiveImageScrollView.swift192
1 files changed, 192 insertions, 0 deletions
diff --git a/Sora/Views/NativeInteractiveImageScrollView.swift b/Sora/Views/NativeInteractiveImageScrollView.swift
new file mode 100644
index 0000000..3ef26d3
--- /dev/null
+++ b/Sora/Views/NativeInteractiveImageScrollView.swift
@@ -0,0 +1,192 @@
+#if os(iOS)
+ import SwiftUI
+ import UIKit
+
+ struct NativeInteractiveImageScrollView<Content: View>: UIViewRepresentable {
+ @Binding var isZoomed: Bool
+ let contentIdentifier: String
+ let minimumZoomScale: CGFloat
+ let maximumZoomScale: CGFloat
+ let doubleTapZoomScale: CGFloat
+ let content: Content
+
+ init(
+ isZoomed: Binding<Bool>,
+ contentIdentifier: String,
+ minimumZoomScale: CGFloat,
+ maximumZoomScale: CGFloat,
+ doubleTapZoomScale: CGFloat,
+ @ViewBuilder content: () -> Content
+ ) {
+ _isZoomed = isZoomed
+ self.contentIdentifier = contentIdentifier
+ self.minimumZoomScale = minimumZoomScale
+ self.maximumZoomScale = maximumZoomScale
+ self.doubleTapZoomScale = doubleTapZoomScale
+ self.content = content()
+ }
+
+ func makeUIView(context: Context) -> UIScrollView {
+ let scrollView = UIScrollView()
+
+ scrollView.delegate = context.coordinator
+ scrollView.minimumZoomScale = minimumZoomScale
+ scrollView.maximumZoomScale = maximumZoomScale
+ scrollView.zoomScale = minimumZoomScale
+ scrollView.bouncesZoom = true
+ scrollView.showsHorizontalScrollIndicator = false
+ scrollView.showsVerticalScrollIndicator = false
+ scrollView.contentInsetAdjustmentBehavior = .never
+ scrollView.decelerationRate = .fast
+
+ let hostingController = UIHostingController(rootView: content)
+
+ hostingController.view.backgroundColor = .clear
+ hostingController.view.translatesAutoresizingMaskIntoConstraints = false
+
+ scrollView.addSubview(hostingController.view)
+
+ NSLayoutConstraint.activate([
+ hostingController.view.leadingAnchor.constraint(
+ equalTo: scrollView.contentLayoutGuide.leadingAnchor
+ ),
+ hostingController.view.trailingAnchor.constraint(
+ equalTo: scrollView.contentLayoutGuide.trailingAnchor
+ ),
+ hostingController.view.topAnchor.constraint(
+ equalTo: scrollView.contentLayoutGuide.topAnchor
+ ),
+ hostingController.view.bottomAnchor.constraint(
+ equalTo: scrollView.contentLayoutGuide.bottomAnchor
+ ),
+ hostingController.view.widthAnchor.constraint(
+ equalTo: scrollView.frameLayoutGuide.widthAnchor
+ ),
+ hostingController.view.heightAnchor.constraint(
+ equalTo: scrollView.frameLayoutGuide.heightAnchor
+ ),
+ ])
+
+ let doubleTapGestureRecognizer = UITapGestureRecognizer(
+ target: context.coordinator,
+ action: #selector(Coordinator.handleDoubleTapGesture(_:))
+ )
+
+ doubleTapGestureRecognizer.numberOfTapsRequired = 2
+
+ scrollView.addGestureRecognizer(doubleTapGestureRecognizer)
+
+ context.coordinator.hostingController = hostingController
+ context.coordinator.hostedView = hostingController.view
+ context.coordinator.latestContentIdentifier = contentIdentifier
+
+ context.coordinator.updateZoomState(for: scrollView)
+ context.coordinator.recenterContent(in: scrollView)
+
+ return scrollView
+ }
+
+ func updateUIView(_ scrollView: UIScrollView, context: Context) {
+ context.coordinator.parent = self
+ context.coordinator.hostingController?.rootView = content
+
+ if context.coordinator.latestContentIdentifier != contentIdentifier {
+ context.coordinator.latestContentIdentifier = contentIdentifier
+ scrollView.setZoomScale(minimumZoomScale, animated: false)
+ scrollView.contentOffset = .zero
+ }
+
+ context.coordinator.recenterContent(in: scrollView)
+ context.coordinator.updateZoomState(for: scrollView)
+ }
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(parent: self)
+ }
+
+ final class Coordinator: NSObject, UIScrollViewDelegate {
+ var parent: NativeInteractiveImageScrollView
+ var hostingController: UIHostingController<Content>?
+ weak var hostedView: UIView?
+ var latestContentIdentifier: String?
+
+ init(parent: NativeInteractiveImageScrollView) {
+ self.parent = parent
+ }
+
+ func viewForZooming(in _: UIScrollView) -> UIView? {
+ hostedView
+ }
+
+ func scrollViewDidZoom(_ scrollView: UIScrollView) {
+ recenterContent(in: scrollView)
+ updateZoomState(for: scrollView)
+ }
+
+ @objc
+ func handleDoubleTapGesture(_ recognizer: UITapGestureRecognizer) {
+ guard let scrollView = recognizer.view as? UIScrollView else { return }
+
+ let minimumZoomScale = scrollView.minimumZoomScale
+ let maximumZoomScale = scrollView.maximumZoomScale
+ let targetZoomScale = min(parent.doubleTapZoomScale, maximumZoomScale)
+
+ if scrollView.zoomScale > minimumZoomScale + 0.01 {
+ scrollView.setZoomScale(minimumZoomScale, animated: true)
+ return
+ }
+
+ guard let hostedView else { return }
+
+ let tapLocation = recognizer.location(in: hostedView)
+ let zoomRectangle = zoomRectangle(
+ for: targetZoomScale,
+ centeredAt: tapLocation,
+ in: scrollView
+ )
+
+ scrollView.zoom(to: zoomRectangle, animated: true)
+ }
+
+ func recenterContent(in scrollView: UIScrollView) {
+ let horizontalInset = max((scrollView.bounds.width - scrollView.contentSize.width) / 2, 0)
+ let verticalInset = max((scrollView.bounds.height - scrollView.contentSize.height) / 2, 0)
+
+ scrollView.contentInset = UIEdgeInsets(
+ top: verticalInset,
+ left: horizontalInset,
+ bottom: verticalInset,
+ right: horizontalInset
+ )
+ }
+
+ func updateZoomState(for scrollView: UIScrollView) {
+ let isCurrentlyZoomed = scrollView.zoomScale > scrollView.minimumZoomScale + 0.01
+
+ if parent.isZoomed != isCurrentlyZoomed {
+ DispatchQueue.main.async {
+ self.parent.isZoomed = isCurrentlyZoomed
+ }
+ }
+ }
+
+ private func zoomRectangle(
+ for targetZoomScale: CGFloat,
+ centeredAt tapLocation: CGPoint,
+ in scrollView: UIScrollView
+ ) -> CGRect {
+ let rectangleWidth = scrollView.bounds.width / targetZoomScale
+ let rectangleHeight = scrollView.bounds.height / targetZoomScale
+ let horizontalOrigin = tapLocation.x - (rectangleWidth / 2)
+ let verticalOrigin = tapLocation.y - (rectangleHeight / 2)
+
+ return CGRect(
+ x: horizontalOrigin,
+ y: verticalOrigin,
+ width: rectangleWidth,
+ height: rectangleHeight
+ )
+ }
+ }
+ }
+#endif