Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to align label in an HStack with the result of the labels in a Form?

Tags:

macos

swiftui

I am trying to include a custom HStack row in a SwiftUI Form as follows:

var body: some View {
    Form {
        TextField("Text", text: .constant("test"))
        Toggle("Toggle", isOn: .constant(true))
            .toggleStyle(SwitchToggleStyle())
        HStack {
            Text("Label")
            MenuButton("Menu") {
                Button(action: {
                    print("Clicked Pizza")
                }) { Text("Pizza") }
                Button(action: {
                    print("Clicked Pasta")
                }) { Text("Pasta") }
            }
            TextField("Topping", text: .constant("Cheese"))
                .labelsHidden()
        }
        
    }
    .padding()
}

resulting in

SwiftUI form output with wrong alignment

However, I would like Label to be vertically aligned with Toggle, and Menu vertically aligned with the toggle.

Is there a standard way of choosing the alignment mode for the custom HStack row?

like image 841
Julian Avatar asked Oct 26 '25 05:10

Julian


2 Answers

macOS 13 only

The new LabeledContent view in SwiftUI will help you:

LabeledContent("Label") {
  MenuButton("Menu") {
    Button(action: {
      print("Clicked Pizza")
    }) { Text("Pizza") }
    Button(action: {
      print("Clicked Pasta")
    }) { Text("Pasta") }
  }
  TextField("Topping", text: .constant("Cheese"))
    .labelsHidden()
  }
}

An example layout with LabeledContent in SwiftUI for macOS 13

However, this view has not been backported to earlier versions of macOS, so if you need to support earlier versions you'll need another approach.


Earlier versions of macOS

Building on the preference key code from @Nhat Nguyen Duc, the key is to use alignment guides rather than padding. Creating a custom view, and with a customised preference that only measures the width:

struct LabeledHStack<Content: View>: View {
    var label: String
    var content: () -> Content
    @State var labelWidth: CGFloat = 0

    init(_ label: String, @ViewBuilder content: @escaping () -> Content) {
        self.label = label
        self.content = content
    }

    var body: some View {
        HStack {
            Text(label)
                .readWidth { self.labelWidth = $0 }
            content()
        }
        .alignmentGuide(.leading) { _ in labelWidth + 10 } // see note
    }
}

struct WidthPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { }
}

extension View {
    func readWidth(onChange: @escaping (CGFloat) -> Void) -> some View {
        background(
            GeometryReader { geometryProxy in
                Color.clear
                    .preference(key: WidthPreferenceKey.self, value: geometryProxy.size.width)
            }
        )
        .onPreferenceChange(WidthPreferenceKey.self, perform: onChange)
    }
}

Note that in the custom view I've added 10 pixels to quickly emulate the spacing between a label and its form elements. There is probably a better way to make this work for accessibility sizes, etc., (e.g., the use of a @ScaledMetric value). You might also wish to apply this as padding rather than in the alignment guide calculation.

Below has a line with macOS13's LabeledContent, followed by LabeledHStack:

A form with LabeledHStack

like image 53
Scott Matthewman Avatar answered Oct 28 '25 21:10

Scott Matthewman


macOS 13

LabeledContent {
    HStack { 
        // ...
    }
} label: {
    Text("Count")
}
  • Read more about LabeledContent here

Previous version

Idea: Calculate the size of the label using GeometryReader, and offset the view by its width.

@State private var textSize = CGSize.zero

var body: some View {
    Form {
        TextField("Text", text: .constant("test"))
            .padding(.leading, -textSize.width)
        Toggle("Toggle", isOn: .constant(true))
            .toggleStyle(SwitchToggleStyle())
            .padding(.leading, -textSize.width)
        HStack {
            Text("Label")
                .readSize { textSize in
                    self.textSize = textSize
                }
            MenuButton("Menu") {
                Button("Pizza") {
                    print("Clicked Pizza")
                }
                Button("Pasta") {
                    print("Clicked Pasta")
                }
            }
            TextField("Topping", text: .constant("Cheese"))
                .labelsHidden()
        }
        .padding(.leading, -textSize.width - 10)
        .frame(maxWidth: .infinity)
    }
    .padding(.leading, textSize.width + 10)
    .padding()
}
  • extension View
extension View {
    func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
        background(
            GeometryReader { geometryProxy in
                Color.clear
                    .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
            }
        )
        .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
    }
}
  • struct SizePreferenceKey
struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) { }
}
  • Bonus: For a button that contains only label, you can use
Button(<#String#>) { <#Action#> }

instead of

Button(action: { <#Action#> }) { Text(<#String#>) }
like image 38
Nhat Nguyen Duc Avatar answered Oct 28 '25 20:10

Nhat Nguyen Duc



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!