Skip to content

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 Stepper bound to an Int state variable using the value: parameter
  • How to use the onIncrement / onDecrement closure 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 ​

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 ​

  1. @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.

  2. @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 from stepperValue makes the two stepper demonstrations fully independent.

  3. 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.

  4. .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.

  5. RoundedRectangle(cornerRadius: 25.0).frame(width: 100 + widthIncrement, height: 100) — The visual output of the second stepper. 100 + widthIncrement means the rectangle starts at 100 pt wide and grows or shrinks by 100 pt increments as the user steps up or down.

  6. Stepper("Stepper 2") { … } onDecrement: { … } — The closure form. No binding is needed because the closures call incrementWidth(amount:) to update state manually. This is the pattern to reach for when a step needs to trigger logic beyond simple arithmetic.

  7. withAnimation(.easeInOut) — Wrapping the state mutation inside withAnimation tells 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 the in: 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

Released under the MIT License.