|
I'm trying to adapt the star-rating tutorial to allow for half-stars, and I've got something that seems to work, but it seems like surely there must be a better way than a different view for all 11 values (0.0 - 5.0)?
struct RatingView: View {
var book: Audiobook
@Binding var rating: Double
var body: some View {
if self.rating == 0.0 {
zeroStars
} else if self.rating == 0.5 {
zeroPointFiveStars
} else if self.rating == 1.0 {
oneStar
} else if self.rating == 1.5 {
onePointFiveStars
} else if self.rating == 2.0 {
twoStars
} else if self.rating == 2.5 {
twoPointFiveStars
} else if self.rating == 3.0 {
threeStars
} else if self.rating == 3.5 {
threePointFiveStars
} else if self.rating == 4.0 {
fourStars
} else if self.rating == 4.5 {
fourPointFiveStars
} else if self.rating == 5.0 {
fiveStars
}
}
private var zeroStars: some View {
HStack {
Image(systemName: "star")
.onTapGesture(count: 1, perform: {
self.rating = 0.5
book.rating = self.rating
})
Image(systemName: "star")
.onTapGesture(count: 1, perform: {
self.rating = 1.5
book.rating = self.rating
})
Image(systemName: "star")
.onTapGesture(count: 1, perform: {
self.rating = 2.5
book.rating = self.rating
})
Image(systemName: "star")
.onTapGesture(count: 1, perform: {
self.rating = 3.5
book.rating = self.rating
})
Image(systemName: "star")
.onTapGesture(count: 1, perform: {
self.rating = 4.5
book.rating = self.rating
})
}
.foregroundColor(.gray)
}
// (repeat ten more times)
But if there is, I haven't found it. I can get the stars right this way:
HStack {
if rating.isInt {
ForEach(0 ..< Int(rating)) { star in
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
ForEach(Int(rating) ..< 5) { star in
Image(systemName: "star")
.foregroundColor(.gray)
}
} else if !rating.isZero {
let wholeStars: Int = Int(rating.rounded(.down))
ForEach(0 ..< wholeStars) { star in
Image(systemName: "star.fill")
.foregroundColor(.yellow)
}
Image(systemName: "star.leadinghalf.fill")
.foregroundColor(.yellow)
ForEach(wholeStars + 1 ..< 5 ) { star in
Image(systemName: "star")
.foregroundColor(.gray)
}
} else {
ForEach(0 ..< 5) { star in
Image(systemName: "star")
.foregroundColor(.gray)
}
}
}
But that doesn't allow setting the rating by tapping on a star or dragging across the stars.
|
|
Hey, an option to set by tapping (if you tap a star once it sets it full, if you tap it a second time it sets half of it, for the first star, tapping it a third time sets rating to 0):
struct RatingView: View {
init(_ rating: Binding<Double>, maxRating: Int = 5) {
_rating = rating
self.maxRating = maxRating
}
let maxRating: Int
@Binding var rating: Double
@State private var starSize: CGSize = .zero
var body: some View {
ZStack {
HStack {
ForEach(0..<Int(rating), id: \.self) { _ in
fullStar
}
if (rating != floor(rating)) {
halfStar
}
ForEach(0..<Int(Double(maxRating) - rating), id: \.self) { _ in
emptyStar
}
}
.onPreferenceChange(StarSizeKey.self) { size in
starSize = size
}
HStack {
ForEach(0..<maxRating, id: \.self) { idx in
Color.clear
.frame(width: starSize.width, height: starSize.height)
.contentShape(Rectangle())
.onTapGesture {
if idx == 0 {
if rating == 1 {
rating = 0.5
} else if rating == 0.5 {
rating = 0
} else {
rating = 1
}
} else {
let i = Double(idx) + 1
rating = (rating == i) ? i - 0.5 : i
}
}
}
}
}
}
var fullStar: some View {
Image(systemName: "star.fill")
.star(size: starSize)
}
var halfStar: some View {
Image(systemName: "star.leadinghalf.fill")
.star(size: starSize)
}
var emptyStar: some View {
Image(systemName: "star")
.star(size: starSize)
}
}
fileprivate extension Image {
func star(size: CGSize) -> some View {
return self
.font(.title)
.background(
GeometryReader { proxy in
Color.clear.preference(key: StarSizeKey.self, value: proxy.size)
}
)
.frame(width: size.width, height: size.height)
}
}
struct StarSizeKey: PreferenceKey {
static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
let next = nextValue()
value = CGSize(width: max(value.width, next.width), height: max(value.height, next.height))
}
}
|
|
And here's a version with drag controls:
struct RatingView: View {
init(_ rating: Binding<Double>, maxRating: Int = 5) {
_rating = rating
self.maxRating = maxRating
}
let maxRating: Int
@Binding var rating: Double
@State private var starSize: CGSize = .zero
@State private var controlSize: CGSize = .zero
@GestureState private var dragging: Bool = false
var body: some View {
ZStack {
HStack {
ForEach(0..<Int(rating), id: \.self) { idx in
fullStar
}
if (rating != floor(rating)) {
halfStar
}
ForEach(0..<Int(Double(maxRating) - rating), id: \.self) { idx in
emptyStar
}
}
.background(
GeometryReader { proxy in
Color.clear.preference(key: ControlSizeKey.self, value: proxy.size)
}
)
.onPreferenceChange(StarSizeKey.self) { size in
starSize = size
}
.onPreferenceChange(ControlSizeKey.self) { size in
controlSize = size
}
Color.clear
.frame(width: controlSize.width, height: controlSize.height)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { value in
rating = rating(at: value.location)
}
)
}
}
private var fullStar: some View {
Image(systemName: "star.fill")
.star(size: starSize)
}
private var halfStar: some View {
Image(systemName: "star.leadinghalf.fill")
.star(size: starSize)
}
private var emptyStar: some View {
Image(systemName: "star")
.star(size: starSize)
}
private func rating(at position: CGPoint) -> Double {
let singleStarWidth = starSize.width
let totalPaddingWidth = controlSize.width - CGFloat(maxRating)*singleStarWidth
let singlePaddingWidth = totalPaddingWidth / (CGFloat(maxRating) - 1)
let starWithSpaceWidth = Double(singleStarWidth + singlePaddingWidth)
let x = Double(position.x)
let starIdx = Int(x / starWithSpaceWidth)
let starPercent = x.truncatingRemainder(dividingBy: starWithSpaceWidth) / Double(singleStarWidth) * 100
let rating: Double
if starPercent < 25 {
rating = Double(starIdx)
} else if starPercent <= 75 {
rating = Double(starIdx) + 0.5
} else {
rating = Double(starIdx) + 1
}
return min(Double(maxRating), max(0, rating))
}
}
fileprivate extension Image {
func star(size: CGSize) -> some View {
return self
.font(.title)
.background(
GeometryReader { proxy in
Color.clear.preference(key: StarSizeKey.self, value: proxy.size)
}
)
.frame(width: size.width, height: size.height)
}
}
fileprivate protocol SizeKey: PreferenceKey { }
fileprivate extension SizeKey {
static var defaultValue: CGSize { .zero }
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
let next = nextValue()
value = CGSize(width: max(value.width, next.width), height: max(value.height, next.height))
}
}
fileprivate struct StarSizeKey: SizeKey { }
fileprivate struct ControlSizeKey: SizeKey { }
|
|
Note that both of the solutions above are fully dynamic and should work with any images for your rating controls, as well as any maximum rating value. Since we're using SF Symbols for the stars, you can always change the colour of the control by appending the foregroundColor(_:) modifier to your RatingView instance, and the font by using the font(_:) modifier. Below an example of a yellow, bold rating control up to 10 stars:
struct ContentView: View {
@State private var rating = 5.5
var body: some View {
RatingView($rating, maxRating: 10)
.font(.system(size: 32, weight: .bold))
.foregroundColor(.yellow)
.padding()
}
}
|
|
Thank you, that is perfect! It's clear I just wasn't diving deep enough with what I was trying to do, which is typical of my learning of SwiftUI to this point.
Thank you again!
|
|
Quick follow-up question:
I'm using the dragGesture version, and it looked great.
But I changed the parent view to a FlipView , because I wanted my book details to be on the "back" of a card, like reading the back cover of a book.
Doing this changed...something with the drag view, so that now instead of the stars changing color as you drag across them, it looks like the the yellow stars are "pushing" the gray stars to the right (or vice versa). I've tried playing with these two settings from the flip view: .animation(.spring(response: 0.35, dampingFraction: 0.7)) but I haven't found it makes much of a difference.
Does anyone know what I can change to get the drag effect back?
|
|
I'm not HWS+ so I don't have the code for the flipview, but from what you say it seems like you have some .animation modifier applied to your FlipView that's falling through to the stars view. That thing's weird. I've played around with it for a while and I'm never able to figure out where it gets applied and how to cancel it, so I'd suggest you get rid of your .animation modifier, and just update your state variables using withAnimation wherever you want it to animate.
|
|
Thank you!
I wonder if I can offset the outer (FlipView ) animation modifier with one for RatingView ? Since an inner modifier is supposed to override an outer one, right?
|