@main
struct aspectratioApp: App {
var body: some Scene {
WindowGroup { ContentView().border(.blue, width: 2).padding() }
}
}
and the ContentView
without the aspectRatio
modifier applied (see below), the results are as expected, incl. the Canvas
fills the available space (which I think is the default behavior), outputting canvas size: (243.0, 78.0)
to the console:
Next, let's apply the .aspectRatio(isSquare ? 1 : nil, contentMode: isSquare ? .fit : .fill)
modifier, which I believe is saying to make the Canvas
a square that fits or whatever fills the available space (see ContentView with aspectRatio below). Right from the start I get a square Canvas
like so:
and a square canvas size printed in the console. Toggling isSquare
keeps the Canvas square with a slightly smaller size:
canvas size: (157.0, 157.0)
canvas size: (130.0, 130.0)
What am I missing here? The documentation for aspectRatio documents the aspectRatio parameter as follows: The ratio of width to height to use for the resulting view. Use nil to maintain the current aspect ratio in the resulting view. which I read to mean whatever the ratio would have been if the modifier is not applied.
Is that not how it works? Is the modifier not stateless, that is, did it remember that the canvas was square once and is just sticking with that for good now, incl. across restarts of the app?
struct ContentView: View {
@State private var isSquare = false
var body: some View {
VStack {
Text("isSquare: \(isSquare)").padding()
Canvas { _, size in print("canvas size: \(size)") }
.border(.teal, width: 2)
.padding()
Toggle(isOn: $isSquare) { Text("Constrain to Square") }.padding()
}
.border(.yellow, width: 2)
.padding()
}
}
struct ContentView: View {
@State private var isSquare = false
var body: some View {
VStack {
Text("isSquare: \(isSquare)").padding()
Canvas { _, size in print("canvas size: \(size)") }
.aspectRatio(isSquare ? 1 : nil, contentMode: isSquare ? .fit : .fill)
.border(.teal, width: 2)
.padding()
Toggle(isOn: $isSquare) { Text("Constrain to Square") }.padding()
}
.border(.yellow, width: 2)
.padding()
}
}
Passing nil
to aspectRatio
is not like .opacity(1)
or .scaleEffect(1)
where it changes nothing. aspectRatio
is not idempotent.
The documentation says:
The ratio of width to height to use for the resulting view. Use
nil
to maintain the current aspect ratio in the resulting view.
So the view needs to have a "current aspect ratio". How does aspectRatio
find this? It gives an .unspecified
size proposal to the Canvas
, asking for its ideal size. But Canvas
is supposed to take up as much space as it like, so it just says 10x10 (this is also the default when you use replacingUnspecifiedDimensions
), which is not very meaningful, but it has to return something at the end of the day.
So aspectRatio
receives 10x10, and goes, "oh the 'current aspect ratio' is 1", and that's how the Canvas
becomes a square even when you use nil
as the aspectRatio
.
For another example of how SwiftUI gives size proposals to views, see my answer here
You can see what proposals your views get, by implementing a NSViewRepresentable
and override sizeThatFits
. SwiftUI calls this method when it wants to propose a size to your views.
struct PropsoalLogger: NSViewRepresentable {
func makeNSView(context: Context) -> some NSView {
NSView()
}
func updateNSView(_ nsView: NSViewType, context: Context) {
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSViewType, context: Context) -> CGSize? {
print(proposal)
// Canvas most likely does this under the hood
return proposal.replacingUnspecifiedDimensions()
}
}
Replace Canvas
with this view and use aspectRatio
. You will see that the first proposal is width: nil, height: nil
- aka the .unspecified
proposal. If you remove aspectRatio
, there will not be any .unspecified
propsoals. Note that the VStack
will call this for quite a lot of times to do its layout.
Basically, you need to get the aspect ratio of the parent and pass that to aspectRatio
, instead of having it ask the Canvas
for its size. You could for example do:
GeometryReader { geo in
Canvas { _, size in print("canvas size: \(size)") }
.aspectRatio(isSquare ? 1 : geo.size.width / geo.size.height, contentMode: .fit)
}