原作中的动画旋转星星并通过某种旋转和滑动来定位它。我已经达到了一定的效果,但看起来并不像原来那样流畅,这里的视频。另外,当滑块移向范围末端时,我的星形定位有点偏离。如何才能达到与目标动画相同的效果?这是我的代码:
struct ContentView: View {
@State private var value = 0.0
@State private var previousValue = 0.0
@State private var rotationAngle = 0.0
private var starSize = 30.0
var body: some View {
VStack(spacing: 90) {
Text("Rating: \(Int(value))/10")
GeometryReader { proxy in
VStack {
Spacer()
Slider(value: $value, in: 0...10, step: 1)
.overlay(alignment: .leading) {
Image(systemName: "star.fill")
.resizable()
.scaledToFit()
.frame(width: starSize, height: starSize)
.foregroundStyle(.gray)
.rotationEffect(.degrees(rotationAngle))
.offset(x: xOffset(proxy: proxy), y: yOffset())
.animation(.spring(duration: 0.8, bounce: value == 0 ? 0 : 0.2).delay(0.1), value: value)
.onChange(of: value) { oldValue, newValue in
if newValue > oldValue {
rotationAngle = -20
} else if newValue < oldValue {
rotationAngle = 20
}
withAnimation(.spring(duration: 0.4).delay(0.2)) {
rotationAngle = 0
}
previousValue = newValue
}
.allowsHitTesting(false)
}
.background {
Color.yellow
}
}
.background {
Color.green
}
}
.frame(height: 80)
.padding(.horizontal)
}
}
private func xOffset(proxy: GeometryProxy) -> CGFloat {
guard value > 0 else { return 0 }
let sliderWidth = proxy.size.width
let position = sliderWidth * (value / 10)
return position - (starSize / 2)
}
private func yOffset() -> CGFloat {
guard value > 0 else { return 0 }
return -1.5 * starSize
}
}
如果此动画使用原生
Slider
,则很难将拇指更改为自定义形状。所以我建议创建一个自定义滑块。
它有助于首先创建一些自定义形状:
分段水平线
此形状用作拇指移动的刻度。
struct SegmentedHorizontalLine: Shape {
let minValue: Int
let maxValue: Int
let spacing: CGFloat = 1
let cornerSize = CGSize(width: 1, height: 1)
func path(in rect: CGRect) -> Path {
let nSteps = maxValue - minValue
let stepWidth = (rect.width + spacing) / CGFloat(max(1, nSteps))
return Path { path in
var x = rect.minX
for _ in 0..<nSteps {
let rect = CGRect(x: x, y: rect.minY, width: stepWidth - spacing, height: rect.height)
path.addRoundedRect(in: rect, cornerSize: cornerSize)
x += stepWidth
}
}
}
}
使用示例:
SegmentedHorizontalLine(minValue: 0, maxValue: 10)
.frame(height: 4)
.foregroundStyle(.gray)
.padding()
粗星
SF 符号仅包含带有尖点的星星。但是,可以很容易地创建 5 角星作为自定义形状:
struct ChunkyStar: Shape {
func path(in rect: CGRect) -> Path {
let halfSize = min(rect.width, rect.height) / 2
let innerSize = halfSize * 0.5
let angle = 2 * Double.pi / 5
let midX = rect.midX
let midY = rect.midY
var points = [CGPoint]()
for i in 0..<5 {
let xOuter = midX + (halfSize * sin(angle * Double(i)))
let yOuter = midY - (halfSize * cos(angle * Double(i)))
points.append(CGPoint(x: xOuter, y: yOuter))
let xInner = midX + (innerSize * sin(angle * (Double(i) + 0.5)))
let yInner = midY - (innerSize * cos(angle * (Double(i) + 0.5)))
points.append(CGPoint(x: xInner, y: yInner))
}
return Path { path in
if let firstPoint = points.first, let lastPoint = points.last {
let startingPoint = CGPoint(
x: lastPoint.x + ((firstPoint.x - lastPoint.x) / 2),
y: lastPoint.y + ((firstPoint.y - lastPoint.y) / 2)
)
points.append(startingPoint)
var previousPoint = startingPoint
for nextPoint in points {
if nextPoint == firstPoint {
path.move(to: startingPoint)
} else {
path.addArc(
tangent1End: previousPoint,
tangent2End: nextPoint,
radius: 1
)
}
previousPoint = nextPoint
}
path.closeSubpath()
}
}
}
}
使用示例:
ChunkyStar()
.fill(.yellow)
.stroke(.red, lineWidth: 2)
.frame(width: 50, height: 50)
现在把它们放在一起。一些注释如下:
struct StarSlider: View {
enum DragDirection {
case atRest
case forwards
case backwards
var rotationDegrees: Double {
switch self {
case .atRest: 0
case .forwards: -360 / 10
case .backwards: 360 / 10
}
}
}
@Binding var value: Double
@State private var sliderWidth = CGFloat.zero
@State private var dragDirection = DragDirection.atRest
let minValue: Double
let maxValue: Double
private let starSize: CGFloat = 40
private let thumbSize: CGFloat = 20
private var fillColor: Color {
Color(red: 0.98, green: 0.57, blue: 0.56)
}
private var fgColor: Color {
Color(red: 0.99, green: 0.42, blue: 0.43)
}
private var haloWidth: CGFloat {
(starSize - thumbSize) / 2
}
private var thumb: some View {
Circle()
.fill(fgColor)
.stroke(.white, lineWidth: 2)
.frame(width: thumbSize, height: thumbSize)
.padding(haloWidth)
}
private var star: some View {
ChunkyStar()
.fill(fillColor)
.stroke(fgColor, lineWidth: 2)
.frame(width: starSize, height: starSize)
}
private var hasValue: Bool {
value > 0
}
private var isBeingDragged: Bool {
dragDirection != .atRest
}
private var xOffset: CGFloat {
let position = (value - minValue) * sliderWidth / (maxValue - minValue)
return position - (starSize / 2)
}
private var yOffset: CGFloat {
hasValue ? -starSize : 0
}
var body: some View {
ZStack(alignment: .leading) {
SegmentedHorizontalLine(minValue: Int(minValue), maxValue: Int(maxValue))
.frame(height: 4)
.foregroundStyle(.gray)
SegmentedHorizontalLine(minValue: Int(minValue), maxValue: Int(maxValue))
.frame(height: 4)
.foregroundStyle(fgColor)
.mask(alignment: .leading) {
Color.black
.frame(width: value * sliderWidth / maxValue)
}
thumb
.background {
Circle()
.fill(.black.opacity(0.05))
.padding(isBeingDragged ? 0 : haloWidth)
}
.animation(.easeInOut.delay(isBeingDragged || !hasValue ? 0 : 1), value: isBeingDragged)
.geometryGroup()
.offset(x: xOffset)
.gesture(dragGesture)
star
.rotationEffect(.degrees(dragDirection.rotationDegrees))
.animation(.spring(duration: 1), value: dragDirection)
.geometryGroup()
.offset(y: yOffset)
.animation(.easeOut(duration: 0.5).delay(hasValue ? 0 : 0.2), value: hasValue)
.geometryGroup()
.offset(x: xOffset)
.animation(.easeInOut(duration: 1), value: value)
.allowsHitTesting(false)
}
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.width
} action: { width in
sliderWidth = width
}
.padding(.horizontal, starSize / 2)
}
var dragGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { dragVal in
let newValue = dragValue(xDrag: dragVal.location.x)
let dxSliderEnd = min(newValue - minValue, maxValue - newValue)
let dxEndLocation = abs(dragVal.location.x - dragVal.predictedEndLocation.x)
if dxEndLocation < 25 ||
(dragDirection != .atRest && dxSliderEnd < (maxValue - minValue) / 100) {
dragDirection = .atRest
} else {
dragDirection = newValue < value ? .backwards : .forwards
}
withAnimation(.easeInOut(duration: 0.2)) {
value = newValue
}
}
.onEnded { dragVal in
dragDirection = .atRest
withAnimation(.easeInOut(duration: 0.2)) {
value = dragValue(xDrag: dragVal.location.x)
}
}
}
private func dragValue(xDrag: CGFloat) -> Double {
let fraction = max(0, min(1, xDrag / sliderWidth))
return minValue + (fraction * (maxValue - minValue))
}
}
备注:
滑块的宽度是使用
.onGeometryChange
测量的。虽然从名称中看不出来,但此修改器还会报告首次显示时的初始大小。
枚举用于指示拖动的方向是向前还是向后。这用于确定旋转角度。
当检测到拇指靠近滑块的最小或最大末端,或者当当前位置接近拖动手势的预测末端时,拖动方向将被重置。
通过使用
.geometryGroup()
“密封”动画修改,可以允许动画彼此独立工作。
付诸行动:
struct ContentView: View {
@State private var value = 0.0
var body: some View {
HStack(alignment: .bottom) {
StarSlider(value: $value, minValue: 0, maxValue: 10)
.padding(.top, 30)
.overlay(alignment: .top) {
if value == 0 {
Text("SLIDE TO RATE →")
.font(.caption)
.foregroundStyle(.gray)
}
}
Text("10/10") // placeholder
.hidden()
.overlay(alignment: .trailing) {
Text("\(Int(value.rounded()))/10")
}
.font(.title3)
.fontWeight(.heavy)
.padding(.bottom, 10)
}
.padding(.horizontal)
}
}