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