BLACK FRIDAY: Save 50% on all my Swift books and bundles! >>

SOLVED: Star-rating with half-values

Forums > SwiftUI

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.

4      

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

4      

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 { }

4      

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

Rating Control

3      

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!

4      

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?

3      

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.

3      

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?

3      

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.