summaryrefslogtreecommitdiff
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
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
-rw-r--r--Sora/Views/InteractiveImageView.swift177
-rw-r--r--Sora/Views/NativeInteractiveImageScrollView.swift192
-rw-r--r--Sora/Views/Post/Details/PostDetailsImageView.swift4
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? {