在电商App的商品详情页和评价提交页,星级评分几乎无处不在。展示评分时需要支持半星,用户打分时又希望支持触摸和滑动,切换时还得带上平滑的过渡动画。用SwiftUI实现这样一个控件,核心思路是拆成两层:底层用一个StarView负责画单颗星星并按比例填充,上层用一个AnimatedRatingBar负责组合、手势换算和动画驱动。本文完整拆解这套方案的实现细节。

一、StarView:单颗星星的按比例填充
一颗星星本质上由两层图形叠加而成:底层是灰色的空星轮廓,上层是根据填充比例裁剪出来的实心星。SwiftUI里最简单的做法是借助SF Symbol里的star.fill,配合mask按宽度裁剪。这样做的好处是不用手算贝塞尔路径,尺寸变化时也天然自适应。
填充比例的取值范围是0到1。当比例是1表示整颗实心,0.5表示半星,0则是空心。裁剪时要注意一个细节:mask的矩形必须从左侧开始对齐,如果用frame加alignment控制,写法上容易出错的点是忘了限制矩形宽度不超过星星本身,需要用min(width, size * ratio)兜底。
struct StarView: View {
var fillRatio: CGFloat // 0...1
var size: CGFloat = 24
var filledColor: Color = .orange
var emptyColor: Color = .gray
var body: some View {
Image(systemName: "star.fill")
.font(.system(size: size))
.foregroundColor(emptyColor)
.overlay(
Image(systemName: "star.fill")
.font(.system(size: size))
.foregroundColor(filledColor)
.mask(
GeometryReader { geo in
Rectangle()
.frame(width: geo.size.width * fillRatio)
.frame(maxWidth: .infinity, alignment: .leading)
}
)
)
}
}如果项目要求使用自定义矢量图或者多边形星星,可以把Image替换成Path绘制的五角星,裁剪逻辑完全不变。用Canvas绘制时还可以做渐变色填充,代价是失去了SF Symbol的便利性,两种方案按需选择即可。
二、AnimatedRatingBar:组合布局与手势换算
有了单颗星星,评分条就是把五颗星横向排列,并根据总分值计算每颗星的填充比例。比如评分是3.7,那么前三颗是1,第四颗是0.7,第五颗是0。这个换算逻辑抽成一个小函数,代码会非常清晰。
func ratio(for index: Int, rating: Double) -> Double {
let diff = rating - Double(index)
if diff >= 1 { return 1 }
if diff <= 0 { return 0 }
return diff
}手势部分用DragGesture同时覆盖点击和滑动两种场景。关键在于把手指在横轴上的偏移量换算成分值:用GeometryReader拿到评分条总宽度,除以星星数量得到每颗星的步长,再用location.x除以步长即可。开启编辑模式时,还应该把分值按半星粒度取整,也就是乘2、取整、再除2,这样用户点左半边和右半边会得到不同的分值。
struct AnimatedRatingBar: View {
@Binding var rating: Double
var editable: Bool = true
var starSize: CGFloat = 28
var spacing: CGFloat = 6
@State private var dragRating: Double = 0
var displayRating: Double { editable ? dragRating : rating }
var body: some View {
GeometryReader { geo in
HStack(spacing: spacing) {
ForEach(0..<5, id: \.self) { i in
StarView(fillRatio: ratio(for: i, rating: displayRating),
size: starSize)
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
guard editable else { return }
let step = geo.size.width / 5
var r = Double(value.location.x / step)
r = (r * 2).rounded() / 2 // 半星粒度
dragRating = min(max(r, 0), 5)
}
.onEnded { _ in
rating = dragRating
}
)
}
.frame(height: starSize)
.animation(.spring(response: 0.25, dampingFraction: 0.7),
value: displayRating)
}
}这里用了一个独立的@State变量dragRating来驱动滑动过程中的实时预览,只有在手势结束时才把值写回外部绑定。这样做的好处是避免滑动过程中频繁触发外部状态更新,比如表单校验或者网络请求预提交之类的副作用。
三、动画细节与交互体验优化
动画方面给displayRating加一个spring修饰符,星星在切换时就会有一个轻微的弹性过渡,视觉上比线性的easeInOut更有活力。如果想让实心部分的出现更有层次,还可以给StarView里的overlay加上scaleEffect配合填充比例做缩放,半星切换时会有放大再回落的微动效。
防误触是实际项目里容易被忽略的问题。评分条如果放在ScrollView里,DragGesture会和滚动手势冲突。解决办法有两个:一是给评分条单独包一层highPriorityGesture,让它在命中区域内优先响应;二是把minimumDistance设为一个小阈值(比如4到6个点),让轻微的滑动交给父视图处理,只有明确停留在评分条上的操作才判定为打分。
另外建议在不可编辑的展示场景下显式关闭手势并改用固定分值渲染,展示模式下的评分通常来自服务端,像4.3、3.8这样的分值直接交给ratio函数就能正确渲染出带小数部分的填充星,这也是这套组件比单纯的Picker或者按钮数组灵活得多的地方。
四、扩展方向
在这个基础上可以继续扩展:支持自定义星星数量只需要把硬编码的5改成参数;需要展示评分文案时,在HStack尾部追加一个Text显示String(format: "%.1f", rating);如果要做带步进动画的初始进场效果,可以在onAppear里用withAnimation把评分从0过渡到目标值,星星会一颗一颗依次点亮,配合前面提到的弹性动画,整体观感会相当不错。整套方案没有依赖任何第三方库,直接拷贝两个结构体就能在任何SwiftUI项目中使用。
SwiftUI评分控件AnimatedRatingBarStarView修改时间:2026-09-15 23:11:05