Layout in SwiftUI with horizontal and vertical alignment

not an expert here, but I managed to achieve the desired layout by (1) opting for the 2-VStacks-in-a-HStack alternative, (2) framing the external labels, (3) freeing them from their default vertical expansion constraint by assigning their maxHeight = .infinity and (4) fixing the height of the HStack

struct ContentView: View {
    @State var text = ""
    let labels = ["Username", "Email", "Password"]

    var body: some View {
        HStack {
            VStack(alignment: .leading) {
                ForEach(labels, id: \.self) { label in
                    Text(label)
                        .frame(maxHeight: .infinity)
                        .padding(.bottom, 4)
                }
            }

            VStack {
                ForEach(labels, id: \.self) { label in
                    TextField(label, text: self.$text)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                }
            }
            .padding(.leading)
        }
        .padding(.horizontal)
        .fixedSize(horizontal: false, vertical: true)
    }
}

Here is the resulting preview:

enter image description here

in order to account for the misaligned baselines of the external and internal labels (a collateral issue that is not related to this specific layout – see for instance this discussion) I manually added the padding

credits to this website for enlightening me on the path to understanding SwiftUI layout trickeries


SwiftUI Grids to the rescue!

Starting from iOS 16 you should use a Grid for this.

struct ContentView: View {
    let labels = ["Username", "Email", "Password"]

    var body: some View {
        Grid {
            ForEach(labels, id: \.self) { label in
                GridRow {
                    Text(label)
                    TextField(label, text: .constant(""))
                }
            }
        }
    }
}

iOS 13-15 legacy hack

You could use kontiki's geometry reader hack for this:

SwiftUI live preview

struct Column: View {
    @State private var height: CGFloat = 0
    @State var text = ""
    let spacing: CGFloat = 8
    
    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: spacing) {
                Group {
                    Text("Hello world")
                    Text("Hello Two")
                    Text("Hello Three")
                }.frame(height: height)
            }.fixedSize(horizontal: true, vertical: false)
            VStack(spacing: spacing) {
                TextField("label", text: $text).bindHeight(to: $height)
                TextField("label 2", text: $text)
                TextField("label 3", text: $text)
            }.textFieldStyle(RoundedBorderTextFieldStyle())
        }.fixedSize().padding()
    }
}

extension View {
    func bindHeight(to binding: Binding<CGFloat>) -> some View {
        func spacer(with geometry: GeometryProxy) -> some View {
            DispatchQueue.main.async { binding.value = geometry.size.height }
            return Spacer()
        }
        return background(GeometryReader(content: spacer))
    }
}

We are only reading the height of the first TextField here and applying it three times on the three different Text Views, assuming that all TextFields have the same height. If your three TextFields have different heights or have appearing/disappearing verification labels that affect the individual heights, you can use the same technique but with three different height bindings instead.

Why is this a bit of a hack?

Because this solution will always first render the TextFields without the labels. During this render phase it will set the height of the Text labels and trigger another render. It would be more ideal to render everything in one layout phase.