import SwiftUI struct InteractiveImageView: View { let image: Image let contextMenu: MenuItems #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 { #if os(iOS) NativeInteractiveImageScrollView( isZoomed: $isZoomed, contentIdentifier: imageIdentity, minimumZoomScale: 1, maximumZoomScale: 8, doubleTapZoomScale: 2 ) { image .resizable() .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) } .contextMenu { if !isZoomed { contextMenu } } .frame(maxWidth: .infinity, maxHeight: .infinity) .ifiOS26Unavailable { $0.clipped() } .accessibilityElement(children: .ignore) .accessibilityLabel(Text("Image")) .accessibilityValue(Text(isZoomed ? "Zoomed in" : "Fit to screen")) .accessibilityHint(Text("Double-tap to zoom. Pinch to adjust zoom level.")) #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) } } .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 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 } } ) .accessibilityElement(children: .ignore) .accessibilityLabel(Text("Image")) .accessibilityValue( Text( currentScale > 1 ? "Zoom \(Int((currentScale * 100).rounded())) percent" : "Fit to screen" ) ) .accessibilityHint(Text("Double-tap to zoom. Pinch to adjust zoom level.")) #endif } #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 newOffset.width = min(max(-maxX, newOffset.width), maxX) newOffset.height = min(max(-maxY, newOffset.height), maxY) } else { newOffset = .zero } return newOffset } #endif } #Preview { InteractiveImageView( image: Image(systemName: "photo"), contextMenu: EmptyView() ) }