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.