Swift Talk # 309

Building a Photo Grid: Square Grid Cells

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start to build a photo grid view like in the stock Photos app, including gestures and transitions.

00:06 Today, we'll start a new series in which we build a small part of the UI of the Photos app. We want to display a grid of photos and make it interactive: we want to tap a photo to show it fullscreen, and we want to be able to close the photo again by dragging it down.

00:37 We've been covering this case study in our SwiftUI workshop recently, because we realized it isn't trivial to get this layout and the interactive transitions right.

01:03 When we're finished, the app will look like this:

All photos are shown as square grid cells. When we tap a photo, it zooms from the grid into a fullscreen view. We can close the photo again by tapping it, or by dragging it down and releasing it, after which it scales back down and lands back in the grid.

Image Grid

02:26 To get started, we set up a new view with a lazy grid view inside a scroll view. We give the grid adaptive columns with a minimum width of 100 points and a spacing of 3 points. For the content, we're using static photos, which were already added to our project:

struct PhotosView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)]) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                }
            }
        }
    }
}

struct ContentView: View {
    var body: some View {
        PhotosView()
            .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

04:26 If we run this, we see that the photos have their original size:

04:34 When we make the images resizable, they get stretched horizontally to fit in the columns:

struct PhotosView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)]) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                }
            }
        }
    }
}

04:45 But the images still have their original height. By setting the content mode to .fit, the original aspect ratio of the image is respected:

struct PhotosView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)]) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                }
            }
        }
    }
}

04:58 By using the resizable modifier, the image can be stretched in any direction. The aspectRatio modifier sends a proposed size of nil to the image view to figure out the image's natural dimensions. It then uses that information to figure out the aspect ratio.

Square Cells

05:31 As we can see, the photos all have different aspect ratios. Our next step is to clip the grid's images to squares.

05:46 The grid computes the width of each cell by looking at the available space and the adaptive column specification. It tries to fit in as many columns as possible, preferring the minimum width we specified for the columns. The remaining space is divided over the columns, up to their maximum widths.

06:47 The grid computes the width for each cell and proposes this width to the cell. For the height, it proposes nil. The height for each row is determined by the tallest size returned by the row's cells. We want to create a square frame from this proposed size. But setting an explicit aspect ratio of 1 doesn't do much good:

struct PhotosView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)]) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .aspectRatio(1, contentMode: .fit)
                }
            }
        }
    }
}

08:09 Another problem we see is that the images overlap each other. This is because SwiftUI draws everything out of bounds by default. We have to clip the image so that only the part inside the square frame is visible. If we set a border, we can see that the frames actually aren't square, and so clipping wouldn't do anything:

struct PhotosView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)]) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .border(.red)
                        .aspectRatio(1, contentMode: .fit)
                }
            }
        }
    }
}

08:55 The aspect ratio modifier may propose a size of 100 by 100 points, but the image inside the frame will become 200 by 100 to satisfy the .fill content mode. This is normal behavior.

09:35 What we need is a way to accept the proposed size from the aspect ratio modifier. And as we saw when we reimplemented parts of SwiftUI's layout system, a flexible frame clamps the proposed size to its bounds and then reports the result back as its final size. So by applying a flexible frame whose width and height both range from zero to infinity, we make sure to always accept the proposed size, regardless of the view's contents:

struct PhotosView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)]) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                        .aspectRatio(1, contentMode: .fit)
                }
            }
        }
    }
}

11:24 Now we can clip the view to the square frame, and we also set the row spacing to 3 points to make the spacing uniform:

struct PhotosView: View {
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)], spacing: 3) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                        .clipped()
                        .aspectRatio(1, contentMode: .fit)
                }
            }
        }
    }
}

12:08 It was a bit more difficult than we'd expect, but we now have a grid of square images.

Detail View

12:17 Next, we want to show a detail view when we tap a photo in the grid. We move the grid view into a separate property, and we wrap it in the body view, along with a detail view. The detail view only appears if a state property, detail, is set to one of the photos' indices:

struct PhotosView: View {
    @State private var detail: Int? = nil
    
    var body: some View {
        ZStack {
            photoGrid
            detailView
        }
    }
    
    @ViewBuilder
    var detailView: some View {
        if let d = detail {
            Image("beach_\(d)")
                .resizable()
                .aspectRatio(contentMode: .fit)
        }
    }
    
    var photoGrid: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)], spacing: 3) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                        .clipped()
                        .aspectRatio(1, contentMode: .fit)
                }
            }
        }
    }
}

14:03 A tap gesture on each image in the grid assigns the image's index to detail. We also add a tap gesture to the detail view, which closes the detail view by setting the detail state property back to nil:

struct PhotosView: View {
    @State private var detail: Int? = nil
    
    var body: some View {
        ZStack {
            photoGrid
            detailView
        }
    }
    
    @ViewBuilder
    var detailView: some View {
        if let d = detail {
            Image("beach_\(d)")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .onTapGesture {
                    detail = nil
                }
        }
    }
    
    var photoGrid: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)], spacing: 3) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                        .clipped()
                        .aspectRatio(1, contentMode: .fit)
                        .onTapGesture {
                            detail = ix
                        }
                }
            }
        }
    }
}

Transition

14:26 We now want to animate between these states, so we apply an implicit animation that's triggered when detail changes. We also want to hide the grid when the detail view is displayed, so we set the grid's opacity to 0 when a photo is selected:

struct PhotosView: View {
    @State private var detail: Int? = nil
    
    var body: some View {
        ZStack {
            photoGrid
                .opacity(detail == nil ? 1 : 0)
            detailView
        }
        .animation(.default, value: detail)
    }
    
    // ...
}

15:15 Now we see two things happening: the opacity modifier on the grid animates between 0 and 1, and the detail view fades in and out of the view. We add a toggle to switch between normal and slower animations to see the transition more clearly:

struct PhotosView: View {
    @State private var detail: Int? = nil
    @State private var slowAnimations = false
    
    var body: some View {
        VStack {
            Toggle("Slow Animations", isOn: $slowAnimations)
            ZStack {
                photoGrid
                    .opacity(detail == nil ? 1 : 0)
                detailView
            }
            .animation(.default.speed(slowAnimations ? 0.2 : 1), value: detail)
        }
    }
    
    // ...
}

We prefer including a toggle like the one above over changing the animation speed in the simulator, because in the latter scenario, it's easy to forget to disable slow animations, and then the app starts up very slowly.

16:47 When opening a photo, we want the detail view to start out at the photo's position and size in the grid and then animate to fullscreen, so it makes sense to use the matched geometry effect. We add the effect to each grid view, before the aspect ratio modifier, using the photo's index as the identifier:

struct PhotosView: View {
    // ...
    
    var photoGrid: some View {
        ScrollView {
            LazyVGrid(columns: [.init(.adaptive(minimum: 100, maximum: .infinity), spacing: 3)], spacing: 3) {
                ForEach(1..<11) { ix in
                    Image("beach_\(ix)")
                        .resizable()
                        .matchedGeometryEffect(id: ix, in: namespace)
                        .aspectRatio(contentMode: .fill)
                        .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
                        .clipped()
                        .aspectRatio(1, contentMode: .fit)
                        .onTapGesture {
                            detail = ix
                        }
                }
            }
        }
    }
}

18:09 We create a namespace for the matched geometry effect:

struct PhotosView: View {
    @State private var detail: Int? = nil
    @State private var slowAnimations = false
    @Namespace private var namespace
    
    // ...
}

18:27 And we also need to set the effect on the detail view:

struct PhotosView: View {
    // ...
    
    @ViewBuilder
    var detailView: some View {
        if let d = detail {
            Image("beach_\(d)")
                .resizable()
                .matchedGeometryEffect(id: d, in: namespace)
                .aspectRatio(contentMode: .fit)
                .onTapGesture {
                    detail = nil
                }
        }
    }
    
    // ...
}

18:52 The problem is we now have two instances of the effect in the view hierarchy, because we're keeping the grid view around when the detail view is displayed. We may have to toggle the two effects depending on the direction of the transition, but for now, we set isSource to false on the detail's effect:

struct PhotosView: View {
    // ...
    
    @ViewBuilder
    var detailView: some View {
        if let d = detail {
            Image("beach_\(d)")
                .resizable()
                .matchedGeometryEffect(id: d, in: namespace, isSource: false)
                .aspectRatio(contentMode: .fit)
                .onTapGesture {
                    detail = nil
                }
        }
    }
    
    // ...
}

19:18 But this doesn't quite work. The detail view is held in the position and size of the grid cell, because the cell is the source for the effect's geometry, and it never leaves the view hierarchy. If we want to use the effect to transition between the grid cell and the full-size detail view, the matched geometry effect needs to be active just before the transition and inactive after the transition. This involves some more work, so we'll have to look at it next time.

Resources

  • Sample Code

    Written in Swift 5.6

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

135 Episodes · 48h28min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes