-6.6 C
New York
Sunday, December 22, 2024

ios – Animating picture with slider in SwiftUI


If a local Slider is used for this animation then it will likely be troublesome to alter the thumb to a customized form and to detect end-of-drag. It’s most likely simpler to create a customized slider as an alternative.

I’d counsel, the one advantage of a local slider is that accessibility comes at no cost. But when accessibility is necessary then a customized slider could be made accessible too. Or you would make it potential for the person to decide on between the customized slider (with its visible results) and a local slider (with out results).

So right here goes with a customized slider. First, it helps to create a few customized shapes:


SegmentedHorizontalLine

This form is used as the size over which the thumb strikes.

struct SegmentedHorizontalLine: Form {
    let minValue: Int
    let maxValue: Int
    let spacing: CGFloat = 1
    let cornerSize = CGSize(width: 1, peak: 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..

Instance use:

SegmentedHorizontalLine(minValue: 0, maxValue: 10)
    .body(peak: 4)
    .foregroundStyle(.grey)
    .padding()

Screenshot


ChunkyStar

SF symbols solely accommodates stars with sharp factors. However a 5-pointed star could be created fairly simply as a customized form:

struct ChunkyStar: Form {
    func path(in rect: CGRect) -> Path {
        let halfSize = min(rect.width, rect.peak) / 2
        let innerSize = halfSize * 0.5
        let angle = 2 * Double.pi / 5
        let midX = rect.midX
        let midY = rect.midY
        var factors = [CGPoint]()
        for i in 0..<5 {
            let xOuter = midX + (halfSize * sin(angle * Double(i)))
            let yOuter = midY - (halfSize * cos(angle * Double(i)))
            factors.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)))
            factors.append(CGPoint(x: xInner, y: yInner))
        }
        return Path { path in
            if let firstPoint = factors.first, let lastPoint = factors.final {
                let startingPoint = CGPoint(
                    x: lastPoint.x + ((firstPoint.x - lastPoint.x) / 2),
                    y: lastPoint.y + ((firstPoint.y - lastPoint.y) / 2)
                )
                factors.append(startingPoint)
                var previousPoint = startingPoint
                for nextPoint in factors {
                    if nextPoint == firstPoint {
                        path.transfer(to: startingPoint)
                    } else {
                        path.addArc(
                            tangent1End: previousPoint,
                            tangent2End: nextPoint,
                            radius: 1
                        )
                    }
                    previousPoint = nextPoint
                }
                path.closeSubpath()
            }
        }
    }
}

Instance use:

ChunkyStar()
    .fill(.yellow)
    .stroke(.orange, lineWidth: 2)
    .body(width: 50, peak: 50)

Screenshot


Now to place all of it collectively.

An enum is used to document the present drag movement. That is used for figuring out the angle of rotation.

enum DragMotion {
    case atRest
    case forwards
    case backwards
    case wasForwards
    case wasBackwards

    var rotationDegrees: Double {
        change self {
        case .forwards: -360 / 10
        case .backwards: 360 / 10
        default: 0
        }
    }

    var isFullMotion: Bool {
        change self {
        case .forwards, .backwards: true
        default: false
        }
    }

    var path: DragMotion {
        change self {
        case .atRest: .atRest
        case .forwards, .wasForwards: .forwards
        case .backwards, .wasBackwards: .backwards
        }
    }
}

The drag movement is reset to a “nearing completion” worth of .wasForwards or .wasBackwards when:

  • it’s detected that the thumb is close to the min or max finish of the slider
  • or, when the present place is close to to the expected end-location for the drag gesture
  • or, after a brief delay has elapsed because the final drag replace.

Resetting the movement worth causes the angle of rotation to be reset. So this will occur earlier than the drag gesture has really been launched and it permits the star to start out “straightening up” earlier. For a brief drag, it additionally stops the star from turning an excessive amount of.

Right here is the primary slider view:

struct StarSlider: View {
    @Binding var worth: Double
    @State personal var sliderWidth = CGFloat.zero
    @State personal var dragMotion = DragMotion.atRest
    let minValue: Double
    let maxValue: Double
    personal let starSize: CGFloat = 40
    personal let thumbSize: CGFloat = 20
    personal let fillColor = Colour(purple: 0.98, inexperienced: 0.57, blue: 0.56)
    personal let fgColor = Colour(purple: 0.99, inexperienced: 0.42, blue: 0.43)

    personal var haloWidth: CGFloat {
        (starSize - thumbSize) / 2
    }

    personal var thumb: some View {
        Circle()
            .fill(fgColor)
            .stroke(.white, lineWidth: 2)
            .body(width: thumbSize, peak: thumbSize)
            .padding(haloWidth)
    }

    personal var star: some View {
        ChunkyStar()
            .fill(fillColor)
            .stroke(fgColor, lineWidth: 2)
            .body(width: starSize, peak: starSize)
    }

    personal var hasValue: Bool {
        worth > minValue
    }

    personal var isBeingDragged: Bool {
        dragMotion.isFullMotion
    }

    personal var place: CGFloat {
        (worth - minValue) * sliderWidth / (maxValue - minValue)
    }

    personal var xOffset: CGFloat {
        place - (starSize / 2)
    }

    personal var yOffset: CGFloat {
        hasValue ? -starSize : 0
    }

    var physique: some View {
        ZStack(alignment: .main) {
            SegmentedHorizontalLine(minValue: Int(minValue), maxValue: Int(maxValue))
                .body(peak: 4)
                .foregroundStyle(.grey)

            SegmentedHorizontalLine(minValue: Int(minValue), maxValue: Int(maxValue))
                .body(peak: 4)
                .foregroundStyle(fgColor)
                .masks(alignment: .main) {
                    Colour.black
                        .body(width: place)
                }

            thumb
                .background {
                    Circle()
                        .fill(.black.opacity(0.05))
                        .padding(isBeingDragged ? 0 : haloWidth)
                }
                .animation(.easeInOut.delay(isBeingDragged || !hasValue ? 0 : 1), worth: isBeingDragged)
                .geometryGroup()
                .offset(x: xOffset)
                .gesture(dragGesture)

            star
                .rotationEffect(.levels(dragMotion.rotationDegrees))
                .animation(.spring(length: 1), worth: dragMotion)
                .geometryGroup()
                .offset(y: yOffset)
                .animation(.spring(length: 1).delay(hasValue ? 0 : 0.2), worth: hasValue)
                .geometryGroup()
                .offset(x: xOffset)
                .animation(.easeInOut(length: 1.5), worth: worth)
                .allowsHitTesting(false)
        }
        .onGeometryChange(for: CGFloat.self) { proxy in
            proxy.dimension.width
        } motion: { width in
            sliderWidth = width
        }
        .padding(.horizontal, starSize / 2)
    }

    personal 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 movement: DragMotion = newValue < worth ? .backwards : .forwards
                if dragMotion == movement {
                    if isNearingDragEnd {
                        dragMotion = movement == .forwards ? .wasForwards : .wasBackwards
                    } else {

                        // Launch a process to reset the drag movement in a short time
                        Process { @MainActor in
                            attempt? await Process.sleep(for: .milliseconds(250))
                            if dragMotion.isFullMotion {
                                dragMotion = movement == .forwards ? .wasForwards : .wasBackwards
                            }
                        }
                    }
                } else if dragMotion.path != movement.path || !isNearingDragEnd {
                    dragMotion = movement
                }
                withAnimation(.easeInOut(length: 0.2)) {
                    worth = newValue
                }

            }
            .onEnded { dragVal in
                dragMotion = .atRest
                withAnimation(.easeInOut(length: 0.2)) {
                    worth = dragValue(xDrag: dragVal.location.x)
                }
            }
    }

    personal func dragValue(xDrag: CGFloat) -> Double {
        let fraction = max(0, min(1, xDrag / sliderWidth))
        return minValue + (fraction * (maxValue - minValue))
    }
}

Extra notes:

  • The width of the slider is measured utilizing .onGeometryChange. Though not obvious from its title, this modifier additionally experiences the preliminary dimension on first present.

  • The animations could be allowed to work independently of one another by “sealing” an animated modification with .geometryGroup().


Placing it into motion:

struct ContentView: View {
    @State personal var worth = 0.0

    var physique: some View {
        HStack(alignment: .backside) {
            StarSlider(worth: $worth, minValue: 0, maxValue: 10)
                .padding(.high, 30)
                .overlay(alignment: .high) {
                    if worth == 0 {
                        Textual content("SLIDE TO RATE →")
                            .font(.caption)
                            .foregroundStyle(.grey)
                    }
                }
            Textual content("10/10") // placeholder
                .hidden()
                .overlay(alignment: .trailing) {
                    Textual content("(Int(worth.rounded()))/10")
                }
                .font(.title3)
                .fontWeight(.heavy)
                .padding(.backside, 10)
        }
        .padding(.horizontal)
    }
}

Animation

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles