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 implement a custom version of SwiftUI's aspectRatio modifier that can be enabled or disabled.

00:06 A while ago, Joe asked how to conditionally use an aspect ratio modifier in SwiftUI, because they wanted to programmatically enable or disable the modifier.

00:25 If we place a Color.teal in our view, we can see that, by default, the shape fills the safe area. If we give the shape a fixed aspect ratio of 16:9 with a .fit content mode, then the shape is resized such that its dimensions use the given aspect ratio and it fits in the safe area.

00:48 If we pass in nil for the optional ratio parameter, we might think that the aspect ratio modifier gets disabled, but that's not what happens:

struct ContentView: View {
    var body: some View {
        VStack {
            Color.teal
                .aspectRatio(nil, contentMode: .fit)
        }
    }
}

00:58 This nil tells the modifier to use the aspect ratio of the underlying view's ideal size. To find the ideal size, the modifier proposes a size of nil by nil to the Color.teal. Because the Color.teal view doesn't have a defined size, it reports the default size of 10 by 10 points, which results in an aspect ratio of 1 being applied to the frame.

Toggling an Aspect Ratio Modifier

02:17 Let's try to implement a modifier that can conditionally modify a view's aspect ratio. Just like SwiftUI's version, it takes an optional ratio and a content mode, but also an enabled parameter. The naive way to implement the modifier would be to use an if statement:

extension View {
    @ViewBuilder
    func conditionalAspectRatio(_ ratio: CGFloat?, contentMode: ContentMode, enabled: Bool = true) -> some View {
        if enabled {
            self.aspectRatio(ratio, contentMode: contentMode)
        } else {
            self
        }
    }
}

03:06 We call the new modifier in our view, and we add a switch to toggle the modifier on and off:

struct ContentView: View {
    @State private var enabled = true

    var body: some View {
        VStack {
            Color.teal
                .conditionalAspectRatio(nil, contentMode: .fit, enabled: enabled)
            Toggle("Aspect Ratio", isOn: $enabled)
        }
    }
}

03:52 When we run this, everything seems to work as expected; the aspect ratio changes when we toggle the switch. But we can see there's an unexpected effect when we add an animation to our view:

struct ContentView: View {
    @State private var enabled = true

    var body: some View {
        VStack {
            Color.teal
                .conditionalAspectRatio(nil, contentMode: .fit, enabled: enabled)
            Toggle("Aspect Ratio", isOn: $enabled)
        }
        .animation(.default.speed(0.2), value: enabled)
    }
}

04:18 We might expect the shape's frame to change between the two aspect ratios, but instead, there's a fade between the two states. When we see one view fading in and another fading out, it's usually an indication that we're really dealing with two different views. And that's also true in this case. The if-else statement in the view builder becomes a ConditionalContent view, which basically contains two subviews: one for the if branch, and one for the else branch. So, as far as SwiftUI is concerned, these are two independent views, which means that when we transition from one view to the other, we lose the first view's state and any animations that might be going on inside the view.

05:36 We could try using a matched geometry effect to connect the two views, and then we'd at least get the frame to animate smoothly, but that doesn't solve the other problems: we'd still be resetting animations and state when we switch between the views. So let's come up with a different solution.

Testing with an Animation

06:35 But first, let's actually add a looping animation to our view so that we can see how it breaks. We move the Color.teal into a new view, and we overlay an image that we can animate to scale up and down. We use the heart icon from SF Symbols, we make it resizable so that it fills its frame, we apply an aspect ratio so that we don't distort the image, and we create a phase animator to apply a scale effect. In the phase animator's closure, we receive the view and the current phase value, which is interpolated between the passed-in values:

struct TestView: View {
    var body: some View {
        Color.teal
            .overlay {
                Image(systemName: "heart")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .symbolVariant(.fill)
                    .foregroundColor(.red)
                    .frame(width: 150, height: 150)
                    .phaseAnimator([0.5, 1]) { view, phase in
                        view.scaleEffect(phase)
                    }
            }
    }
}

struct ContentView: View {
    @State private var enabled = true
    
    var body: some View {
        VStack {
            TestView()
                .conditionalAspectRatio(nil, contentMode: .fit, enabled: enabled)
            Toggle("Aspect Ratio", isOn: $enabled)
        }
        .animation(.default.speed(0.2), value: enabled)
    }
}

08:09 The heart scales up and down with the default .bounce animation, but we can change this to a slower animation that eases in and out by providing another trailing closure. This closure receives the phase value as well so that we can provide different animation curves for the various values, but we can ignore this parameter and return the same curve for each phase:

struct TestView: View {
    var body: some View {
        Color.teal
            .overlay {
                Image(systemName: "heart")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .symbolVariant(.fill)
                    .foregroundColor(.red)
                    .frame(width: 150, height: 150)
                    .phaseAnimator([0.5, 1]) { view, phase in
                        view.scaleEffect(phase)
                    } animation: { _ in
                        Animation.easeInOut(duration: 1)
                    }
            }
    }
}

08:46 Now, when we toggle the conditional aspect ratio on and off, we can see that the animation doesn't continue across the transition between views, but that it restarts each time.

09:18 This proves that the view really gets thrown out, along with its state. If we want to fix that, we definitely have to get rid of the if statement in our modifier.

Switching Layouts

09:37 In cases like this, where a modifier can be toggled on or off, SwiftUI tends to implement the modifier to switch between different layouts. The easiest example of how this works is when we take an HStackLayout and a VStackLayout, we wrap them in AnyLayouts, and we switch between the two. SwiftUI can animate the children of these layouts while keeping the identity of the views stable. We can use a similar trick here — we'll wrap our view in a ConditionalAspectRatio layout that can conditionally apply an aspect ratio to its child view. Unfortunately, this also means we can no longer use the built-in aspectRatio modifier, so we'll have to recreate it ourselves.

10:25 First, we set up a struct that conforms to the Layout protocol, and we give it the necessary properties:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool


}

10:45 To conform to Layout, we need to implement two things: sizeThatFits and placeSubviews. The first thing we can do is assert that we have a single subview in sizeThatFits and place that subview at the origin of the proposed bounds:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        

    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews[0].place(at: bounds.origin, proposal: proposal)
    }
}

11:14 In sizeThatFits, we can check whether enabled is true. If it's not, we forward the value of the subview, which is equivalent to this layout wrapper not being there at all:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        guard enabled else {
            return subviews[0].sizeThatFits(proposal)
        }


    }
    // ...
}

11:39 Now that we know the modifier is enabled, we need to compute a new proposed size based on the given ratio. And if the ratio is nil, we need to know the underlying aspect ratio of the child view. We get the latter by proposing an unspecified size to the child view:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        guard enabled else {
            return subviews[0].sizeThatFits(proposal)
        }
        let aspectRatio = ratio ?? subviews[0].sizeThatFits(.unspecified).aspectRatio


    }
    // ...
}

12:29 We add a helper to compute the aspect ratio from a CGSize:

extension CGSize {
    var aspectRatio: CGFloat {
        width / height
    }
}

12:59 We now know that we have an aspect ratio, so the next step is to compute the size to propose to the subview. Depending on the specified content mode, we need to propose a size that either fills the available space or fits inside it. For now, we'll just focus on the content mode we're using and ignore the other case:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        guard enabled else {
            return subviews[0].sizeThatFits(proposal)
        }
        let aspectRatio = ratio ?? subviews[0].sizeThatFits(.unspecified).aspectRatio
        switch contentMode {
        case .fit:
            

        case .fill:
            fatalError()
        }
    }
    // ...
}

13:46 For the .fit case, we can compare the width of the proposed size to the width we get from multiplying the proposed height with the aspect ratio. If we take the lesser of the two, we'll find a size that fits the proposed size:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        guard enabled else {
            return subviews[0].sizeThatFits(proposal)
        }
        let aspectRatio = ratio ?? subviews[0].sizeThatFits(.unspecified).aspectRatio
        switch contentMode {
        case .fit:
            let width = min(proposal.width!, proposal.height! * aspectRatio)

        case .fill:
            fatalError()
        }
    }
    // ...
}

15:30 Using the width and the aspect ratio, we can also compute the correct height:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        guard enabled else {
            return subviews[0].sizeThatFits(proposal)
        }
        let aspectRatio = ratio ?? subviews[0].sizeThatFits(.unspecified).aspectRatio
        switch contentMode {
        case .fit:
            let width = min(proposal.width!, proposal.height! * aspectRatio)
            return .init(width: width, height: width/aspectRatio)
        case .fill:
            fatalError()
        }
    }
    // ...
}

15:52 We change the modifier to use the new layout instead of an if statement:

extension View {
    @ViewBuilder
    func conditionalAspectRatio(_ ratio: CGFloat?, contentMode: ContentMode, enabled: Bool = true) -> some View {
        ConditionalAspectRatioLayout(ratio: ratio, contentMode: contentMode, enabled: enabled) {
            self
        }
    }
}

16:31 This looks strange — the shape is positioned wrongly. The problem is that we're returning the computed size, but what we've actually computed there is the size we should propose to the child view. So we should first propose that size to the child, and then return the size it reports back:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        guard enabled else {
            return subviews[0].sizeThatFits(proposal)
        }
        let aspectRatio = ratio ?? subviews[0].sizeThatFits(.unspecified).aspectRatio
        switch contentMode {
        case .fit:
            let width = min(proposal.width!, proposal.height! * aspectRatio)
            let childProposal = CGSize(width: width, height: width/aspectRatio)
            return subviews[0].sizeThatFits(.init(childProposal))
        case .fill:
            fatalError()
        }
    }
    // ...
}

17:21 We're still off, because we aren't passing on the correct proposal to the child view in placeSubviews. Rather than forwarding the proposal we receive, we need to do the same calculation as above so that we propose a size with the correct aspect ratio.

17:56 We pull the calculation of the proposed size for the child view out to a method:

struct ConditionalAspectRatioLayout: Layout {
    var ratio: CGFloat?
    var contentMode: ContentMode
    var enabled: Bool

    func childProposal(proposal: ProposedViewSize, child: Subviews.Element) -> ProposedViewSize {
        guard enabled else {
            return proposal
        }
        let aspectRatio = ratio ?? child.sizeThatFits(.unspecified).aspectRatio
        switch contentMode {
        case .fit:
            let width = min(proposal.width!, proposal.height! * aspectRatio)
            return .init(width: width, height: width/aspectRatio)
        case .fill:
            fatalError()
        }
    }

    // ...
}

19:08 We call the method in sizeThatFits to get the proposal for the child view. We then pass the result to sizeThatFits on the child, and we return the size it reports:

struct ConditionalAspectRatioLayout: Layout {
    // ...

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        assert(subviews.count == 1)
        let s = subviews[0]
        return s.sizeThatFits(childProposal(proposal: proposal, child: s))
    }

    // ...
}

19:34 Now we can call the same helper in placeSubview:

struct ConditionalAspectRatioLayout: Layout {
    // ...

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        let s = subviews[0]
        s.place(at: bounds.origin, proposal: childProposal(proposal: proposal, child: s))
    }
}

20:01 We could optimize our code so that we don't compute the proposal for the child view twice, but that adds a lot of complexity, so we'll leave it for now.

Result

20:28 The result is looking very good. The animation of the heart continues running smoothly as we toggle the aspect ratio modifier on and off, which means we're looking at the same view being laid out in two different ways, rather than the view being replaced altogether.

21:06 Next time, we'll take a look at the missing cases — the fatal errors and the force-unwraps — to make our conditional modifier more robust.

Resources

  • Sample Code

    Written in Swift 5.9

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

162 Episodes · 56h09min

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