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
.buttonRepeatBehaviorandKeyframeAnimatorare iOS 17+ APIs. For earlier versions, use a long-press gesture with a Timer for repeat behavior. - Frame cleanup is essential: Without
removeFrame, thebuttonFramesarray 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+