Building a Photo Gallery app in SwiftUI Part 4: Adding Multiple Gestures

In this post, I’m going to show you how to add multiple gestures so we can provide functionalities such as double tap to zoom, pinching to zoom, and panning the photo.
Written by

Chris C

Updated on

Apr 29 2024

Table of contents

    – Written by Iñaki Narciso, 24 Oct 2022

    Table of Contents

    1. Introduction
    2. Part 1: Memory Management using PhotoKit
    3. Part 2: Memory Management practices for a photo-grid UI
    4. Part 3: Creating the photo gallery app
    5. Part 4: Adding Multiple Gestures

    Hello, and welcome back! This is the final part of the Building a Photo Gallery app in SwiftUI series.

    In part 3 of the series, we have completed the UI of the photo gallery app consisting of the PhotoLibraryView which shows the photo grid, the PhotoThumbnailView as the GridItem, and the PhotoDetailView which focuses on an image selected from the grid. However, the PhotoDetailView lacks gesture support, and we don’t have any interaction support for inspecting the photo like zooming or panning. A photo gallery app isn’t complete without support for these gestures.

    In this post, I’m going to show you how to add multiple gestures so we can provide functionalities such as double tap to zoom, pinching to zoom, and panning the photo.

    Double Tapping to Zoom

    Adding support for double tapping to zoom is quite easy. We first need to add the @State properties that we’ll use to store the zoomScale value so we can tell if the photo is zoomed or not and how much zoom is applied to our photos.

    struct PhotoDetailView: View {
    	...
        /// Zooming value modifiers that are set by pinching to zoom 
        /// gestures
        @State private var zoomScale: CGFloat = 1
        @State private var previousZoomScale: CGFloat = 1
        private let minZoomScale: CGFloat = 1
        private let maxZoomScale: CGFloat = 5
        ...
    }
    

    Let’s create the function to zoom the photo when the double tap gesture is received, and to reset the zoom state back to normal.

    extension PhotoDetailView {
    	/// Resets the zoom scale back to 1 – the photo scale at 1x zoom
        func resetImageState() {
            withAnimation(.interactiveSpring()) {
                zoomScale = 1
            }
        }
    
    	/// On double tap
        func onImageDoubleTapped() {
    	    /// Zoom the photo to 5x scale if the photo isn't zoomed in
            if zoomScale == 1 {
                withAnimation(.spring()) {
                    zoomScale = 5
                }
            } else {
    	        /// Otherwise, reset the photo zoom to 1x
                resetImageState()
            }
        }
    }
    

    Then we’ll add support for the double tap gesture to the photoView by using the onTapGesture(count, perform) function.

    extension PhotoDetailView {
        var photoView: some View {
            GeometryReader { proxy in
                image?
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .onTapGesture(count: 2, perform: onImageDoubleTapped)
                    .frame(width: proxy.size.width * max(minZoomScale, zoomScale))
                    .frame(maxHeight: .infinity)
            }
        }
    }
    

    Run the app and double tap on the photo on PhotoDetailView. You’ll notice that it would toggle back and forth between zoomed and normal states.

    Next is to add support for pinching to zoom gestures. It’s a great thing that SwiftUI supports pinching gestures by default through a built-in gesture called MagnificationGesture, and adding it is easy.

    Before we go add a MagnificationGesture to the photoView, we first need to define the functions that we’ll need for the MagnificationGesture behaviour.

    To use MagnificationGesture, we need to define what its behaviour would be when the gesture starts, and when the gesture ends.

    extension PhotoDetailView {
    	...
        
        func onZoomGestureStarted(value: MagnificationGesture.Value) {
            withAnimation(.easeIn(duration: 0.1)) {
                let delta = value / previousZoomScale
                previousZoomScale = value
                let zoomDelta = zoomScale * delta
                var minMaxScale = max(minZoomScale, zoomDelta)
                minMaxScale = min(maxZoomScale, minMaxScale)
                zoomScale = minMaxScale
            }
        }
        
        func onZoomGestureEnded(value: MagnificationGesture) {
            previousZoomScale = 1
            if zoomScale <= 1 {
                resetImageState()
            } else if zoomScale > 5 {
                zoomScale = 5
            }
        }
        ...
    }
    

    When the gesture starts, we are going to calculate the zoomScale to accept a value between 1x original scale, and the 5x zoomed scale. We won’t allow any values lower than the original scale, and any values larger than the zoomed scale.

    The entire calculation is wrapped on a withAnimation() closure so we can tell SwiftUI that we need to smoothly animate the zooming behaviour every time the gesture changes.

    When the gesture ends, we will retain the zoomScale so our photo is zoomed according to the gesture input.

    Now we have onZoomGestureStarted and onZoomGestureEnded functions that define the behaviour we wanted, let’s add them up on a MagnificationGesture object, and name it zoomGesture.

    extension PhotoDetailView {
        var zoomGesture: some Gesture {
            MagnificationGesture()
                .onChanged(onZoomGestureStarted)
                .onEnded(onZoomGestureEnded)
        }
    }
    

    Then add it to the photoView as a gesture as follows:

    extension PhotoDetailView {
        var photoView: some View {
            GeometryReader { proxy in
                image?
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .onTapGesture(count: 2, perform: onImageDoubleTapped)
                    .gesture(zoomGesture) // Add it here
                    .frame(width: proxy.size.width * max(minZoomScale, zoomScale))
                    .frame(maxHeight: .infinity)
            }
        }
    }
    

    Run the app again, and you should be able to pinch the screen and zoom. However, you may have noticed that you can’t pan or scroll the photo to move along as you swipe. To do this, you can add a DragGesture to store the drag offset, and apply it to the photoView.offSet modifier so it would move along your pan gesture.

    However, based on experience, it won’t be an ideal solution as it would be difficult to manage the photo offset concerning the bounds of the PhotoDetailView. You will be needing to calculate against the bounds of PhotoDetailView so the photoView won’t go out of bounds.

    Adding DragGesture to Support Panning (Not Recommended)

    You don’t need to follow me on the example below, since the example is only to show you what would go wrong if you choose to implement DragGesture for adding pan gesture support.

    struct PhotoDetailView: View {
    	...
    	// Add a new dragOffset property to store your drag gesture
    	// movement updates
    	@State private var dragOffset: CGSize = .zero
    	...
    }
    
    extension PhotoDetailView {
    	...   
    	// Then declare a function that will store the new offset
    	// based on the actual movement of the user
        func onDragGestureStarted(value: DragGesture.Value) {
            withAnimation(.easeIn(duration: 0.1)) {
                dragOffset = value.translation
            }
        }
    
    	// Declare the `DragGesture` object, and assign the drag changed
    	// behaviour above.
    	var panGesture: some Gesture {
    		DragGesture()
    			.onChanged(onDragGestureStarted)
    	}
    
    	// Lastly, assign the pan gesture to the `photoView`
        var photoView: some View {
            GeometryReader { proxy in
                image?
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .onTapGesture(count: 2, perform: onImageDoubleTapped)
                    .gesture(panGesture) // Add it here
                    .gesture(zoomGesture)
                    .frame(width: proxy.size.width * max(minZoomScale, zoomScale))
                    .frame(maxHeight: .infinity)
                    .offset(dragOffset)
            }
        }
    }
    

    Run the app then observe. Zoom in the photo and do a pan gesture by swiping the photo in the direction you want to go.

    The photo should follow your drag movement, but every time you release the drag movement, the drag offset moves the photo to a new location. If you move the photo to its edges, you’ll notice that the photo can go out of the view bounds, which we don’t want to happen.

    Simulator Screen Shot - iPhone 13 mini - 2022-09-17 at 01 24 58 This is an example of a photo with DragGesture implementation and swiping to the top-left most of the photo, you can see that the top went out of bounds from the view container top.

    Wrapping it with a ScrollView (Recommended)

    If you want to achieve the same result with minimal effort, you can do so by wrapping the image of the photoView in a ScrollView and setting the scroll direction to both .horizontal and .vertical.

    extension PhotoDetailView {
        var photoView: some View {
            GeometryReader { proxy in
    	        // Wrap the image with a scroll view.
    	        // Doing so would limit the photo scroll within the
    	        // bounds of the scroll view, but will still have
    	        // the same functionality of adding pan gesture support.
                ScrollView(
                    [.vertical, .horizontal],
                    showsIndicators: false
                ) {
                    image?
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .onTapGesture(count: 2, perform: onImageDoubleTapped)
                        .gesture(zoomGesture)
                        .frame(width: proxy.size.width * max(minZoomScale, zoomScale))
                        .frame(maxHeight: .infinity)
                }
            }
        }
    }
    

    Run the app again then zoom the photo. You should be able to scroll the photo while limiting the photo frame within the bounds of its container view. Wrapping the image into a ScrollView is the surprisingly easy solution to an otherwise complex problem of the photo frame and view bounds calculations.

    Do you need help starting your development for a Photo Gallery app?

    If you need to create a photo gallery app like the one on this series, but you’re not sure how to start on your own, then you can purchase one of our CWC code kits.

    The photo gallery app code kit features all of the memory management best practices that we’ve discussed in this series and is written in 100% SwiftUI. You’ll start with a photo gallery app with complete basic functionality including memory performance, so you can focus on adding more features and making it your own!

    We also have kits for other apps as well, so please check out our code and design kits at https://codewithchris.wpengine.com/kits.

    This concludes the final part of the series, and I hope that you have learned how to create a photo gallery app in SwiftUI! It was quite a journey, and congratulations on making it this far. Hope you enjoyed the series! Thanks for reading!



    Get started for free

    Join over 2,000+ students actively learning with CodeWithChris