102

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:

  • The first to be 43% (of its parent's height) high
  • The second to be 37% (of its parent's height) high
  • The last to be 20% (of its parent's height) high

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.

3
  • 1
    You could use GeometryReader to get the size of the parent to calculate it by yourself. Commented Jul 28, 2019 at 18:20
  • According to the slides on the link, the parent can propose a size for the child. The child then manages itself and creates it size relation to that proposition. What results are you getting when you do the above? Commented Jul 28, 2019 at 18:44
  • @impression7vx Take a look at the EDIT. Commented Jul 29, 2019 at 10:21

6 Answers 6

138

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())
Sign up to request clarification or add additional context in comments.

6 Comments

Thanks for your help. When you get the chance can you slightly improve your answer? What I don't get here is why you have to specify that "layoutPriority(1)" modifier (I know what layoutPriority does, but I don't understand why it's needed here). Also, can you take a look at my EDIT and explain us why the "relativeHeight" modifier won't work here? Thank you again.
You have to specify layoutPriority because it doesn’t work otherwise. I don’t understand why it doesn’t work otherwise. It might be a SwiftUI bug. All I know is that, from experimentation, I found that using layoutPriority makes it work. As for relativeHeight, it is deprecated, so I’m not interested in investigating how to make it work.
Thx. I didn't know that relativeHeight was deprecated. For the layoutPriority issue I'll keep trying to find out what's happening.
It seems beta 5 fixed the .layoutPriority() problem and might not be needed anymore for this to work.
Just updated MacOS and xCode to Beta 5 and, as suggested by @kontiki, there's no need for the .layoutPriority() anymore. Rob, would you mind update your answer? Thank you guys.
|
10

iOS 17+, macOS 14.0+ (and aligned releases)

There's now a simple way of achieving this using the containerRelativeFrame(_:alignment:) modifier!

For instance, with a count of 100, span will represent the % amount of the parent view size you wish to assign to your child view along the specified axis/axes:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack(spacing: 0) {
            Rectangle()
                .fill(Color.red)
                .containerRelativeFrame(.vertical, count: 100, span: 43, spacing: 0)

            Rectangle()
                .fill(Color.green)
                .containerRelativeFrame(.vertical, count: 100, span: 37, spacing: 0)

            Rectangle()
                .fill(Color.yellow)
        }
    }
}

Xcode preview of vertically laid out rectangles with assigned proportions

1 Comment

Note that this does not behave as one might expect. containerRelativeFrame is not relative to VStacks or HStacks. It's relative to a small number of types of parent containers: developer.apple.com/documentation/swiftui/view/…
6

Pretty easy to do with a Grid too for horizontal proportions for folks that need that.

Grid(horizontalSpacing: 0, verticalSpacing: 0) {
    GridRow {
        ForEach(0 ..< 10) { _ in
            Color.clear
        }
    }
    .frame(height: 0)
    GridRow {
        Color.red
            .gridCellColumns(2) // 20 %
        Color.green
            .gridCellColumns(8) // 80 %
    }
}

Comments

5

In SwiftUI, there are multiple ways to achieve the desired layout.

#1 Using GeometryReader

struct ContentView: View {
    var body: some View {
        GeometryReader { proxy in
            VStack(spacing: 0) {
                Color.green
                    .frame(height: proxy.size.height * 0.55)
                Color.orange
                    .frame(height: proxy.size.height * 0.3)
                Color.red
            }
        }
    }
}

#2 Using containerRelativeFrame(_:count:span:spacing:alignment:)

struct ContentView: View {
    var body: some View {
        VStack(spacing: 0) {
            Color.green
                .containerRelativeFrame(.vertical, count: 10, span: 5, spacing: 0)
            Color.orange
                .containerRelativeFrame(.vertical, count: 10, span: 3, spacing: 0)
            Color.red
        }
    }
}

#3 Using ScrollView and onScrollGeometryChange(for:of:action:)

struct ContentView: View {
    @State private var height = 0.0

    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                Color.green
                    .frame(height: height * 0.55)
                Color.orange
                    .frame(height: height * 0.3)
                Color.red
                    .frame(height: height * 0.15)
            }
        }
        .onScrollGeometryChange(for: Double.self) { geo in
            geo.containerSize.height
        } action: { oldValue, newValue in
            height = newValue
        }
        .scrollDisabled(true)
    }
}

#4 Using Layout and ContainerValues

extension ContainerValues {
    @Entry var ratio: Double = 0
}

extension View {
    func ratio(_ ratio: Double) -> some View {
        containerValue(\.ratio, ratio)
    }
}
struct ProportionalLayout: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize {
        let totalWidth = proposal.width ?? 0
        let totalHeight = subviews
            .map { (proposal.height ?? 0) * $0.containerValues.ratio }
            .reduce(0, +)

        return CGSize(width: totalWidth, height: totalHeight)
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) {
        let heights = subviews.map { (proposal.height ?? 0) * $0.containerValues.ratio }

        var y = bounds.minY
        for index in subviews.indices {
            let height = heights[index]
            subviews[index].place(
                at: CGPoint(x: bounds.minX, y: y),
                proposal: ProposedViewSize(width: proposal.width ?? 0, height: height)
            )
            y += height
        }
    }
}
struct ContentView: View {
    var body: some View {
        ProportionalLayout {
            Color.green
                .ratio(0.55)
            Color.orange
                .ratio(0.3)
            Color.red
                .ratio(0.15)
        }
    }
}

#5 Using Grid (for an horizontal layout)

struct ContentView: View {
    var body: some View {
        Grid(horizontalSpacing: 0, verticalSpacing: 0) {
            GridRow {
                Color.red
                    .gridCellColumns(5)
                Color.orange
                    .gridCellColumns(3)
                Color.green
                    .gridCellColumns(2)
            }
        }
    }
}

Comments

4

Here is an example of proportional width in SwiftUI

Before iOS 16:

struct GeometryReaderHStack: View {
  var body: some View {
    GeometryReader { geometry in
      HStack(spacing: .zero) {
        let component1Width = geometry.size.width * 0.75
        let component2Width = geometry.size.width * 0.25
        Text("3/4")
          .frame(width: component1Width)
          .background(.red)

        Text("1/4")
          .frame(width: component2Width)
          .background(.blue)
      }
    }
    .frame(height: .zero)
  }
}

Notes:

  1. you need to add .frame(height: .zero) to force the GeometryReader to respect its content size. if u don't add this, the GeometryReader will go edge to edge full screen of the device. U can add a .background(.gray) on GeometryReader scope to see the side effect!
  2. if u need to add spacing for HStack, it doesn't work as expected. instead of deducting/excluding the spacing and then lay out the subviews, it will push other subviews out of screen! U may want to use .padding for each subview

After iOS 16+:

I can see Layout already mentioned by others but below is my spike. It is CustomHStack which works well with spacing however may need some extra work like (guarding around number of widthWeights must be the same as subviews counts, and VerticalAlignment implementation possibly)

struct CustomHStack: Layout {
  let widthWeights: [CGFloat]
  let spacing: CGFloat

  init(widthWeights: [CGFloat], spacing: CGFloat? = nil) {
    self.widthWeights = widthWeights
    self.spacing = spacing ?? .zero
  }

  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let maxHeight = subviews
      .map { proxy in
        return proxy.sizeThatFits(.unspecified).height
      }
      .max() ?? .zero

    return CGSize(width: proposal.width ?? .zero, height: maxHeight)
  }

  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let subviewSizes = subviews.map { proxy in
      return proxy.sizeThatFits(.infinity)
    }

    var x = bounds.minX
    let y = bounds.minY
    let parentTotalWith = (proposal.width ?? .zero) - (CGFloat((widthWeights.count - 1)) * spacing)

    for index in subviews.indices {
      let widthWeight = widthWeights[index]
      let proposedWidth = widthWeight * parentTotalWith

      let sizeProposal = ProposedViewSize(width: proposedWidth, height: subviewSizes[index].height)

      subviews[index]
        .place(
          at: CGPoint(x: x, y: y),
          anchor: .topLeading,
          proposal: sizeProposal
        )

      x += (proposedWidth + spacing)
    }
  }
}

Usage:

struct ProportionalWidthHStack: View {
  var body: some View {
    CustomHStack(widthWeights: [0.75, 0.25]) {
      Text("3/4")
        .frame(maxWidth: .infinity)
        .background(.red)
      Text("1/4")
        .frame(maxWidth: .infinity)
        .background(.blue)
    }
  }
}

1 Comment

your code works for particular cases only - when you know exact percentage and your views together fulfil the whole stack. What if your stack contains subviews which are for example 3:4 and there is the third view between them which has its own size (defined by its content)?
0

If you need to support iOS 15 then you could do something like this:

HRatioSplitView(ratio: 0.2) {
    VStack {
        Text("Hello")
        Text("World")
        Image(systemName: "star")
        Color.yellow
    }
    .border(Color.blue)
    VStack {
        Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Voluptate consectetur duis reprehenderit dolor ad qui sit, ut enim et officia ex cupidatat sed fugiat in commodo dolore aliqua culpa.")
        HStack {
            Spacer()
            Image(systemName: "star")
                .padding(4)
        }
    }
    .border(Color.green)
}

Result:

enter image description here

Where HRatioSplitView is this:

struct HRatioSplitView<View1: View, View2: View>: View {
    let ratio: CGFloat
    @ViewBuilder let content: () -> TupleView<(View1, View2)>
    @State private var height: CGFloat?
    
    var body: some View {
        GeometryReader { proxy in
            HStack(alignment: .top, spacing: 0) {
                content().value.0
                    .frame(width: proxy.size.width * ratio)
                content().value.1
                    .frame(width: proxy.size.width * (1 - ratio))
            }
            .fixedSize(horizontal: false, vertical: true)
            .background(
                GeometryReader { proxy in
                    Color.clear
                        .onAppear { height = proxy.size.height }
                        .onChange(of: proxy.size) { height = $0.height }
                }
            )
        }
        .frame(height: height)
    }
}

The second GeometryReader here is used to fix the height of the main GeometryReader which is needed in case you for example use this HRatioSplitView inside another VStack and you have other views below HRatioSplitView because without this the main GeometryReader has a smaller height and other views overlap. You could modify it to take the proper amount of columns if needed or nest one HRatioSplitView inside another and this way have 3 columns with custom proportions however that could work slower. Similarly you could implement VRatioSplitView.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.