What You Will Build

An increment/decrement counter where holding the +/- buttons auto-repeats using iOS 17's .buttonRepeatBehavior(.enabled). Each value change spawns a floating number that drifts upward and fades away using KeyframeAnimator. This is perfect for quantity selectors in shopping carts, stepper controls, or any input that benefits from press-and-hold acceleration.

Why This Pattern Matters

The built-in Stepper control looks generic and offers no visual feedback per increment. By combining .buttonRepeatBehavior(.enabled) with keyframe animations, each tap or repeat produces a floating ghost number that reinforces the value change. This is the kind of polish that distinguishes a premium app from a default one.

Key SwiftUI Concepts

  • .buttonRepeatBehavior(.enabled) — iOS 17 modifier that auto-fires the button action on long press.
  • KeyframeAnimator — drives multi-property animations (offset, opacity, blur) with precise timing tracks.
  • Frame management — each increment spawns a temporary frame object that is cleaned up after animation completes.

Step 1: Define the Animation Frame Model

struct RepeatButtonFrame: Identifiable, Equatable {
    var id: UUID = .init()
    var value: Int
    var offset: CGSize = .zero
    var opacity: CGFloat = 1
    var triggerKeyFrame: Bool = false
}

Step 2: Build the Counter with Keyframe Overlays

struct RepeatIncrementer: View {
    @Binding var count: Int
    @State private var buttonFrames: [RepeatButtonFrame] = []

    var body: some View {
        HStack(spacing: 12) {
            Button(action: {
                if count != 0 {
                    let frame = RepeatButtonFrame(value: count)
                    buttonFrames.append(frame)
                    toggleAnimation(frame.id, false)
                }
            }, label: {
                Image(systemName: "minus")
            })
            .fontWeight(.bold)
            .buttonRepeatBehavior(.enabled)

            Text("\(count)")
                .fontWeight(.bold)
                .frame(width: 45, height: 45)
                .background(
                    .white.shadow(.drop(color: .black.opacity(0.15), radius: 5)),
                    in: .rect(cornerRadius: 10)
                )
                .overlay {
                    ForEach(buttonFrames) { btFrame in
                        KeyframeAnimator(
                            initialValue: RepeatButtonFrame(value: 0),
                            trigger: btFrame.triggerKeyFrame
                        ) { frame in
                            Text("\(btFrame.value)")
                                .fontWeight(.bold)
                                .offset(frame.offset)
                                .opacity(frame.opacity)
                                .blur(radius: (1 - frame.opacity) * 5)
                        } keyframes: { _ in
                            KeyframeTrack(\.offset) {
                                LinearKeyframe(CGSize(width: 0, height: -20), duration: 0.2)
                                LinearKeyframe(CGSize(width: .random(in: -2...2), height: -40), duration: 0.2)
                                LinearKeyframe(CGSize(width: .random(in: -2...2), height: -70), duration: 0.4)
                            }
                            KeyframeTrack(\.opacity) {
                                LinearKeyframe(1, duration: 0.2)
                                LinearKeyframe(1, duration: 0.2)
                                LinearKeyframe(0.7, duration: 0.2)
                                LinearKeyframe(0, duration: 0.2)
                            }
                        }
                    }
                }

            Button(action: {
                let frame = RepeatButtonFrame(value: count)
                buttonFrames.append(frame)
                toggleAnimation(frame.id)
            }, label: {
                Image(systemName: "plus")
            })
            .fontWeight(.bold)
            .buttonRepeatBehavior(.enabled)
        }
    }

Step 3: Animation Trigger and Cleanup

The animation trigger is delayed by 0.01 seconds to ensure the frame is in the view hierarchy before the keyframe animator fires:

func toggleAnimation(_ id: UUID, _ increment: Bool = true) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
        if let index = buttonFrames.firstIndex(where: { $0.id == id }) {
            buttonFrames[index].triggerKeyFrame = true
            if increment { count += 1 } else { count -= 1 }
            removeFrame(id)
        }
    }
}

func removeFrame(_ id: UUID) {
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
        buttonFrames.removeAll(where: { $0.id == id })
    }
}

Tips and Pitfalls

  • iOS 17 required: Both .buttonRepeatBehavior and KeyframeAnimator are iOS 17+ APIs. For earlier versions, use a long-press gesture with a Timer for repeat behavior.
  • Frame cleanup is essential: Without removeFrame, the buttonFrames array grows unbounded during rapid pressing, causing memory issues.
  • The 0.01s delay: Without this micro-delay, the keyframe trigger fires before SwiftUI has inserted the overlay view, resulting in a missed animation.
  • Random horizontal drift: The .random(in: -2...2) on the X offset gives each floating number a slightly different path, avoiding a robotic look.
  • Blur tie-in: The blur radius increases as opacity decreases: (1 - frame.opacity) * 5. This creates a natural dissolve effect.

iOS Version: iOS 17+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.