How to create a stepper with a TextField?

I am trying to create a standard stepper with text field in swiftUI, but it does not seem to exist.


I need something that looks like this:


Do I have to create my own view, or is something already available?


I tried an HStack with Text, TextField and stepper, but this does not align properly when placed in a form, and the stepper and text field cannot have a binding to the same value.


Any ideas on how to implement this?

Accepted Reply

Oof. This went down a rabbit hole and no mistake. Happily though, it turns out to be fairly simple to do what we need.


Conceptually, the StepperField view looks like this:


HStack {
    Title
    TextField
    Stepper
}


What we want is to get the value of the TextField's leading alignment guide and use that value to set the leading alignment guide of the surrounding HStack. Pushing that value upwards is straightforward, but you can't use the standard alignment values to do so, as they already have settings. If you create your own custom alignment, then a sub-view can set a guide value for that and it'll be inherited by its parent. Thus we want to do:


HStack {
    Title
    TextField(...).alignmentGuide(.someValue) { $0[.leading] }
    Stepper
}
.alignmentGuide(.leading) { $0[.someValue] }


Here you get the value of the TextField's leading alignment and store it a new alignment value. Then in the HStack you read that value and assign it to the HStack's leading alignment, which is what the Form view is using to align its content. I tried using .leading in both cases, but that value didn't get passed up it seems—at the HStack level, the custom alignment is available as an expicit value, but the same isn't true of any leading alignment set by the TextField.


So, you need to define a custom alignment. This one seems like it'll be useful, so it would make sense to put it somewhere easily accessible to other controls:


extension HorizontalAlignment {
    private enum ControlAlignment: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            return context[HorizontalAlignment.center]
        }
    }
    static let controlAlignment = HorizontalAlignment(ControlAlignment.self)
}


Now you can use this to pass the text field's leading offset up the stack. A useful side-effect of this approach is that, with explicit alignments in play, you no longer need a trailing Spacer on the StepperField:


struct StepperField: View {
    let title: LocalizedStringKey
    @Binding var value: Int

    var body: some View {
        HStack {
            Text(title)
            TextField("Enter Value", value: $value, formatter: NumberFormatter())
                .multilineTextAlignment(.center)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(minWidth: 15, maxWidth: 60)
                .alignmentGuide(.controlAlignment) { $0[.leading] }
            Stepper(title, value: $value, in: 1...100)
                .labelsHidden()
        }
        .alignmentGuide(.leading) { $0[.controlAlignment] }
    }
}


Everything will now work happily, and the Stepper field will now align itself correctly within the form.


However, if you swap your Form for a List, or for a VStack with leading alignment, you'll see the stepper slide too far to the left. Only the Form is using this special behavior. I'd guess that internally there's a special 'control alignment' value that the Form is ultimately using, and its default is '.leading'. That would explain why setting our leading alignment lets us line up our controls correctly. Unfortunately we don't have a way of easily determining whether we're in a List, Form, etc. I don't see anything inside EnvironmentValues that we could use to automatically infer our context, though, so likely the only real option from out here is to have a custom value set in the StepperField initializer that tells it where to align:


struct StepperField: View {
    let title: LocalizedStringKey
    @Binding var value: Int
    var alignToControl: Bool = false

    var body: some View {
        HStack {
            Text(title)
            TextField("Enter Value", value: $value, formatter: NumberFormatter())
                .multilineTextAlignment(.center)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(minWidth: 15, maxWidth: 60)
                .alignmentGuide(.controlAlignment) { $0[.leading] }
            Stepper(title, value: $value, in: 1...100)
                .labelsHidden()
        }
        .alignmentGuide(.leading) {
            self.alignToControl
                ? $0[.controlAlignment]
                : $0[.leading]
        }
    }
}

Replies

TextField is actually fairly flexible. By default you'd bind it to a String, true, but you can bind it to any type you like, so long as you provide a Formatter for it to use. That'll then let you bind both the stepper and the text field to the same underlying value. Additionally, if you wanted to merely show the value via some static text, then you'd be able to simply access the Int value directly in a string interpolation.


Here's a working sample that does what I believe you want:


struct ContentView: View {
    @State var value: Int = 0

    var body: some View {
        VStack(alignment: .leading) {
            Text("Enter Value (\(value))")
                .font(.headline)

            HStack {
                TextField("Enter Value", value: $value, formatter: NumberFormatter())
                    .multilineTextAlignment(.center)
                    .keyboardType(.decimalPad)
                    .frame(minWidth: 15, maxWidth: 60)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                Stepper("Value", value: $value, in: 0...100)
                    .labelsHidden()
                Spacer()
            }
        }
        .padding()
    }
}

Thanks, that helps a lot, but I'm still struggling with the alignment.


I am on macOS, and my control is wrapped in a Form, so that it aligns controls as expected (eg. see XCode Text Editing preferences form).


The following bit of code in my form:

HStack(spacing: 0) {
    Text("Count:")
    TextField("", value: $numDigits, formatter: NumberFormatter())
        .multilineTextAlignment(.trailing)
        .frame(minWidth: 15, maxWidth: 25)
    Stepper("Bla", value: self.$numDigits, in: 0...9)
        .labelsHidden()
}


gives something looking like this:


I would like "Count:" and "Format:" to line up, right justified.


I've played around trying to add spacers, dividers, different modifiers, etc. but all come out wrong.


Any ideas?

Ultimately it'll all come down to the use of anchors and alignment values, as that's what SwiftUI will be using to line things up. Generally, though, it's lining up the labels of the controls, so I'm not sure what value to use for that. I would guess it's using a custom 'leading' alignment that's set to the leading edge of the actual control rather than its label, but I'm not certain.


I'll have a dig around and see what I can come up with.

Oof. This went down a rabbit hole and no mistake. Happily though, it turns out to be fairly simple to do what we need.


Conceptually, the StepperField view looks like this:


HStack {
    Title
    TextField
    Stepper
}


What we want is to get the value of the TextField's leading alignment guide and use that value to set the leading alignment guide of the surrounding HStack. Pushing that value upwards is straightforward, but you can't use the standard alignment values to do so, as they already have settings. If you create your own custom alignment, then a sub-view can set a guide value for that and it'll be inherited by its parent. Thus we want to do:


HStack {
    Title
    TextField(...).alignmentGuide(.someValue) { $0[.leading] }
    Stepper
}
.alignmentGuide(.leading) { $0[.someValue] }


Here you get the value of the TextField's leading alignment and store it a new alignment value. Then in the HStack you read that value and assign it to the HStack's leading alignment, which is what the Form view is using to align its content. I tried using .leading in both cases, but that value didn't get passed up it seems—at the HStack level, the custom alignment is available as an expicit value, but the same isn't true of any leading alignment set by the TextField.


So, you need to define a custom alignment. This one seems like it'll be useful, so it would make sense to put it somewhere easily accessible to other controls:


extension HorizontalAlignment {
    private enum ControlAlignment: AlignmentID {
        static func defaultValue(in context: ViewDimensions) -> CGFloat {
            return context[HorizontalAlignment.center]
        }
    }
    static let controlAlignment = HorizontalAlignment(ControlAlignment.self)
}


Now you can use this to pass the text field's leading offset up the stack. A useful side-effect of this approach is that, with explicit alignments in play, you no longer need a trailing Spacer on the StepperField:


struct StepperField: View {
    let title: LocalizedStringKey
    @Binding var value: Int

    var body: some View {
        HStack {
            Text(title)
            TextField("Enter Value", value: $value, formatter: NumberFormatter())
                .multilineTextAlignment(.center)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(minWidth: 15, maxWidth: 60)
                .alignmentGuide(.controlAlignment) { $0[.leading] }
            Stepper(title, value: $value, in: 1...100)
                .labelsHidden()
        }
        .alignmentGuide(.leading) { $0[.controlAlignment] }
    }
}


Everything will now work happily, and the Stepper field will now align itself correctly within the form.


However, if you swap your Form for a List, or for a VStack with leading alignment, you'll see the stepper slide too far to the left. Only the Form is using this special behavior. I'd guess that internally there's a special 'control alignment' value that the Form is ultimately using, and its default is '.leading'. That would explain why setting our leading alignment lets us line up our controls correctly. Unfortunately we don't have a way of easily determining whether we're in a List, Form, etc. I don't see anything inside EnvironmentValues that we could use to automatically infer our context, though, so likely the only real option from out here is to have a custom value set in the StepperField initializer that tells it where to align:


struct StepperField: View {
    let title: LocalizedStringKey
    @Binding var value: Int
    var alignToControl: Bool = false

    var body: some View {
        HStack {
            Text(title)
            TextField("Enter Value", value: $value, formatter: NumberFormatter())
                .multilineTextAlignment(.center)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .frame(minWidth: 15, maxWidth: 60)
                .alignmentGuide(.controlAlignment) { $0[.leading] }
            Stepper(title, value: $value, in: 1...100)
                .labelsHidden()
        }
        .alignmentGuide(.leading) {
            self.alignToControl
                ? $0[.controlAlignment]
                : $0[.leading]
        }
    }
}

Thanks, that helps a lot, and I agree about the rabit hole.


A stepper with a label and text edit is such a standard thing that I hoped to just be missing it when posting the question. I tried to find other examples of steppers being used inside of apples own applications, and could not come up with any.


I am afraid this is still not 100% correct, but I have enough to make it work, but it is an ugly hack, and will almost certainly break in future macos releases. If you look carefully in my example, then you will see that the two labels (Text) do not right-align correctly. Your alignment does make the content correctly left alight, but not the labels.


My work-around was to add trailing padding to the Text, but then it is too much. I had to change the actual padding value to get it just-right, and that meant pixel counting.


I am afraid without knowing how apple does alignment in forms, there is probably not going to be a way to get it perfect.


Thanks again for the help.

Interesting; can you share a code sample? In my version everything seemed to line up happily with the following code:


struct ContentView: View {
    @State var pickerChoice: Int = 1
    @State var count: Int = 1

    var body: some View {
        Form {
            Picker("Format:", selection: $pickerChoice) {
                ForEach(1..<6) {
                    Text("\($0)")
                }
            }
            StepperField(title: "Count:", value: $count, alignToControl: true)
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

Aah, sorry about this. Looking again after your comment I realised I broke your StepperField by adding a "spacing: 0" to the H-Stack.


I did this much earlier in my code to get the TextField and Stepper to be adjacent one another, as it normally appears.


I've modified the StepperField to put the TextField and Stepper in their own HStack, so that the spacing between them can be removed. In the code below, I used to have a spacing of 0 on line 7, but now added it at line 9.


struct StepperField: View {
  let title: LocalizedStringKey
  @Binding var value: Int
  var alignToControl: Bool = false
  
  var body: some View {
    HStack {
      Text(title)
      HStack(spacing: 0) {
        TextField("Enter Value", value: $value, formatter: NumberFormatter())
          .multilineTextAlignment(.center)
          .textFieldStyle(RoundedBorderTextFieldStyle())
          .frame(minWidth: 15, maxWidth: 60)
          .alignmentGuide(.controlAlignment) { $0[.leading] }
        Stepper(title, value: $value, in: 1...100)
          .labelsHidden()
      }
    }
    .alignmentGuide(.leading) {
      self.alignToControl
        ? $0[.controlAlignment]
        : $0[.leading]
    }
  }
}

I was unhappy with the native Stepper component and decided to create my own Swift package to solve the issue... it may be able to help some others as well.

https://github.com/joe-scotto/TextFieldStepper