swiftswiftuiautolayout

Proportional height (or width) in SwiftUI


I started exploring SwiftUI and I can't find a way to get a simple thing: I'd like a View to have proportional height (basically a percentage of its parent's height). Let's say I have 3 views vertically stacked. I want:

I watched this interesting video from the WWDC19 about custom views in SwiftUI (https://developer.apple.com/videos/play/wwdc2019/237/) and I understood (correct me if I'm wrong) that basically a View never has a size per se, the size is the size of its children. So, the parent view asks its children how tall they are. They answer something like: "half your height!" and then... what? How does the layout system (that is different from the layout system we are used to) manage this situation?

If you write the below code:

struct ContentView : View {
    var body: some View {
        VStack(spacing: 0) {
            Rectangle()
                .fill(Color.red)
            Rectangle()
                .fill(Color.green)
            Rectangle()
                .fill(Color.yellow)
        }
    }
}

The SwiftUI layout system sizes each view to be 1/3 high and this is right according to the video I posted here above. You can wrap the rectangles in a frame this way:

struct ContentView : View {
    var body: some View {
        VStack(spacing: 0) {
            Rectangle()
                .fill(Color.red)
                .frame(height: 200)
            Rectangle()
                .fill(Color.green)
                .frame(height: 400)
            Rectangle()
                .fill(Color.yellow)
        }
    }
}

This way the layout system sizes the first rectangle to be 200 high, the second one to be 400 high and the third one to fit all the left space. And again, this is fine. What you can't do (this way) is specifying a proportional height.


Solution

  • UPDATE

    If your deployment target at least iOS 16, macOS 13, tvOS 16, or watchOS 9, you can write a custom Layout. For example:

    import SwiftUI
    
    struct MyLayout: Layout {
        func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
            return proposal.replacingUnspecifiedDimensions()
        }
    
        func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
            precondition(subviews.count == 3)
    
            var p = bounds.origin
            let h0 = bounds.size.height * 0.43
            subviews[0].place(
                at: p,
                proposal: .init(width: bounds.size.width, height: h0)
            )
            p.y += h0
    
            let h1 = bounds.size.height * 0.37
            subviews[1].place(
                at: p,
                proposal: .init(width: bounds.size.width, height: h1)
            )
            p.y += h1
    
            subviews[2].place(
                at: p,
                proposal: .init(
                    width: bounds.size.width,
                    height: bounds.size.height - h0 - h1
                )
            )
        }
    }
    
    import PlaygroundSupport
    PlaygroundPage.current.setLiveView(MyLayout {
        Color.pink
        Color.indigo
        Color.mint
    }.frame(width: 50, height: 100).padding())
    

    Result:

    a pink block 43 points tall atop an indigo block 37 points tall atop a mint block 20 points tall

    Although this is more code than the GeometryReader solution (below), it can be easier to debug and to extend to a more complex layout.

    ORIGINAL

    You can make use of GeometryReader. Wrap the reader around all other views and use its closure value metrics to calculate the heights:

    let propHeight = metrics.size.height * 0.43
    

    Use it as follows:

    import SwiftUI
    
    struct ContentView: View {
        var body: some View {
            GeometryReader { metrics in
                VStack(spacing: 0) {
                    Color.red.frame(height: metrics.size.height * 0.43)
                    Color.green.frame(height: metrics.size.height * 0.37)
                    Color.yellow
                }
            }
        }
    }
    
    import PlaygroundSupport
    
    PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())