UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Adding a custom star rating component

Paul Hudson    @twostraws   

SwiftUI makes it really easy to create custom UI components, because they are effectively just views that have some sort of @Binding exposed for us to read.

To demonstrate this, we’re going to build a star rating view that lets the user enter scores between 1 and 5 by tapping images. Although we could just make this view simple enough to work for our exact use case, it’s often better to add some flexibility where appropriate so it can be used elsewhere too. Here, that means we’re going to make six customizable properties:

  • What label should be placed before the rating (default: an empty string)
  • The maximum integer rating (default: 5)
  • The off and on images, which dictate the images to use when the star is highlighted or not (default: nil for the off image, and a filled star for the on image; if we find nil in the off image we’ll use the on image there too)
  • The off and on colors, which dictate the colors to use when the star is highlighted or not (default: gray for off, yellow for on)

We also need one extra property to store an @Binding integer, so we can report back the user’s selection to whatever is using the star rating.

So, create a new SwiftUI view called “RatingView”, and start by giving it these properties:

@Binding var rating: Int

var label = ""

var maximumRating = 5

var offImage: Image?
var onImage = Image(systemName: "star.fill")

var offColor = Color.gray
var onColor = Color.yellow

Before we fill in the body property, please try building the code – you should find that it fails, because our #Preview code doesn’t pass in a binding to use for rating.

SwiftUI has a specific and simple solution for this called constant bindings. These are bindings that have fixed values, which on the one hand means they can’t be changed in the UI, but also means we can create them trivially – they are perfect for previews.

So, replace the existing preview code with this:

#Preview {
    RatingView(rating: .constant(4))
}

Now let’s turn to the body property. This is going to be a HStack containing any label that was provided, plus as many stars as have been requested – although, of course, they can choose any image they want, so it might not be a star at all.

The logic for choosing which image to show is pretty simple, but it’s perfect for carving off into its own method to reduce the complexity of our code. The logic is this:

  • If the number that was passed in is greater than the current rating, return the off image if it was set, otherwise return the on image.
  • If the number that was passed in is equal to or less than the current rating, return the on image.

We can encapsulate that in a single method, so add this to RatingView now:

func image(for number: Int) -> Image {
    if number > rating {
        roffImage ?? onImage
    } else {
        onImage
    }
}

And now implementing the body property is surprisingly easy: if the label has any text use it, then use ForEach to count from 1 to the maximum rating plus 1 and call image(for:) repeatedly. We’ll also apply a foreground color depending on the rating, and wrap each star inside a button that adjusts the rating.

Replace your existing body property with this:

HStack {
    if label.isEmpty == false {
        Text(label)
    }

    ForEach(1..<maximumRating + 1, id: \.self) { number in
        Button {
            rating = number
        } label: {
            image(for: number)
                .foregroundStyle(number > rating ? offColor : onColor)
        }
    }
}

That completes our rating view already, so to put it into action go back to AddBookView and replace the second section with this:

Section("Write a review") {
    TextEditor(text: $review)
    RatingView(rating: $rating)
}

Our default values are sensible, so it looks great out of the box – go ahead and try it now!

Chances are you'll see things don't quite work right: no matter which star rating you press, it will select 5 stars!

I've seen this problem hit countless hundreds of people, no matter how much experience they have. The problem is that when we have rows inside a form or a list, SwiftUI likes to assume the rows themselves are tappable. This makes selection easier for users, because they can tap anywhere in a row to trigger the button inside it.

In our case we have multiple buttons, so SwiftUI is tapping them all in order – rating gets set to 1, then 2, then 3, 4, and 5, which is why it ends up at 5 no matter what.

We can disable the whole "tap the row to trigger its buttons" behavior with an extra modifier attached to the whole HStack:

.buttonStyle(.plain)

That makes SwiftUI treat each button individually, so everything works as planned. And the result is much nicer to use: there’s no need to tap into a detail view with a picker here, because star ratings are more natural and more common.

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Beyond Code

Was this page useful? Let us know!

Average rating: 4.1/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.