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