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 building an interactive animation similar to the one of the picture-in-picture view in FaceTime.

00:06 Back when iOS 7 came out, there was a big deal about UIKit's new dynamics system and how you can take any view, swipe it, and interrupt the animation, and how the animation system was physics-based. And although we also have additive animations in SwiftUI, all of this isn't as straightforward. Maybe we can have a look at what it would take to, for example, implement the FaceTime view, which lets you flick your camera preview into one of the four corners of the screen.

Corner Placeholders

01:23 Let's start with a simple prototype. We first create a placeholder view, so that we have something to tap on:

import SwiftUI

struct Placeholder: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .stroke(.secondary, style: .init(dash: [4]))
            .frame(width: 105, height: 120)
    }
}

02:19 We quickly add four of these placeholders to our view, one in each corner of the screen, using a VStack with HStacks and Spacers:

struct ContentView: View {
    var body: some View {
        mainView
            .padding()
    }

    var mainView: some View {
        VStack {
            HStack {
                Placeholder()
                Spacer()
                Placeholder()
            }
            Spacer()
            HStack {
                Placeholder()
                Spacer()
                Placeholder()
            }
        }
    }
}

Matching Geometries

03:14 Now we need a view we can move over one of these four placeholders. We could manually measure the positions and sizes of the placeholders, but we'd have to repeat this whenever the view changes, e.g. when we rotate our device. So, we prefer to use a matched geometry effect to make our draggable view adopt the geometry of one of the corner placeholders, even when those will be made invisible later on.

03:50 In an overlay, we add a blue rectangle with the same corner radius as the placeholders:

struct ContentView: View {
    var body: some View {
        mainView
            .padding()
            .overlay {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.blue.gradient)
            }
    }

    var mainView: some View {
        // ...
    }
}

04:17 The blue rectangle now fills the entire view, but we can give it the same dimensions as one of the corner views by adding matched geometry effects to the placeholders and to the blue rectangle. We specify the blue rectangle's effect to not be the source view, which results in the blue rectangle taking on the geometry of the placeholder view:

struct ContentView: View {
    @Namespace var namespace

    var body: some View {
        mainView
            .padding()
            .overlay {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.blue.gradient)
                    .matchedGeometryEffect(id: 0, in: namespace, isSource: false)
            }
    }

    var mainView: some View {
        VStack {
            HStack {
                Placeholder()
                    .matchedGeometryEffect(id: 0, in: namespace)
                Spacer()
                Placeholder()
                    .matchedGeometryEffect(id: 1, in: namespace)
            }
            Spacer()
            HStack {
                Placeholder()
                    .matchedGeometryEffect(id: 2, in: namespace)
                Spacer()
                Placeholder()
                    .matchedGeometryEffect(id: 3, in: namespace)
            }
        }
    }
}

Switching Corners

05:11 To switch between the corners, we add a state property, selection, and we set it to the index of the corner we want to use in a tap gesture on each of the placeholder views. We use this selection as the ID for the matched geometry effect on the blue rectangle:

struct ContentView: View {
    @Namespace var namespace
    @State private var selection = 0

    var body: some View {
        mainView
            .padding()
            .overlay {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.blue.gradient)
                    .matchedGeometryEffect(id: selection, in: namespace, isSource: false)
            }
    }

    var mainView: some View {
        VStack {
            HStack {
                Placeholder()
                    .matchedGeometryEffect(id: 0, in: namespace)
                    .onTapGesture {
                        selection = 0
                    }
                Spacer()
                Placeholder()
                    .matchedGeometryEffect(id: 1, in: namespace)
                    .onTapGesture {
                        selection = 1
                    }
            }
            Spacer()
            HStack {
                Placeholder()
                    .matchedGeometryEffect(id: 2, in: namespace)
                    .onTapGesture {
                        selection = 2
                    }
                Spacer()
                Placeholder()
                    .matchedGeometryEffect(id: 3, in: namespace)
                    .onTapGesture {
                        selection = 3
                    }
            }
        }
    }
}

06:16 By default, taps are only registered on the border of a placeholder, which makes it very hard to tap the view. By specifying a content shape, we make the entire view tappable:

struct Placeholder: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 16)
            .stroke(.secondary, style: .init(dash: [4]))
            .frame(width: 105, height: 120)
            .contentShape(.rect(cornerRadius: 16))
    }
}

06:44 We put an animation on the blue rectangle to make it move to the newly selected corner, instead of jumping there instantly:

struct ContentView: View {
    @Namespace var namespace
    @State private var selection = 0

    var body: some View {
        mainView
            .padding()
            .overlay {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.blue.gradient)
                    .matchedGeometryEffect(id: selection, in: namespace, isSource: false)
                    .animation(.default, value: selection)
            }
    }

    // ...
}

07:04 If we slow the animation down, and we quickly tap on a few different corners, we can see that the animation can be interrupted and that the rectangle travels along a smooth path between the corners:

.animation(.default.speed(0.2), value: selection)

Plotting the Path

08:05 So far, the implementation has been relatively straightforward by using the matched geometry effect. Adding a drag gesture to pick the blue rectangle up might turn out to be a bit more difficult. And to get that right, it'd be helpful if we can somehow visualize the path of the rectangle. Ideally, we could just record the position of this view at every frame and plot those points as a path by calling trace on it:

struct ContentView: View {
    @Namespace var namespace
    @State private var selection = 0

    var body: some View {
        mainView
            .padding()
            .overlay {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.blue.gradient)
                    .trace()
                    .matchedGeometryEffect(id: selection, in: namespace, isSource: false)
                    .animation(.easeInOut(duration: 4), value: selection)
            }
    }
    // ...
}

08:49 Let's try to implement a TraceModifier. We apply it to a view with a helper method in an extension of View. Inside the modifier, we'll store an array of points:

struct TraceModifier: ViewModifier {
    @State private var points: [CGPoint] = []

    func body(content: Content) -> some View {
        content
    }
}

extension View {
    func trace() -> some View {
        modifier(TraceModifier())
    }
}

09:31 By calling onGeometryChange on the content view — i.e. the blue rectangle in our case — we can read out the origin of the view's frame in the global coordinate space. Let's print this point out to see what happens:

struct TraceModifier: ViewModifier {
    @State private var points: [CGPoint] = []

    func body(content: Content) -> some View {
        content
            .onGeometryChange(for: CGPoint.self, of: { $0.frame(in: .global).origin }) { newValue in
                print(newValue)
            }
    }
}

09:53 Each time we tap a corner, we get one printout of a point. That's not enough for what we want to do; we want to measure the view's position at each step along the way, and not just its last position.

10:12 A long time ago, we reimplemented matched geometry effect. All it does is set a frame and an offset on the target view. And we think these are propagated all the way down to the leaf node, but the traced content view doesn't see the interpolated values.

10:52 We can change this with a modifier called geometryGroup, which isolates the geometry changes. Now, when a new position is set on the geometry group, we do get the interpolated values while the view is animating:

struct TraceModifier: ViewModifier {
    @State private var points: [CGPoint] = []
    
    func body(content: Content) -> some View {
        content
            .onGeometryChange(for: CGPoint.self, of: { $0.frame(in: .global).origin }) { newValue in
                print(newValue)
            }
            .geometryGroup()
    }
}

11:15 We append the received points to the points array, and then in an overlay, we use them to draw a path:

struct TraceModifier: ViewModifier {
    @State private var points: [CGPoint] = []
    
    func body(content: Content) -> some View {
        content
            .onGeometryChange(for: CGPoint.self, of: { $0.frame(in: .global).origin }) { newValue in
                points.append(newValue)
            }
            .geometryGroup()
            .overlay {
                Path { path in
                    guard let firstPoint = points.first else { return }
                    path.move(to: firstPoint)
                    path.addLines(Array(points.dropFirst()))
                }
                .stroke()
            }
    }
}

12:09 We can see the view's movement being drawn as a line, but not at the correct position. The line moves with the view, but we want it to be static to the global coordinate space, so we have to offset the overlay to compensate for the rectangle's own offset. We add a geometry reader to read out the rectangle's frame and we apply its origin as a negative offset to the path overlay:

struct TraceModifier: ViewModifier {
    @State private var points: [CGPoint] = []
    
    func body(content: Content) -> some View {
        content
            .onGeometryChange(for: CGPoint.self, of: { $0.frame(in: .global).origin }) { newValue in
                points.append(newValue)
            }
            .geometryGroup()
            .overlay {
                GeometryReader { proxy in
                    let f = proxy.frame(in: .global)
                    Path { path in
                        guard let firstPoint = points.first else { return }
                        path.move(to: firstPoint)
                        path.addLines(Array(points.dropFirst()))
                    }
                    .stroke()
                    .offset(x: -f.minX, y: -f.minY)
                }
            }
    }
}

Different Animations

13:59 Without slowing down the animation, we have to be very quick to make the rectangle move in something other than a straight line. We could, for example, use a spring animation with a duration of four seconds:

struct ContentView: View {
    @Namespace var namespace
    @State private var selection = 0

    var body: some View {
        mainView
            .padding()
            .overlay {
                RoundedRectangle(cornerRadius: 16)
                    .fill(.blue.gradient)
                    .trace()
                    .matchedGeometryEffect(id: selection, in: namespace, isSource: false)
                    .animation(.interactiveSpring(duration: 4), value: selection)
            }
    }
    // ...
}

14:28 Now we can tap a corner to send the rectangle there, and before it reaches its target, tap a different corner to override the animation. Even though the plotted path now illustrates the position of the top-left corner of the rectangle instead of its center point, we can see how smooth the curves are:

14:43 We can also choose the .easeInOut animation curve, which used to be the default curve on iOS:

15:00 Although this path looks smoother than the one drawn with the spring animation, the latter felt much more responsive, whereas the .easeInOut curve feels more sluggish to respond to our tap gestures.

15:47 We now have some basic tracing in place. Next time, we can take a look at gestures, and see if we can make those work smoothly with the animations.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

179 Episodes · 62h02min

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