Layout in SwiftUI with horizontal and vertical ali

2020-05-27 05:48发布

问题:

I'm trying to accomplish this layout

If I try HStack wrapped in VStack, I get this:

If I try VStack wrapped in HStack, I get this:

Is there a way to baseline align the text with the textfield and get standard spacing from the longest label to the start of the aligned textfields?

回答1:

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:

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



回答2:

Looks like this will work:

extension HorizontalAlignment {
    private enum MyAlignment: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> Length {
            context[.trailing]
        }
    }
    static let myAlignmentGuide = HorizontalAlignment(MyAlignment.self)
}

struct ContentView : View {
    @State var username: String = ""
    @State var email: String = ""
    @State var password: String = ""

    var body: some View {
        VStack(alignment: .myAlignmentGuide) {
            HStack {
                Text("Username").alignmentGuide(.myAlignmentGuide, computeValue: { d in d[.trailing] })
                TextField($username)
                    .textFieldStyle(.roundedBorder)
                    .frame(maxWidth: 200)
            }
            HStack {
                Text("Email")
                    .alignmentGuide(.myAlignmentGuide, computeValue: { d in d[.trailing] })
                TextField($email)
                    .textFieldStyle(.roundedBorder)
                    .frame(maxWidth: 200)
            }
            HStack {
                Text("Password")
                    .alignmentGuide(.myAlignmentGuide, computeValue: { d in d[.trailing] })
                TextField($password)
                    .textFieldStyle(.roundedBorder)
                    .frame(maxWidth: 200)
            }
        }
    }
}

With that code, I am able to achieve this layout:

The caveat here is that I had to specify a max width for the TextFields. Left unconstrained, the layout system described in the WWDC talk I linked in the comments retrieves a size for the TextField prior to alignment happening, causing the TextField for email to extend past the end of the other two. I'm not sure how to address this in a way that will allow the TextFields to expand to the size of the containing view without going over...



回答3:

    var body: some View {

    VStack {
        HStack {
            Text("Username")
            Spacer()
            TextField($username)
                .textFieldStyle(.roundedBorder)
                .frame(maxWidth: 200)
                .foregroundColor(.gray)
                .accentColor(.red)
        }
        .padding(.horizontal, 20)
        HStack {
            Text("Email")
            Spacer()
            TextField($email)
                .textFieldStyle(.roundedBorder)
                .frame(maxWidth: 200)
                .foregroundColor(.gray)
            }
            .padding(.horizontal, 20)
        HStack {
            Text("Password")
            Spacer()
            TextField($password)
                .textFieldStyle(.roundedBorder)
                .frame(maxWidth: 200)
                .foregroundColor(.gray)
            }
            .padding(.horizontal, 20)
    }
}



回答4:

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

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.



回答5:

You need to add fixed width and leading alignment. I've tested in Xcode 11.1 it's ok.

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

    var body: some View {
        VStack {
            ForEach(labels, id: \.self) { label in
                HStack {
                    Text(label).frame(width: 100, alignment: .leading)
                    TextField(label, text: self.$text)
                        .textFieldStyle(RoundedBorderTextFieldStyle())
                }
            }
            .padding(.horizontal)
            .fixedSize(horizontal: false, vertical: true)
        }
    }
}

Below You can see what the issue when we use different VStack for Text and TextField. See more info here

Updated 16 Oct 2019

A closer inspection of Texts and TextFields you can notice that they have different heights and it effects the positions of Texts relative to TextFields as you can see on the right side of the screenshot that Password Text is higher relative to Password TextField than the Username Text relative to Username TextField. I gave three ways to resolve this issue here