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?
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]
}
}
}