Animating image with slider in SwiftUI

I’m trying to re-create the following slider animation with the default slider.

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

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


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)


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:

  • it is detected that the thumb is near the min or max end of the slider
  • or, when the current position is near to the predicted end-location for the drag gesture
  • or, after a short delay has elapsed since the last drag update.

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
                if dragMotion != .atRest || dragVal.translation.width != 0 {
                    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
                if dragMotion != .atRest {
                    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:

  • The width of the slider is measured using .onGeometryChange. Although not apparent from its name, this modifier also reports the initial size on first show.

  • The animations can be allowed to work independently of each other by “sealing” an animated modification with .geometryGroup().


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

4

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật