在 SwiftUI 中使用滑块动画图像

问题描述 投票:0回答:1

我正在尝试使用默认滑块重新创建以下滑块动画。 enter image description here

原作中的动画旋转星星并通过某种旋转和滑动来定位它。我已经达到了一定的效果,但看起来并不像原来那样流畅,这里的视频。另外,当滑块移向范围末端时,我的星形定位有点偏离。如何才能达到与目标动画相同的效果?这是我的代码:

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
    }
}
ios swift swiftui
1个回答
0
投票

如果此动画使用原生

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()

Screenshot


粗星

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)

Screenshot


现在把它们放在一起。一些注释如下:

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)
    }
}

Animation

© www.soinside.com 2019 - 2024. All rights reserved.