These two solutions have different compromises. You'll need to decide what happens, for example, if the views will be too wide for the parent view based on the longest label. Option 1 wraps the text. Option 2 makes the view expand beyond its parent.
Option #1:
Use LazyVGrid:
struct ContentView: View { let columns = [ GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), ] var body: some View { LazyVGrid(columns: columns, spacing: 30) { Item(color: .yellow, label: "Berlin") Item(color: .green, label: "Copenhagen") Item(color: .blue, label: "Madrid") Item(color: .purple, label: "Helsinki") } } }
Option #2:
Use a PreferenceKey and a GeometryReader to get the size of the widest View and propagate the changes back up to the parent.
struct Item: View { let color: Color let label: String let width: CGFloat var body: some View { VStack { Circle() .fill(color) .frame(width: 40, height: 40) Text(label) } .border(Color.blue) .background( GeometryReader { Color.clear.preference(key: ViewWidthKey.self, value: $0.size.width) }.scaledToFill() ) .frame(width: width) } } struct ViewWidthKey: PreferenceKey { static var defaultValue: CGFloat { 0 } static func reduce(value: inout Value, nextValue: () -> Value) { let nextValue = nextValue() guard nextValue > value else { return } value = nextValue } } struct ContentView: View { @State private var maxWidth : CGFloat = 0 var body: some View { HStack(alignment: .top, spacing: 30) { Item(color: .yellow, label: "Berlin", width: maxWidth) Item(color: .green, label: "Copenhagen", width: maxWidth) Item(color: .blue, label: "Madrid", width: maxWidth) Item(color: .purple, label: "Helsinki", width: maxWidth) }.onPreferenceChange(ViewWidthKey.self) { width in self.maxWidth = width print(width) } } }