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 AnyLayout
s, 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.