Home > database >  SwiftUI form alignment macOS
SwiftUI form alignment macOS

Time:10-05

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?

CodePudding user response:

You can wrap your content inside a VStack and use its alignment modifier to align all the content to the leading e.g:

VStack(alignment: .leading)

like this:

var body: some View {
    Form {
        VStack (alignment: .leading){
            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()
            }
            .frame(width: .infinity, height: .infinity)
        }
        
    }
    .padding()
}

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)
                .readSize { 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).

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

A form with LabeledHStack

CodePudding user response:

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#>) }
  • Related