niklassaers
niklassaers

Reputation: 8830

SwiftUI: MagnificationGesture to zoom in centered on a CGPoint

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)
        }
    }
}

Upvotes: 14

Views: 2148

Answers (2)

Ice
Ice

Reputation: 757

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 zoomIn = scale > scaleValue ? false : true
        let scale = min(max(scale.magnitude, 0), 20.0)
        scaleValue = scale
        if zoomIn {
            if self.scale > minZoomStep {
                self.scale -= zoomStep
            }
        } else {
            if self.scale < maxZoomStep {
                self.scale += zoomStep
            }
        }
    }
}

Upvotes: -1

Jack Goossen
Jack Goossen

Reputation: 1281

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)
    }
}

Upvotes: 3

Related Questions