summaryrefslogtreecommitdiff
path: root/Sora/Views/InteractiveImageView.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/InteractiveImageView.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/InteractiveImageView.swift')
-rw-r--r--Sora/Views/InteractiveImageView.swift177
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 {