Search code examples
swiftui

Unexpected behavior of aspectRatio(_:contentMode:) modifier


Using the following

@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: enter image description here

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: enter image description here

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?

ContentView without aspectRatio

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()
  }
}

ContentView with aspectRatio

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()
  }
}

Solution

  • 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)
    }