SwiftUI: MagnificationGesture to zoom in centered on a CGPoint
Asked Answered
P

2

13

I have a piece of code that allows you to zoom in and out on a circle with a gradient using the magnification gesture. This works fine if I place my fingers in the middle of the screen and zoom, but if I place my fingers on the edge of the screen and do the magnification gesture, I want it to zoom in on the point in between my fingers. Right now, it still magnifies with the center of the screen as center for the magnification.

How can I modify my code to allow the users to center on the CGPoint right between the placement of their finger?

struct ContentView: View {
    @GestureState var magnificationState = MagnificationState.inactive
    @State var viewMagnificationState = CGFloat(1.0)
    
    var magnificationScale: CGFloat {
        return viewMagnificationState * magnificationState.scale
    }
    
    var body: some View {
        let gradient = Gradient(colors: [.red, .yellow, .green, .blue, .purple, .red, .yellow, .green, .blue, .purple, .red, .yellow, .green, .blue, .purple])
        
        let magnificationGesture = MagnificationGesture()
            .updating($magnificationState) { value, state, transaction in
                state = .zooming(scale: value)
            }.onEnded { value in
                self.viewMagnificationState *= value
            }
        
        
        Circle()
            .fill(
                RadialGradient(gradient: gradient, center: .center, startRadius: 50, endRadius: 2000)
            )
            .frame(width: 2000, height: 2000)
            .scaleEffect(magnificationScale)
            .gesture(magnificationGesture)
    }
}

enum MagnificationState {
    case inactive
    case zooming(scale: CGFloat)
    
    var scale: CGFloat {
        switch self {
        case .zooming(let scale):
            return scale
        default:
            return CGFloat(1.0)
        }
    }
}
Pomp answered 19/5, 2021 at 13:52 Comment(1)
Unfortunately this is still (!) not possible in SwiftUI. Your best bet is to create a UIViewRepresentable with a transparent view and use a UIPinchGestureRecognizer on that view to get the center of the pinch. It's tricky to get it right though, especially if you want to combine it with panning and rotating.Puckery
P
2

Zooming while centering on an anchor point is still (!) not supported in SwiftUI. As a workaround, we can use UIPinchGestureRecognizer on a transparent UIView with UIViewRepresentable. Zooming with an anchor point is essentially scaling and translating. We can apply this to a view with a transformEffect view modifier. This view modifier applies a CGAffineTransform to the view.

The following extension simplifies scaling around an anchor point:

extension CGAffineTransform {
    func scaled(by scale: CGFloat, with anchor: CGPoint) -> CGAffineTransform {
        self
            .translatedBy(x: anchor.x, y: anchor.y)
            .scaledBy(x: scale, y: scale)
            .translatedBy(x: -anchor.x, y: -anchor.y)
    }
}

GestureTransformView is a UIViewRepresentable with a binding to a transform. We will update the transform in the delegate for the UIPinchGestureRecognizer.

struct GestureTransformView: UIViewRepresentable {
    @Binding var transform: CGAffineTransform

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        
        let zoomRecognizer = UIPinchGestureRecognizer(
            target: context.coordinator,
            action: #selector(Coordinator.zoom(_:)))
        
        zoomRecognizer.delegate = context.coordinator
        view.addGestureRecognizer(zoomRecognizer)
        context.coordinator.zoomRecognizer = zoomRecognizer
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
}

extension GestureTransformView {
    class Coordinator: NSObject, UIGestureRecognizerDelegate {
        var parent: GestureTransformView
        var zoomRecognizer: UIPinchGestureRecognizer?

        var startTransform: CGAffineTransform = .identity
        var pivot: CGPoint = .zero
        
        init(_ parent: GestureTransformView){
            self.parent = parent
        }
        
        func setGestureStart(_ gesture: UIGestureRecognizer) {
            startTransform = parent.transform
            pivot = gesture.location(in: gesture.view)
        }
        
        @objc func zoom(_ gesture: UIPinchGestureRecognizer) {
            switch gesture.state {
            case .began:
                setGestureStart(gesture)
                break
            case .changed:
                applyZoom()
                break
            case .cancelled:
                fallthrough
            case .ended:
                applyZoom()
                startTransform = parent.transform
                zoomRecognizer?.scale = 1
            default:
                break
            }
        }
        
        func applyZoom() {
            let gestureScale = zoomRecognizer?.scale ?? 1
            parent.transform = startTransform
                .scaled(by: gestureScale, with: pivot)
        }
    }
}

And this is how you can use the GestureTransformView. Note that the transformEffect is applied to the Stack, not the Circle. This makes sure that the (previous) transformation is applied correctly to the overlay as well.

struct ContentView: View {
    @State var transform: CGAffineTransform = .identity
    
    var body: some View {
        let gradient = Gradient(colors: [.red, .yellow, .green, .blue, .purple,
                                         .red, .yellow, .green, .blue, .purple,
                                         .red, .yellow, .green, .blue, .purple])
        ZStack {
            Circle()
                .fill(
                    RadialGradient(gradient   : gradient,
                                   center     : .center,
                                   startRadius: 50,
                                   endRadius  : 2000)
                )
                .frame(width: 2000, height: 2000)
                .overlay {
                    GestureTransformView(transform: $transform)
                }
        }   .transformEffect(transform)
    }
}
Puckery answered 7/11, 2023 at 14:11 Comment(0)
M
0

You could achieve that using MagnificationGesture + Custom logic Video result

struct ZoomInOutView: View {
    // Scale value
    @State private var scale: CGFloat = 1.0
    // Scale value for detecting in/out direction
    @State private var scaleValue: CGFloat = 0
    // Scale step, if need faster speed update step
    @State private var zoomStep: CGFloat = 0.2
    // Scale bounds
    let minZoomStep: CGFloat = 1.0
    let maxZoomStep: CGFloat = 20.0
    
    var body: some View {
        ZStack {
            // Could be any UI
            Color.red.frame(width: 50, height: 50)
                .clipShape(Circle())
            // Subscribe scale update to this element
                .scaleEffect(scale)
            
            // "Invisible" view for detecting gestures, just put it at top of the stack
            Color.white.opacity(0.0001)
                .gesture(MagnificationGesture().onChanged { updateScale($0) })
        }
        .onChange(of: scale, perform: { value in
            // Any logic based on updated value gradient something else etc.
            print("Zoom scale", value)
        })
    }
    
    private func updateScale(_ scale: MagnificationGesture.Value) {
        let zoonIn = scale > scaleValue ? false : true
        let scale = min(max(scale.magnitude, 0), 20.0)
        scaleValue = scale
        if zoonIn {
            if self.scale > minZoomStep {
                self.scale -= zoomStep
            }
        } else {
            if self.scale < maxZoomStep {
                self.scale += zoomStep
            }
        }
    }
}
Moreta answered 14/2 at 14:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.