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.