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/InteractiveImageView.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/InteractiveImageView.swift')
| -rw-r--r-- | Sora/Views/InteractiveImageView.swift | 177 |
1 files changed, 104 insertions, 73 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 { |