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 | |
| 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
| -rw-r--r-- | Sora/Views/InteractiveImageView.swift | 177 | ||||
| -rw-r--r-- | Sora/Views/NativeInteractiveImageScrollView.swift | 192 | ||||
| -rw-r--r-- | Sora/Views/Post/Details/PostDetailsImageView.swift | 4 |
3 files changed, 296 insertions, 77 deletions
diff --git a/Sora/Views/InteractiveImageView.swift b/Sora/Views/InteractiveImageView.swift index 19c4dce..2bafa5d 100644 --- a/Sora/Views/InteractiveImageView.swift +++ b/Sora/Views/InteractiveImageView.swift @@ -3,95 +3,126 @@ import SwiftUI struct InteractiveImageView<MenuItems: View>: View { let image: Image let contextMenu: MenuItems - @State private var screenWidth = 0.0 - @State private var screenHeight = 0.0 - @State private var currentScale = 1.0 - @State private var previousScale = 1.0 - @State private var currentOffset: CGSize = .zero - @State private var previousOffset: CGSize = .zero - @State private var zoomAnchor: UnitPoint = .center + + #if os(iOS) + @State private var isZoomed = false + #else + @State private var screenWidth = 0.0 + @State private var screenHeight = 0.0 + @State private var currentScale = 1.0 + @State private var previousScale = 1.0 + @State private var currentOffset: CGSize = .zero + @State private var previousOffset: CGSize = .zero + @State private var zoomAnchor: UnitPoint = .center + #endif var body: some View { - Group { - image - .resizable() - .scaledToFit() - .contextMenu { if currentScale == 1 { contextMenu } } - } - .scaleEffect(currentScale, anchor: zoomAnchor) - .offset(currentOffset) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ifiOS26Unavailable { $0.clipped() } - .background( - GeometryReader { geometry in - Color.clear - .onAppear { - screenWidth = geometry.size.width - screenHeight = geometry.size.height - } + #if os(iOS) + NativeInteractiveImageScrollView( + isZoomed: $isZoomed, + contentIdentifier: imageIdentity, + minimumZoomScale: 1, + maximumZoomScale: 8, + doubleTapZoomScale: 2 + ) { + image + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) } - ) - .gesture( - MagnifyGesture() - .onChanged { gesture in - withAnimation(.interactiveSpring()) { - if previousScale == 1 { - zoomAnchor = gesture.startAnchor + .contextMenu { if !isZoomed { contextMenu } } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ifiOS26Unavailable { $0.clipped() } + #else + Group { + image + .resizable() + .scaledToFit() + .contextMenu { if currentScale == 1 { contextMenu } } + } + .scaleEffect(currentScale, anchor: zoomAnchor) + .offset(currentOffset) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ifiOS26Unavailable { $0.clipped() } + .background( + GeometryReader { geometry in + Color.clear + .onAppear { + screenWidth = geometry.size.width + screenHeight = geometry.size.height } + } + ) + .gesture( + MagnifyGesture() + .onChanged { gesture in + withAnimation(.interactiveSpring()) { + if previousScale == 1 { + zoomAnchor = gesture.startAnchor + } - currentScale = max(previousScale * gesture.magnification, 1) + currentScale = max(previousScale * gesture.magnification, 1) + } } - } - .onEnded { _ in - previousScale = currentScale - } - .simultaneously( - with: (currentScale > 1 ? DragGesture(minimumDistance: 0) : nil) - .onChanged { gesture in - withAnimation(.interactiveSpring()) { - var newOffset: CGSize = .zero - let offset = gesture.translation + .onEnded { _ in + previousScale = currentScale + } + .simultaneously( + with: (currentScale > 1 ? DragGesture(minimumDistance: 0) : nil) + .onChanged { gesture in + withAnimation(.interactiveSpring()) { + var newOffset: CGSize = .zero + let offset = gesture.translation - newOffset.width = offset.width + previousOffset.width - newOffset.height = offset.height + previousOffset.height + newOffset.width = offset.width + previousOffset.width + newOffset.height = offset.height + previousOffset.height - currentOffset = clampOffset(offset: newOffset) + currentOffset = clampOffset(offset: newOffset) + } } + .onEnded { _ in + previousOffset = currentOffset + } + ) + ) + .highPriorityGesture( + TapGesture(count: 2) + .onEnded { + withAnimation { + currentScale = currentScale == 1 ? 2 : 1 + previousScale = currentScale + currentOffset = .zero + previousOffset = .zero + zoomAnchor = .center } - .onEnded { _ in - previousOffset = currentOffset - } - ) - ) - .highPriorityGesture( - TapGesture(count: 2) - .onEnded { - withAnimation { - currentScale = currentScale == 1 ? 2 : 1 - previousScale = currentScale - currentOffset = .zero - previousOffset = .zero - zoomAnchor = .center } - } - ) + ) + #endif } - private func clampOffset(offset: CGSize = .zero) -> CGSize { - var newOffset = offset + #if os(iOS) + private var imageIdentity: String { + String(describing: image) + } + #endif + + #if !os(iOS) + private func clampOffset(offset: CGSize = .zero) -> CGSize { + var newOffset = offset - if currentScale > 1 { - let maxX = ((screenWidth * currentScale) - screenWidth) / 2 - let maxY = ((screenHeight * currentScale) - screenHeight) / 2 + if currentScale > 1 { + let maxX = ((screenWidth * currentScale) - screenWidth) / 2 + let maxY = ((screenHeight * currentScale) - screenHeight) / 2 - newOffset.width = min(max(-maxX, newOffset.width), maxX) - newOffset.height = min(max(-maxY, newOffset.height), maxY) - } else { - newOffset = .zero - } + newOffset.width = min(max(-maxX, newOffset.width), maxX) + newOffset.height = min(max(-maxY, newOffset.height), maxY) + } else { + newOffset = .zero + } - return newOffset - } + return newOffset + } + #endif } #Preview { 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 diff --git a/Sora/Views/Post/Details/PostDetailsImageView.swift b/Sora/Views/Post/Details/PostDetailsImageView.swift index c33f7db..b1d4498 100644 --- a/Sora/Views/Post/Details/PostDetailsImageView.swift +++ b/Sora/Views/Post/Details/PostDetailsImageView.swift @@ -10,10 +10,6 @@ struct PostDetailsImageView<Placeholder: View>: View { var finalLoadingState: BooruPostLoadingState let placeholder: () -> Placeholder let post: BooruPost? - @State private var currentScale: CGFloat = 1.0 - @State private var finalScale: CGFloat = 1.0 - @State private var currentOffset: CGSize = .zero - @State private var finalOffset: CGSize = .zero #if os(iOS) var keyWindow: UIWindow? { |