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

3

1

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.

Sporophore answered 28/10, 2022 at 16:41 Comment(3)
Put the answer as, well, answer stackoverflow.com/help/self-answerPlatinumblond
It’s perfectly fine to post an answer to your own question but to directly post an answer like this is not how stackoverflow works. Please edit this so you ask a question and then post an answer to the questionMcpherson
Ok, I'm going to change it. My mistake.Sporophore
S
7

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.

Sporophore answered 28/10, 2022 at 16:52 Comment(1)
Thanks for posting the solution you found, I was looking for something like thisUnitarianism
Y
8

Based on your solution, I've implemented more dynamic struct that allows you to use it anywhere you'd like with any kind of View you'd like, animates double tap scale changes and zooming to the specific point that the user double tapped on;

How to use it:

//  ContentView.swift

import SwiftUI

struct ContentView: View {
    var body: some View {
        ZoomableContainer{
            // Put here any `View` you'd like (e.g. `Image`, `Text`)
        }
    }
}

The implementation:

//  ZoomableContainer.swift

import SwiftUI

fileprivate let maxAllowedScale = 4.0

struct ZoomableContainer<Content: View>: View {
    let content: Content

    @State private var currentScale: CGFloat = 1.0
    @State private var tapLocation: CGPoint = .zero

    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    func doubleTapAction(location: CGPoint) {
        tapLocation = location
        currentScale = currentScale == 1.0 ? maxAllowedScale : 1.0
    }

    var body: some View {
        ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) {
            content
        }
        .onTapGesture(count: 2, perform: doubleTapAction)
    }

    fileprivate struct ZoomableScrollView<Content: View>: UIViewRepresentable {
        private var content: Content
        @Binding private var currentScale: CGFloat
        @Binding private var tapLocation: CGPoint

        init(scale: Binding<CGFloat>, tapLocation: Binding<CGPoint>, @ViewBuilder content: () -> Content) {
            _currentScale = scale
            _tapLocation = tapLocation
            self.content = content()
        }

        func makeUIView(context: Context) -> UIScrollView {
            // Setup the UIScrollView
            let scrollView = UIScrollView()
            scrollView.delegate = context.coordinator // for viewForZooming(in:)
            scrollView.maximumZoomScale = maxAllowedScale
            scrollView.minimumZoomScale = 1
            scrollView.bouncesZoom = true
            scrollView.showsHorizontalScrollIndicator = false
            scrollView.showsVerticalScrollIndicator = false
            scrollView.clipsToBounds = false

            // 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: content), scale: $currentScale)
        }

        func updateUIView(_ uiView: UIScrollView, context: Context) {
            // Update the hosting controller's SwiftUI content
            context.coordinator.hostingController.rootView = content

            if uiView.zoomScale > uiView.minimumZoomScale { // Scale out
                uiView.setZoomScale(currentScale, animated: true)
            } else if tapLocation != .zero { // Scale in to a specific point
                uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true)
                // Reset the location to prevent scaling to it in case of a negative scale (manual pinch)
                // Use the main thread to prevent unexpected behavior
                DispatchQueue.main.async { tapLocation = .zero }
            }

            assert(context.coordinator.hostingController.view.superview == uiView)
        }

        // MARK: - Utils

        func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect {
            let scrollViewSize = scrollView.bounds.size

            let width = scrollViewSize.width / scale
            let height = scrollViewSize.height / scale
            let x = center.x - (width / 2.0)
            let y = center.y - (height / 2.0)

            return CGRect(x: x, y: y, width: width, height: height)
        }

        // MARK: - Coordinator

        class Coordinator: NSObject, UIScrollViewDelegate {
            var hostingController: UIHostingController<Content>
            @Binding var currentScale: CGFloat

            init(hostingController: UIHostingController<Content>, scale: Binding<CGFloat>) {
                self.hostingController = hostingController
                _currentScale = scale
            }

            func viewForZooming(in scrollView: UIScrollView) -> UIView? {
                return hostingController.view
            }

            func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
                currentScale = scale
            }
        }
    }
}

Yet answered 9/7, 2023 at 18:57 Comment(0)
S
7

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.

Sporophore answered 28/10, 2022 at 16:52 Comment(1)
Thanks for posting the solution you found, I was looking for something like thisUnitarianism
M
1

This is the double tap to zoom version. Thanks to @Antonio Calvo for the base.

struct TapableImage: View {
@GestureState private var position = CGSize.zero
@State private var offset = CGSize.zero     // 205-242
@State private var scale: CGFloat = 1.0
let maxAllowedScale = 4.0
var image: String

func resetStatus(){
    self.offset = CGSize.zero
    self.scale = 1.0
}

var doubleTapGesture : some Gesture {
    SpatialTapGesture(count: 2)
        .onEnded { value in
            withAnimation {
                if scale < maxAllowedScale / 2 {
                    scale = maxAllowedScale
                    offset.width = (205 - value.location.x) * 4
                    offset.height = (242 - value.location.y) * 4
                } else {
                    resetStatus()
                }
            }
        }
}

var pinchGesture: some Gesture {
    MagnificationGesture()
        .onChanged { value in
            if (value.magnitude > 1.0 && value.magnitude <= maxAllowedScale) {
                self.scale = value.magnitude
            }
        }
}

var dragGesture: some Gesture {
    DragGesture()
        .updating($position) { currentState, gestureState, _ in
            gestureState = currentState.translation
        }.onEnded { value in
            let widthBoundary = 30.0
            let heightBoundary = 60.0
            offset.height += value.translation.height
            offset.width += value.translation.width
            
            if (scale <= 1.0) {
                if (offset.height > heightBoundary || offset.height < 0 - heightBoundary || offset.width > widthBoundary || offset.width < 0 - widthBoundary) {
                    withAnimation {
                        resetStatus()
                    }
                }
            }
        }
}

var body: some View {
    Image(image)
        .resizable()
        .scaledToFit()
        .scaleEffect(scale)
        .offset(x: offset.width + position.width, y: offset.height + position.height)
        .gesture(doubleTapGesture)
        .gesture(SimultaneousGesture(pinchGesture, dragGesture))
}

}

Morril answered 15/1 at 15:51 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.