I'm attempting to implement a star rating view using a drag gesture. (eg user can press on star#1, then 'change their mind' and drag to star 3 and release)
The code below will calculate the inferred star rating based on the width of the Stars HStack and the x value of the current drag position.
The odd behavior I'm seeing is when I drag off the leading (left) edge of the stars. sometimes the app gets non-responsive, repeatedly calling onChanged.
I believe the cause of this behavior is the following:
- app detects the user drag has moved to a location to the left of the starts (eg -1, 15)
- app updates rating Int to 0 and redraws the text view containing rating "0"
- this increases the width of the root HStack, which causes the stars hstack to move slightly to the left
- This effectively moves the drag position to just inside the start (eg 1, 15)
- This changes the rating value to 1 which causes the text view value to update to "1"
- This causes the stars hStack to move slightly to the right
- This causes the drag position to update to be slightly to the left of the start (eg -1, 15)
- goto 2
What's not clear to me is why this behavior continues even after the drag is completed.
There are many ways I've found to avoid/prevent this behavior including: fixing the width of the Text containing the rating Int. not letting the rating value be change to 0 when dragging to the left of the stars. But these both feel like hacky work arounds.
Is there a better way to avoid this metastable ping pong behavior?
thanks!
struct ContentView: View {
@State var rating: Int = 0
var body: some View {
HStack {
GeometryReader { proxy in
HStack {
ForEach(0..<5) { index in
Image(systemName: symbolName(for: index))
}
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { gesture in
rating = starValue(for: gesture.location.x, width: proxy.size.width)
}
)
}
.frame(width: 145, height: 30)
Text("\(rating)")
}
}
func starValue(for xPosition: CGFloat, width: CGFloat) -> Int {
guard xPosition > 0 else {
return 0
}
let fraction = xPosition / width
let result = Int(ceil(fraction * 5))
return max(0, min(result, 5))
}
func symbolName(for index: Int) -> String {
if index < rating {
return "star.fill"
}
return "star"
}
}