Weighted Layout (HStack and VStack) in SwiftUI
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:
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)