Create a Stepper in SwiftUI | SwiftUI Bootcamp #41
A Stepper solves the problem of discrete numeric input — think quantity selectors, timer settings, or font size controls — without the imprecision of a slider or the verbosity of a text field. After this lesson you'll be able to wire up both a simple value-bound stepper and a custom closure-based stepper that drives animated side effects.
What You'll Learn
- How to create a basic
Stepperbound to anIntstate variable using thevalue:parameter - How to use the
onIncrement/onDecrementclosure form to run arbitrary logic on each step - How to trigger smooth animations from inside a stepper's action closures using
withAnimation - The difference between letting SwiftUI manage arithmetic vs. managing it yourself
Mental Model
Think of a Stepper like the +/- buttons on a restaurant order screen. The simple form is like a waiter who just updates a tally on their notepad — they handle the math for you, and you only care about the final number. The closure form is like giving that waiter a custom script: "When you increment, also flash the order total and play a sound." The closure form runs your code on each tap, which is why it's powerful for animated side effects.
The key distinction is ownership of the arithmetic. When you use value: $stepperValue, SwiftUI owns the addition and subtraction. When you use onIncrement: and onDecrement:, you own it — which means you can clamp, animate, or trigger network calls as part of the step.
Detailed Explanation
Stepper is a SwiftUI control that presents a label alongside a decrement (−) and an increment (+) button. In its simplest form you pass a label string and a binding to a numeric value; SwiftUI automatically adjusts the value by 1 on each tap without any extra code from you. You can also restrict the range with in: and set the step: size.
The closure form — Stepper("Label") { … } onDecrement: { … } — gives you full control. SwiftUI calls onIncrement when the + button is tapped and onDecrement when the − button is tapped. No binding is required, because you are responsible for updating whatever state you care about inside those closures.
Use the value-binding form when you simply need an integer counter (item quantities, page numbers, score adjustments). Use the closure form when the step should trigger something beyond incrementing a number, such as updating a shape's width with an animation, loading the next page of data, or playing a haptic.
One nuance: Stepper does not enforce a minimum or maximum value by itself when you use the closure form — that is your responsibility. Forgetting to clamp the value can let users step into impossible states (negative quantities, widths that extend off screen, etc.).
Code Structure
StepperBootcamp.swift demonstrates both stepper forms side by side. The first stepper uses the simple value: binding pattern to manage an integer counter. The second stepper uses closures to animate the width of a RoundedRectangle, illustrating how the same control can drive non-numeric visual changes.
Complete Code
StepperBootcamp.swift
import SwiftUI
struct StepperBootcamp: View {
@State var stepperValue: Int = 10 // simple integer counter owned by this view
@State var widthIncrement: CGFloat = 0 // accumulated width delta driven by the closure stepper
var body: some View {
VStack {
Stepper("Stepper: \(stepperValue)", value: $stepperValue) // SwiftUI manages the ±1 arithmetic automatically
.padding(50)
RoundedRectangle(cornerRadius: 25.0)
.frame(width: 100 + widthIncrement, height: 100) // base width of 100; widthIncrement shifts it each step
Stepper("Stepper 2") {
// increment
incrementWidth(amount: 100) // called when the user taps +
} onDecrement: {
// decrement
incrementWidth(amount: -100) // called when the user taps −
}
}
}
func incrementWidth(amount: CGFloat) {
withAnimation(.easeInOut) { // wrapping the state change in withAnimation makes the rectangle grow/shrink smoothly
widthIncrement += amount
}
}
}
struct StepperBootcamp_Previews: PreviewProvider {
static var previews: some View {
StepperBootcamp()
}
}Code Walkthrough
@State var stepperValue: Int = 10— The integer the first stepper controls. Starting at 10 (not 0) makes it immediately obvious that the stepper changes an existing value rather than building up from nothing.@State var widthIncrement: CGFloat = 0— Tracks how much the rectangle's width has been adjusted by the second stepper. Keeping it as a separate state variable fromstepperValuemakes the two stepper demonstrations fully independent.Stepper("Stepper: \(stepperValue)", value: $stepperValue)— The binding form. The label interpolates the current value so users can see the number without a separate label view. SwiftUI handles the ±1 step automatically..padding(50)— Adds breathing room around the stepper so the + and − buttons are easy to tap. This is especially important on real devices where finger targets smaller than ~44pt are error-prone.RoundedRectangle(cornerRadius: 25.0).frame(width: 100 + widthIncrement, height: 100)— The visual output of the second stepper.100 + widthIncrementmeans the rectangle starts at 100 pt wide and grows or shrinks by 100 pt increments as the user steps up or down.Stepper("Stepper 2") { … } onDecrement: { … }— The closure form. No binding is needed because the closures callincrementWidth(amount:)to update state manually. This is the pattern to reach for when a step needs to trigger logic beyond simple arithmetic.withAnimation(.easeInOut)— Wrapping the state mutation insidewithAnimationtells SwiftUI to interpolate the layout change over time rather than snapping. The rectangle visibly grows and shrinks with a smooth ease-in/ease-out curve.
Common Mistakes
Mistake: Forgetting to clamp values in the closure form, allowing negative widths or impossible states
The binding form (value: $stepperValue, in: 0...100) enforces a range automatically. The closure form does not — you can keep tapping − until widthIncrement is so negative that 100 + widthIncrement becomes negative and the rectangle disappears. Always guard against invalid states inside onDecrement.
Mistake: Using a Stepper for large value ranges (e.g., 0 to 1000)
Steppers work well for small, precise adjustments. If users need to jump from 50 to 500 they will tap + hundreds of times, which is unusable. For large ranges use a Slider instead, or add a step: of 10 or 50 to make each tap count for more.
Mistake: Putting withAnimation inside the view body instead of the action closure
Animations must wrap the state mutation, not the view declaration. Placing withAnimation around a Stepper modifier rather than inside the closure it calls has no effect on the animated transition.
Key Takeaways
- Use the
value:binding form for straightforward numeric counters — SwiftUI handles the arithmetic and you get range clamping for free with thein:parameter. - Use the closure form (
onIncrement:/onDecrement:) whenever a step should trigger custom logic — animations, network calls, sound effects, or any state change that isn't simple ±1. - Always clamp values manually when using the closure form; without
in:constraints SwiftUI does not prevent values from going out of bounds.
Last updated: June 27, 2026