在SwiftUI中,数字输入是表单场景的高频需求。系统自带的Stepper虽然能完成基础增减,但它的外观和交互相对固定,无法直接给数值变化添加流畅的滚动或弹性动画。而NumberPicker虽然可以通过Picker的滚轮样式呈现,但默认样式在自定义界面中往往显得突兀,切换数字时也缺少过渡效果。这篇文章会从组件封装的角度出发,分别实现带动画的AnimatedStepper和可定制的NumberPicker,并把两者组合成一个联动示例。

一、AnimatedStepper:从原生控件到带动画的数字增减器
原生Stepper由减号按钮、数值标签和加号按钮组成,它遵循系统设计,在Form中表现稳定,但颜色、间距、点击反馈都难以深度定制。更重要的是,当数值变化时,标签只是瞬间替换,没有视觉过渡。为了获得更好的手感,可以基于Button和Text重新创建结构。
核心思路是用@State或@Binding保存值,在按钮动作中通过withAnimation修改状态,同时给Text附加contentTransition(.numericText()),这样数字变化会触发数字滚动动画。需要iOS 17及以上系统支持。下面是一个基础实现:
struct AnimatedStepper: View {
@Binding var value: Int
let range: ClosedRange<Int>
let step: Int
var body: some View {
HStack(spacing: 16) {
Button {
guard value > range.lowerBound else { return }
withAnimation(.snappy(duration: 0.25)) {
value = max(range.lowerBound, value - step)
}
} label: {
Image(systemName: "minus.circle.fill")
.font(.title2)
}
.buttonStyle(.plain)
.disabled(value <= range.lowerBound)
Text(value, format: .number)
.font(.system(size: 34, weight: .bold, design: .rounded))
.monospacedDigit()
.contentTransition(.numericText(value: Double(value)))
.animation(.snappy(duration: 0.25), value: value)
Button {
guard value < range.upperBound else { return }
withAnimation(.snappy(duration: 0.25)) {
value = min(range.upperBound, value + step)
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
}
.buttonStyle(.plain)
.disabled(value >= range.upperBound)
}
.padding()
.background(.background, in: RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.08), radius: 8, y: 4)
}
}
这个实现使用monospacedDigit保证数字宽度一致,避免位数变化时产生水平跳动。减号和加号按钮在到达边界时会自动禁用,避免越界操作。动画方面,contentTransition(.numericText(value:))会根据数值变化方向自动产生向上或向下的滚动效果,animation修饰符则让过渡更加平滑。也可以把withAnimation移除,只保留Text上的animation,这样状态变化依然会触发动画,代码更简洁。
如果希望按钮有触感反馈,可以给按钮添加sensoryFeedback修饰符,例如在减号按钮上使用.sensoryFeedback(.decrease, trigger: value),加号使用.sensoryFeedback(.increase, trigger: value)。对于长时间连续增减的需求,可以结合LongPressGesture与Timer实现按压重复触发,但需要注意主线程更新频率和定时器生命周期。
二、NumberPicker:打造可定制的数字选择器
SwiftUI的Picker支持.wheel样式,在Form中会呈现类似UIPickerView的滚轮效果。如果希望独立使用或深度定制,直接放一个Picker并不总是理想,因为它的背景、分隔线、字体都受系统限制。自定义NumberPicker的常见做法是使用UIViewRepresentable包装UIPickerView,但这样代码量较大且需要处理协调器。另一种思路是使用ScrollView和LazyVStack实现垂直滚轮,配合ScrollViewReader定位到选中项。
这里给出一个轻量的自定义NumberPicker,基于ScrollView和视觉反馈实现点击选择与自动居中:
struct NumberPicker: View {
let range: ClosedRange<Int>
@Binding var selection: Int
var body: some View {
ScrollViewReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
LazyVStack(spacing: 8) {
ForEach(Array(range), id: \.self) { number in
Text("\(number)")
.font(.title3)
.fontWeight(selection == number ? .bold : .regular)
.foregroundStyle(selection == number ? .primary : .secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(selection == number ? Color.accentColor.opacity(0.2) : .clear)
)
.id(number)
.onTapGesture {
withAnimation(.bouncy(duration: 0.35)) {
selection = number
}
}
}
}
.scrollTargetLayout()
}
.scrollTargetBehavior(.viewAligned)
.frame(height: 220)
.onAppear {
proxy.scrollTo(selection, anchor: .center)
}
.onChange(of: selection) { _, newValue in
withAnimation(.easeInOut(duration: 0.25)) {
proxy.scrollTo(newValue, anchor: .center)
}
}
}
}
}
这个实现利用ScrollViewReader在选中值变化时自动滚动到对应项,点击任意数字会更新选中状态。选中项通过粗体、主色文字和浅色背景突出显示,未选中项则使用次要样式,形成清晰的视觉层级。scrollTargetBehavior(.viewAligned)让滚动停止在合适位置,产生类似滚轮的粘性效果,避免停在两个数字之间。
如果需要手指拖动选择,可以进一步结合DragGesture计算偏移,但在实际项目中点击选择已经能满足大部分数字选择需求。另一方案是采用UIViewRepresentable包装UIPickerView,它能提供系统级惯性滚动和触感,适合需要大量连续数字选择的场景,但样式定制成本较高。选择哪种方案取决于是否需要原生滚动手感以及项目对视觉一致性的要求。
三、数字变化过渡动画的几种实现路径
数字变化动画不只是Text替换,SwiftUI提供了多个层级的能力。最基础的是隐式动画,给数值绑定animation,但Text内容变化不会自动产生滚动效果。iOS 17引入的contentTransition(.numericText())专为数字设计,它会在数字增减时产生类似计数器翻动或滚动的过渡,适合AnimatedStepper。这个方法还可以传入countsDown参数控制动画方向。
如果希望更夸张的动画,比如每个数字字符依次跳动、缩放、翻转,可以使用自定义过渡。核心做法是给Text添加id(number)并在数值变化时用withAnimation切换,让旧视图移除、新视图插入。下面是一个基于不对称过渡的示例:
struct TransitionNumber: View {
let number: Int
var body: some View {
Text(number, format: .number)
.font(.system(size: 48, weight: .black, design: .rounded))
.monospacedDigit()
.id(number)
.transition(
.asymmetric(
insertion: .move(edge: .bottom).combined(with: .opacity),
removal: .move(edge: .top).combined(with: .opacity)
)
)
}
}
在使用TransitionNumber时,需要把它放在一个容器中,例如ZStack或VStack,并通过按钮动作触发withAnimation { value = newValue }。由于id随数值变化,SwiftUI会移除旧视图并插入新视图,transition修饰符定义的动画就会生效。不过这种方案需要注意视图的布局是否会因为移除和插入产生跳动,通常将容器设置为固定高度可以避免。
对比几种方案:contentTransition(.numericText)最简单、性能最好,但样式固定;自定义transition配合id和withAnimation可以控制方向、缩放、透明度,但需要保证旧视图确实被移除;phaseAnimator适合循环动画或状态相位动画,用于持续脉冲效果,不适合一次性数字切换;KeyframeAnimator则可以实现复杂的多阶段动画,比如数字回弹、阻尼、旋转。优先考虑系统提供的contentTransition,定制需求高再引入自定义过渡。
四、联动案例:AnimatedStepper与NumberPicker协同工作
将前面两个组件放入同一个界面,可以实现步进器和选择器双向同步。外层维护一个@State变量,AnimatedStepper绑定该值,NumberPicker也绑定该值。无论点击加号减号还是在滚轮中点击数字,另一个组件都会因为状态变化而更新。这种联动在设置数量、选择年龄、调整闹钟等场景非常实用。
下面是完整的联动示例:
struct ContentView: View {
@State private var quantity = 3
var body: some View {
VStack(spacing: 32) {
AnimatedStepper(value: $quantity, range: 1...20, step: 1)
NumberPicker(range: 1...20, selection: $quantity)
Text("当前数量:\(quantity)")
.font(.headline)
.padding()
.background(.ultraThinMaterial, in: Capsule())
}
.padding()
}
}
运行后,点击AnimatedStepper的加减按钮,数字会以滚动方式变化,NumberPicker中对应数字高亮并自动滚动到中心。反过来,在NumberPicker中点击任一数字,AnimatedStepper中的数值也会动画更新。这种双向绑定让数据流清晰,组件之间只通过Binding通信,没有耦合。可以将这两个组件封装为可复用控件,暴露range、step、样式等参数,方便在不同页面中使用。
无障碍方面,为按钮添加accessibilityLabel和accessibilityValue,NumberPicker每个数字设置accessibilityAddTraits(.isSelected)以支持VoiceOver。如果数字范围很大,例如1到1000,NumberPicker的LazyVStack能保证性能,但滚动到目标项需要计算偏移,可能需要调整。此时更推荐使用系统Picker的wheel样式或UIPickerView,因为它们在大量数据下惯性滚动表现更好。对于AnimatedStepper,范围大时长按连续增减功能就变得重要,可以添加一个Timer在用户长按按钮时周期性触发增减,增强交互效率。

