iosNudge

Star Rating

EasySwiftUISwiftUIAccessibility 30 min read

Build a reusable star rating control in SwiftUI. It has two modes:

  • Editable: the user taps a star to give a rating from 1 to 5.
  • Read-only: it shows a value, like an average rating of 3.5.
struct StarRating: View {
    init(rating: Binding<Double>, maxRating: Int = 5)  // editable
    init(rating: Double, maxRating: Int = 5)           // read-only
}

Examples

@State private var myRating: Double = 0

StarRating(rating: $myRating)  // tap the 4th star -> myRating == 4
StarRating(rating: 3.5)        // 3 full stars, 1 half star, 1 empty star
StarRating(rating: 4.2)        // 4 full stars, 1 empty star (4.2 rounds to 4.0)
StarRating(rating: 3.3)        // 3 full stars, 1 half star (3.3 rounds to 3.5)

Requirements

  • Draw maxRating stars in a row. The default is 5.
  • Show full, half and empty stars. Round the value to the nearest 0.5 first.
  • Tapping star number n sets the rating to n.
  • Read-only mode does not react to taps.
  • Each star has a tap area of at least 44 × 44 points.
  • The stars grow with Dynamic Type (the user's text size setting).
  • VoiceOver reads the whole control as one element: "Rating, 3.5 out of 5 stars".
  • In editable mode, VoiceOver users swipe up or down to change the rating. This is an adjustable action.

What a good answer handles

  • Bad values. -1 or 7 must not crash. Taps only set values from 1 to maxRating.
  • No rating yet. 0 means "not rated". VoiceOver says "Not rated".
  • Right-to-left languages. In Arabic or Hebrew the half star fills from the right side.
  • Feedback. A small haptic when the rating changes is a nice extra.

Notes

  • Target iOS 17 or later. Use SF Symbols: star, star.fill, star.leadinghalf.filled.
  • starter.swift has the API and a preview. Fill in the body and the helper.

Starter code

Copy this into a new Xcode project (or Swift Playgrounds) and build from here.

import SwiftUI

/// A row of stars.
/// Editable: the user taps a star to rate (1...maxRating).
/// Read-only: shows a value like an average, with half stars.
struct StarRating: View {
    @Binding private var rating: Double
    private let maxRating: Int
    private let isEditable: Bool

    /// Editable control. Tapping star `n` sets `rating` to `n`.
    init(rating: Binding<Double>, maxRating: Int = 5) {
        _rating = rating
        self.maxRating = max(maxRating, 1)
        self.isEditable = true
    }

    /// Read-only display, for example an average rating of 3.5.
    init(rating: Double, maxRating: Int = 5) {
        _rating = .constant(rating)
        self.maxRating = max(maxRating, 1)
        self.isEditable = false
    }

    var body: some View {
        // TODO:
        // 1. Draw `maxRating` stars: "star.fill", "star.leadinghalf.filled" or "star".
        // 2. In editable mode, tapping star n sets `rating` to n.
        // 3. Each star needs a 44 x 44 pt tap area.
        // 4. VoiceOver: one element with a label, a value and an adjustable action.
        Text("Rating: \(rating)")
    }

    /// Which SF Symbol to use for star number `star` (1-based).
    private func symbolName(for star: Int) -> String {
        // TODO: round `rating` to the nearest 0.5, then compare with `star`.
        "star"
    }
}

// MARK: - Demo

struct StarRatingDemo: View {
    @State private var myRating: Double = 0

    var body: some View {
        VStack(alignment: .leading, spacing: 32) {
            VStack(alignment: .leading, spacing: 8) {
                Text("Average rating").font(.headline)
                StarRating(rating: 3.5)
                StarRating(rating: 4.2)
            }

            VStack(alignment: .leading, spacing: 8) {
                Text("Your rating").font(.headline)
                StarRating(rating: $myRating)
            }
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

#Preview {
    StarRatingDemo()
}

Solution

Try to build it first. Then compare your design with ours.