Search code examples
swiftuiuikitgesturedragpinch

Is there an easy way to pinch to zoom and drag any View in SwiftUI?


I have been looking for a short, reusable piece of code that allows to zoom and drag any view in SwiftUI, and also to change the scale independently.


Solution

  • This would be the answer.

    The interesting part that I add is that the scale of the zoomed View can be controled from outside via a binding property. So we don't need to depend just on the pinching gesture, but can add a double tap to get the maximum scale, return to the normal scale, or have a slider (for instance) that changes the scale as we please.

    I owe the bulk of this code to jtbandes in his answer to this question.

    Here you have in a single file the code of the Zoomable and Scrollable view and a Test View to show how it works:

    `

    import SwiftUI
    
    let maxAllowedScale = 4.0
    
    struct TestZoomableScrollView: View {
    
        @State private var scale: CGFloat = 1.0
        
        var doubleTapGesture: some Gesture {
            TapGesture(count: 2).onEnded {
                if scale < maxAllowedScale / 2 {
                    scale = maxAllowedScale
                } else {
                    scale = 1.0
                }
            }
        }
        
        var body: some View {
                VStack(alignment: .center) {
                    Spacer()
                    ZoomableScrollView(scale: $scale) {
                        Image("foto_producto")
                            .resizable()
                            .scaledToFit()
                            .frame(width: 200, height: 200)
                    }
                    .frame(width: 300, height: 300)
                    .border(.black)
                    .gesture(doubleTapGesture)
                    Spacer()
                    Text("Change the scale")
                    Slider(value: $scale, in: 0.5...maxAllowedScale + 0.5)
                    .padding(.horizontal)
                    Spacer()
                }
        }
    }
    
    struct ZoomableScrollView<Content: View>: UIViewRepresentable {
        
        private var content: Content
        @Binding private var scale: CGFloat
    
        init(scale: Binding<CGFloat>, @ViewBuilder content: () -> Content) {
            self._scale = scale
            self.content = content()
        }
    
        func makeUIView(context: Context) -> UIScrollView {
            // set up the UIScrollView
            let scrollView = UIScrollView()
            scrollView.delegate = context.coordinator  // for viewForZooming(in:)
            scrollView.maximumZoomScale = maxAllowedScale
            scrollView.minimumZoomScale = 1
            scrollView.showsVerticalScrollIndicator = false
            scrollView.showsHorizontalScrollIndicator = false
            scrollView.bouncesZoom = true
    
    //      Create a UIHostingController to hold our SwiftUI content
            let hostedView = context.coordinator.hostingController.view!
            hostedView.translatesAutoresizingMaskIntoConstraints = true
            hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
            hostedView.frame = scrollView.bounds
            scrollView.addSubview(hostedView)
    
            return scrollView
        }
    
        func makeCoordinator() -> Coordinator {
            return Coordinator(hostingController: UIHostingController(rootView: self.content), scale: $scale)
        }
    
        func updateUIView(_ uiView: UIScrollView, context: Context) {
            // update the hosting controller's SwiftUI content
            context.coordinator.hostingController.rootView = self.content
            uiView.zoomScale = scale
            assert(context.coordinator.hostingController.view.superview == uiView)
        }
        
        class Coordinator: NSObject, UIScrollViewDelegate {
    
            var hostingController: UIHostingController<Content>
            @Binding var scale: CGFloat
    
            init(hostingController: UIHostingController<Content>, scale: Binding<CGFloat>) {
                self.hostingController = hostingController
                self._scale = scale
            }
    
            func viewForZooming(in scrollView: UIScrollView) -> UIView? {
                return hostingController.view
            }
    
            func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
                self.scale = scale
            }
        }
    }
    

    `

    I think it's the shortest, easiest way to get the desired behaviour. Also, it works perfectly, something that I haven't found in other solutions offered here. For example, the zooming out is smooth and usually it can be jerky if you don't use this approach.

    The slider hast that range to show how the minimun and maximum values are respected, in a real app the range would be 1...maxAllowedScale.

    As for the double tap, the behaviour can be changed very easily depending on what you prefer.

    I attach video to show everything at once:

    enter image description here

    I hope this helps anyone who's looking for this feature.