diff options
| author | Fuwn <[email protected]> | 2026-02-18 16:23:25 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-18 16:27:23 -0800 |
| commit | e079d76aefd3207c0957a3191d575dae9b0c7f65 (patch) | |
| tree | 594bd81e55a5188ef03d722cb5ae05d748dd279d /Sora/Views/NativeInteractiveImageScrollView.swift | |
| parent | fix: prevent thumbnail grid launch crash from column and data drift (diff) | |
| download | sora-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.swift | 192 |
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 |