11

I have the following Cocoa form:

struct Canvas: PreviewProvider {
  static var previews: some View {
    VStack {
      HStack(alignment: .firstTextBaseline) {
        Text("Endpoint:")
        TextField("https://localhost:8080/api", text: .constant(""))
      }
      Divider()
      HStack(alignment: .firstTextBaseline) {
        Text("Path:")
        TextField("/todos", text: .constant(""))
      }
      Spacer()
    }
    .padding()
    .previewLayout(.fixed(width: 280, height: 200))
  }
}

This panel looks nice but I’d like to right-align “Endpoint:” and “Path:” labels:

So I apply a custom horizontal alignment:

struct Canvas: PreviewProvider {
  static var previews: some View {
    VStack(alignment: .label) {
      HStack(alignment: .firstTextBaseline) {
        Text("Endpoint:").alignmentGuide(.label) { $0[.trailing] }
        TextField("https://localhost:8080/api", text: .constant(""))
      }
      Divider()
      HStack(alignment: .firstTextBaseline) {
        Text("Path:").alignmentGuide(.label) { $0[.trailing] }
        TextField("/todos", text: .constant(""))
      }
      Spacer()
    }
    .padding()
    .previewLayout(.fixed(width: 280, height: 200))
  }
}

extension HorizontalAlignment {
  private enum Label: AlignmentID {
    static func defaultValue(in context: ViewDimensions) -> CGFloat {
      context[.leading]
    }
  }
  static let label: HorizontalAlignment = .init(Label.self)
}

Results are not what I need however:

There is no documentation, please help.

Vadim
  • 9,383
  • 7
  • 36
  • 58

2 Answers2

6

I don't believe alignment guides will work here in their current implementation. After playing with them a bit, it seems that they size their children based on the container's given size and then align each child based on the guide. This leads to the weird behavior you were seeing.

Below I show 3 different techniques that will allow you to get your desired results, in order of complexity. Each has its applications outside of this specific example.

The last (label3()) will be the most reliable for longer forms.


struct ContentView: View {
    @State var sizes: [String:CGSize] = [:]

    var body: some View {
        VStack {
            HStack(alignment: .firstTextBaseline) {
                self.label3("Endpoint:")
                TextField("https://localhost:8080/api", text: .constant(""))
            }
            Divider()
            HStack(alignment: .firstTextBaseline) {
                self.label3("Path:")
                TextField("/todos", text: .constant(""))
            }
        }
        .padding()
        .onPreferenceChange(SizePreferenceKey.self) { preferences in
            self.sizes = preferences
        }
    }

    func label1(_ text: String) -> some View {
        Text(text) // Use a minimum size based on your best guess.  Look around and you'll see that many macOS apps actually lay forms out like this because it's simple to implement.
            .frame(minWidth: 100, alignment: .trailing)
    }

    func label2(_ text: String, sizer: String = "Endpoint:") -> some View {
        ZStack(alignment: .trailing) { // Use dummy content for sizing based on the largest expected item.  This can be great when laying out icons and you know ahead of time which will be the biggest.
            Text(sizer).opacity(0.0)
            Text(text)
        }
    }

    func label3(_ text: String) -> some View {
        Text(text) // Use preferences and save the size of each label
        .background(
            GeometryReader { proxy in
                Color.clear
                    .preference(key: SizePreferenceKey.self, value: [text : proxy.size])
            }
        )
        .frame(minWidth: self.sizes.values.map { $0.width }.max() ?? 0.0, alignment: .trailing)
    }
}

struct SizePreferenceKey: PreferenceKey {
    typealias Value = [String:CGSize]
    static var defaultValue: Value = [:]

    static func reduce(value: inout Value, nextValue: () -> Value) {
        let next = nextValue()
        for (k, v) in next {
            value[k] = v
        }
    }
}

Here's a screenshot of the results with label2 or label3. The two labels are aligned

arsenius
  • 12,090
  • 7
  • 58
  • 76
  • Alright, I prefer the `.label1()`, but is there any way to define a maximum size using a ratio of the container’s width? – Vadim Oct 23 '19 at 05:49
  • 1
    You can, using `GeometryReader`. I recommend using the same technique I illustrated in `label3()` of placing the `GeometryReader` in the background and saving the size like `@State var contentSize: CGSize = .zero` and then `.frame(maxWidth: self.contentSize.width * 0.25, alignment: .trailing)`. This way your view will continue to lay out properly with an intrinsic size. `GeometryReader` itself always expands to fit its full available area just like `Color` does. – arsenius Oct 23 '19 at 06:01
  • This helps. But it also turns out that `HStack` uses 50% by default, which works for 99% of all cases. The slight problem with a `.label3()` approach is an asynchronous calculation, so that it jumps to the right position just after the tiny delay. I’ll go with `.label1()` for now! Thanks again – Vadim Oct 23 '19 at 06:16
0

Using XCode 13.1 and targeting MacOS 12 you can achieve the desired result quite easily by adding a "Form" element:

struct Canvas: PreviewProvider {
    static var previews: some View {
        Form {
            TextField("Endpoint:", text: .constant(""))
            Divider()
            TextField("Path:", text: .constant(""))
        }
            .previewLayout(.fixed(width: 280, height: 200))
    }
}

The divider is not covering the area of the labels, but this is intended by Apple. Also, I haven't found out quickly how to add the placeholders to the text fields.

G. Marc
  • 4,987
  • 4
  • 32
  • 49