imagescrollswiftuipinch

How to pinch and scroll an image in SwiftUI?


I want to add an image viewer in my application but I have trouble implementing it. I want to display an image and allow the user to pinch and scroll in the image to examine it in details. Based on what I've gathered from multiple Internet posts, I have something kinda working, but not exactly. Here is my code:

import SwiftUI

struct ContentView: View {

    @State var currentScale: CGFloat = 1.0
    @State var previousScale: CGFloat = 1.0

    @State var currentOffset = CGSize.zero
    @State var previousOffset = CGSize.zero

    var body: some View {

        ZStack {

            Image("bulldog2")
                .resizable()
                .edgesIgnoringSafeArea(.all)
                .aspectRatio(contentMode: .fit)
                .offset(x: self.currentOffset.width, y: self.currentOffset.height)
                .scaleEffect(self.currentScale)
                .gesture(DragGesture()
                    .onChanged { value in
                        let deltaX = value.translation.width - self.previousOffset.width
                        let deltaY = value.translation.height - self.previousOffset.height
                        self.previousOffset.width = value.translation.width
                        self.previousOffset.height = value.translation.height
                        self.currentOffset.width = self.currentOffset.width + deltaX / self.currentScale
                        self.currentOffset.height = self.currentOffset.height + deltaY / self.currentScale }
                    .onEnded { value in self.previousOffset = CGSize.zero })
                .gesture(MagnificationGesture()
                    .onChanged { value in
                        let delta = value / self.previousScale
                        self.previousScale = value
                        self.currentScale = self.currentScale * delta
                }
                .onEnded { value in self.previousScale = 1.0 })

            VStack {

                Spacer()

                HStack {
                    Text("Menu 1").padding().background(Color.white).cornerRadius(30).padding()
                    Spacer()
                    Text("Menu 2").padding().background(Color.white).cornerRadius(30).padding()
                }
            }
        }
    }
}

The initial view looks like this:

enter image description here

The first problem I have is that I can move the image too far in the way that I can see outside of the image. This can cause the image not to be not visible in the application anymore if it moved too far.

enter image description here

The second problem, which is not a big one, is that I can scale down the image but it becomes too small compared to the view. I want to constraint it so that "fit" would be its smallest size. Is there a better way than constraining self.currentScale and self.previousScale?

enter image description here

The third problem is that if I change the image to fill the space, the bottom menu gets larger that the phone's screen.

enter image description here

I'm not an iOS developer and there is probably a much better way to implement this feature. Thanks for you help.


Solution

  • I can answer 2 of 3 questions. I can't repeat the third one.

    1. you can use GeometryReader and use it's frame and size to make some constraints (I'll show it in example below);
    2. maybe the most simplest and better way is just to use max function, like .scaleEffect(max(self.currentScale, 1.0)).

    Here is changed example:

    struct BulldogImageViewerView: View {
        @State var currentScale: CGFloat = 1.0
        @State var previousScale: CGFloat = 1.0
    
        @State var currentOffset = CGSize.zero
        @State var previousOffset = CGSize.zero
    
        var body: some View {
    
            ZStack {
    
                GeometryReader { geometry in // here you'll have size and frame
    
                    Image("bulldog")
                        .resizable()
                        .edgesIgnoringSafeArea(.all)
                        .aspectRatio(contentMode: .fit)
                        .offset(x: self.currentOffset.width, y: self.currentOffset.height)
                        .scaleEffect(max(self.currentScale, 1.0)) // the second question
                        .gesture(DragGesture()
                            .onChanged { value in
    
                                let deltaX = value.translation.width - self.previousOffset.width
                                let deltaY = value.translation.height - self.previousOffset.height
                                self.previousOffset.width = value.translation.width
                                self.previousOffset.height = value.translation.height
    
                                let newOffsetWidth = self.currentOffset.width + deltaX / self.currentScale
                                // question 1: how to add horizontal constraint (but you need to think about scale)
                                if newOffsetWidth <= geometry.size.width - 150.0 && newOffsetWidth > -150.0 {
                                    self.currentOffset.width = self.currentOffset.width + deltaX / self.currentScale
                                }
    
                                self.currentOffset.height = self.currentOffset.height + deltaY / self.currentScale }
    
                            .onEnded { value in self.previousOffset = CGSize.zero })
    
                        .gesture(MagnificationGesture()
                            .onChanged { value in
                                let delta = value / self.previousScale
                                self.previousScale = value
                                self.currentScale = self.currentScale * delta
                        }
                        .onEnded { value in self.previousScale = 1.0 })
    
                }
    
                VStack {
    
                    Spacer()
    
                    HStack {
                        Text("Menu 1").padding().cornerRadius(30).background(Color.blue).padding()
                        Spacer()
                        Text("Menu 2").padding().cornerRadius(30).background(Color.blue).padding()
                    }
                }
            }
        }
    }
    

    with this code I achieved: