Reading time: 3 min

This recipe shows how to add weights to HStack and VStack elements, so that you can easily size them relative to each other. E.g, if you had two views, one with weight of 10, and the other with weight of 5, the first one would be twice as wide / tall as the second one.

The end result looks like this:

preview

TL;DR You can find the full code in this gist.

General solution

To set relative / proportional weights in a container in SwiftUI, use a GeometryReader. The GeometryReader's proxy will give you the size of the HStack / VStack it wraps and allow you to set the frame of their children:

GeometryReader { geo in
  VStack {
      Text("50%")
        .frame(height: geo.size.height * 0.5)
      Text("30%")
        .frame(height: geo.size.height * 0.3)
      Text("20%")
        .frame(height: geo.size.height * 0.2)
  }
}

Of course, the same works for HStack as well:

GeometryReader { geo in
  HStack {
      Text("50%")
        .frame(width: geo.size.width * 0.5)
      Text("30%")
        .frame(width: geo.size.width * 0.3)
      Text("20%")
        .frame(width: geo.size.width * 0.2)
  }
}

While this is nice, it's a bit cryptic at first and forces you to write a lot of boilerplate code. That's where the custom weighted modifier comes in!

Custom weighted modifier

Start off by creating WeightedProxy, which will be the glue that ties all the elements in a weighted layout, allowing them to report their weights and receive their relative dimensions:

class WeightedProxy {
  let kind: Kind // differentiates between HStack and VStack
  var geo: GeometryProxy? = nil // wrapped GeometryProxy
  private(set) var totalWeight: CGFloat = 0

  init(kind: Kind) {
    self.kind = kind
  }

  func register(with weight: CGFloat) {
    totalWeight += weight
  }

  func dimensionForRelative(weight: CGFloat) -> CGFloat {
    guard let geo = geo,
          totalWeight > 0
    else {
      return 0
    }
    let dimension = (kind == .vertical) ? geo.size.height : geo.size.width
    return dimension * weight / totalWeight
  }

  enum Kind {
    case vertical, horizontal
  }
}

Then, add the Weighted modifier that registers a view with a WeightedProxy:

struct Weighted: ViewModifier {
  private let weight: CGFloat
  private let proxy: WeightedProxy

  init(_ weight: CGFloat, proxy: WeightedProxy) {
    self.weight = weight
    self.proxy = proxy
    proxy.register(with: weight)
  }

  @ViewBuilder func body(content: Content) -> some View {
    if proxy.kind == .vertical {
      content.frame(height: proxy.dimensionForRelative(weight: weight))
    } else {
      content.frame(width: proxy.dimensionForRelative(weight: weight))
    }
  }
}

extension View {
  func weighted(_ weight: CGFloat, proxy: WeightedProxy) -> some View {
    self.modifier(Weighted(weight, proxy: proxy))
  }
}

Lastly, we need vertical and horizontal containers that can provide a WeightedProxy to their children, as well as a GeometryProxy to it. We'll name those WeightedHStack and WeightedVStack, respectively:

struct WeightedHStack<Content>: View where Content : View {
  private let proxy = WeightedProxy(kind: .horizontal)
  @State private var initialized = false
  @ViewBuilder let content: (WeightedProxy) -> Content

  var body: some View {
    GeometryReader { geo in
      HStack(spacing: 0) {
        if initialized {
          content(proxy)
        } else {
          Color.clear.onAppear {
            proxy.geo = geo
            initialized.toggle()
          }
        }
      }
    }
  }
}

struct WeightedVStack<Content>: View where Content : View {
  private let proxy = WeightedProxy(kind: .vertical)
  @State private var initialized = false
  @ViewBuilder let content: (WeightedProxy) -> Content

  var body: some View {
    GeometryReader { geo in
      VStack(spacing: 0) {
        if initialized {
          content(proxy)
        } else {
          Color.clear.onAppear {
            proxy.geo = geo
            initialized.toggle()
          }
        }
      }
    }
  }
}

Finally, here's some sample usage. You can use WeightedHStack and WeightedVStack just like their stock counterparts, plus you can use the weighted modifier on their children:

WeightedVStack { proxy in
  Text("20%")
    .frame(minWidth: 0, maxWidth: .infinity)
    .weighted(2, proxy: proxy)
    .background(Color.green)
  Text("50%")
    .frame(minWidth: 0, maxWidth: .infinity)
    .weighted(5, proxy: proxy)
    .background(Color.red)
  Text("30%")
    .weighted(3, proxy: proxy)
    .background(Color.cyan)
}
.padding()
.foregroundColor(.white)
WeightedHStack { proxy in
  Text("50%")
    .weighted(5, proxy: proxy)
    .background(Color.blue)
  Text("20%")
    .weighted(2, proxy: proxy)
    .background(Color.green)
  Text("30%")
    .weighted(3, proxy: proxy)
    .background(Color.red)
}
.padding()
.foregroundColor(.white)

Next Post Previous Post