iosswiftanimationswiftui

Animating image with slider in SwiftUI


I'm trying to re-create the following slider animation with the default slider. enter image description here

The animation in the original rotates the star & positions it with a sort of rotate & slide. I've somewhat achieved the effect but it doesn't seem smooth as in the original, video here. Also my star positioning is a bit off as the slider is moved towards the end of the range. How can I achieve the same effect as in the target animation? Here's my code:

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

Solution

  • If a native Slider is used for this animation then it will be difficult to change the thumb to a custom shape and to detect end-of-drag. It is probably easier to create a custom slider instead.

    I would suggest, the only benefit of a native slider is that accessibility comes for free. But if accessibility is important then a custom slider can be made accessible too. Or you could make it possible for the user to choose between the custom slider (with its visual effects) and a native slider (without effects).

    So here goes with a custom slider. First, it helps to create a couple of custom shapes:


    SegmentedHorizontalLine

    This shape is used as the scale over which the thumb moves.

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

    Example use:

    SegmentedHorizontalLine(minValue: 0, maxValue: 10)
        .frame(height: 4)
        .foregroundStyle(.gray)
        .padding()
    

    Screenshot


    ChunkyStar

    SF symbols only contains stars with sharp points. But a 5-pointed star can be created quite easily as a custom shape:

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

    Example use:

    ChunkyStar()
        .fill(.yellow)
        .stroke(.orange, lineWidth: 2)
        .frame(width: 50, height: 50)
    

    Screenshot


    Now to put it all together.

    An enum is used to record the current drag motion. This is used for determining the angle of rotation.

    enum DragMotion {
        case atRest
        case forwards
        case backwards
        case wasForwards
        case wasBackwards
    
        var rotationDegrees: Double {
            switch self {
            case .forwards: -360 / 10
            case .backwards: 360 / 10
            default: 0
            }
        }
    
        var isFullMotion: Bool {
            switch self {
            case .forwards, .backwards: true
            default: false
            }
        }
    
        var direction: DragMotion {
            switch self {
            case .atRest: .atRest
            case .forwards, .wasForwards: .forwards
            case .backwards, .wasBackwards: .backwards
            }
        }
    }
    

    The drag motion is reset to a "nearing completion" value of .wasForwards or .wasBackwards when:

    Resetting the motion value causes the angle of rotation to be reset. So this can happen before the drag gesture has actually been released and it allows the star to start "straightening up" earlier. For a short drag, it also stops the star from turning too much.

    Here is the main slider view:

    struct StarSlider: View {
        @Binding var value: Double
        @State private var sliderWidth = CGFloat.zero
        @State private var dragMotion = DragMotion.atRest
        let minValue: Double
        let maxValue: Double
        private let starSize: CGFloat = 40
        private let thumbSize: CGFloat = 20
        private let fillColor = Color(red: 0.98, green: 0.57, blue: 0.56)
        private let fgColor = 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 > minValue
        }
    
        private var isBeingDragged: Bool {
            dragMotion.isFullMotion
        }
    
        private var position: CGFloat {
            (value - minValue) * sliderWidth / (maxValue - minValue)
        }
    
        private var xOffset: CGFloat {
            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: position)
                    }
    
                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(dragMotion.rotationDegrees))
                    .animation(.spring(duration: 1), value: dragMotion)
                    .geometryGroup()
                    .offset(y: yOffset)
                    .animation(.spring(duration: 1).delay(hasValue ? 0 : 0.2), value: hasValue)
                    .geometryGroup()
                    .offset(x: xOffset)
                    .animation(.easeInOut(duration: 1.5), value: value)
                    .allowsHitTesting(false)
            }
            .onGeometryChange(for: CGFloat.self) { proxy in
                proxy.size.width
            } action: { width in
                sliderWidth = width
            }
            .padding(.horizontal, starSize / 2)
        }
    
        private 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 predictedX = max(0, min(dragVal.predictedEndLocation.x, sliderWidth))
                    let dxEndLocation = abs(predictedX - dragVal.location.x)
                    let isNearingDragEnd = dxEndLocation < 20 || dxSliderEnd < (maxValue - minValue) / 100
                    let motion: DragMotion = newValue < value ? .backwards : .forwards
                    if dragMotion == motion {
                        if isNearingDragEnd {
                            dragMotion = motion == .forwards ? .wasForwards : .wasBackwards
                        } else {
    
                            // Launch a task to reset the drag motion in a short while
                            Task { @MainActor in
                                try? await Task.sleep(for: .milliseconds(250))
                                if dragMotion.isFullMotion {
                                    dragMotion = motion == .forwards ? .wasForwards : .wasBackwards
                                }
                            }
                        }
                    } else if dragMotion.direction != motion.direction || !isNearingDragEnd {
                        dragMotion = motion
                    }
                    withAnimation(.easeInOut(duration: 0.2)) {
                        value = newValue
                    }
    
                }
                .onEnded { dragVal in
                    dragMotion = .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))
        }
    }
    

    Additional notes:


    Putting it into action:

    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