SwiftUI Rating Component

Star rating you can tap or drag across. Dragging is the part most implementations skip, and it is how people actually set a rating once they realise it works.

Drag across the stars

Install the free SwiftUI rating

  1. Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
  2. Copy SKRating.swift below into your project. It needs nothing else.

Usage

Usage.swift
SKRating(value: $stars)                       // editable
SKRating(value: .constant(4), isEditable: false)

Full source · 121 lines · MIT

SKRating.swift
//
//  SKRating.swift
//  Swift Kit UI
//
//  Requires: SwiftKitUI.swift
//
//  Star rating you can tap or drag across. Dragging is the part most
//  implementations skip, and it is how people actually set a rating once they
//  realise it works.
//
//  Usage:
//      SKRating(value: $stars)                       // editable
//      SKRating(value: .constant(4), isEditable: false)
//

import SwiftUI

public struct SKRating: View {
    @Binding private var value: Int
    private let count: Int
    private let isEditable: Bool
    private let size: CGFloat
    private let tint: Color

    @State private var dragValue: Int?
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    public init(
        value: Binding<Int>,
        count: Int = 5,
        isEditable: Bool = true,
        size: CGFloat = SK.Icon.lg,
        tint: Color = SK.Palette.warning
    ) {
        self._value = value
        self.count = count
        self.isEditable = isEditable
        self.size = size
        self.tint = tint
    }

    private var shown: Int { dragValue ?? value }

    public var body: some View {
        HStack(spacing: SK.Space.xs) {
            ForEach(1...count, id: \.self) { index in
                star(index)
            }
        }
        .contentShape(.rect)
        .gesture(isEditable ? drag : nil)
        .accessibilityElement()
        .accessibilityValue(Text("\(value) of \(count)"))
        .accessibilityAdjustableAction { direction in
            guard isEditable else { return }
            switch direction {
            case .increment: if value < count { value += 1 }
            case .decrement: if value > 0 { value -= 1 }
            @unknown default: break
            }
        }
    }

    private func star(_ index: Int) -> some View {
        let filled = index <= shown
        return Image(systemName: filled ? "star.fill" : "star")
            .font(SK.Icon.symbol(size, weight: .medium))
            .foregroundStyle(filled ? tint : SK.Palette.separator)
            // Only the star being set pops; popping all of them at once reads
            // as a glitch rather than feedback.
            .scaleEffect(filled && index == shown ? 1.18 : 1)
            .frame(width: size * 1.6, height: SK.Icon.hitTarget)
            .skAnimation(.pop, value: shown)
            .onTapGesture {
                guard isEditable else { return }
                set(index)
            }
    }

    private var drag: some Gesture {
        DragGesture(minimumDistance: 0)
            .onChanged { g in
                let per = size * 1.6 + SK.Space.xs
                let index = Int(g.location.x / per) + 1
                let clamped = min(max(index, 1), count)
                if clamped != dragValue {
                    dragValue = clamped
                    SK.Haptic.play(.light)
                }
            }
            .onEnded { _ in
                if let dragValue { value = dragValue }
                dragValue = nil
            }
    }

    private func set(_ index: Int) {
        withAnimation(SKMotion.pop.resolved(reduceMotion: reduceMotion)) {
            // Tapping the only filled star clears the rating, which is the
            // conventional way to undo an accidental one-star.
            value = (value == index && index == 1) ? 0 : index
        }
        SK.Haptic.play(.light)
    }
}

#Preview("SKRating") {
    @Previewable @State var stars = 3

    VStack(spacing: SK.Space.xl) {
        SKRating(value: $stars)
        SKRating(value: $stars, size: SK.Icon.xl, tint: SK.Palette.accent)
        SKRating(value: .constant(4), isEditable: false, size: SK.Icon.sm)
        Text("Drag across the stars").font(SK.Type_.caption)
            .foregroundStyle(SK.Palette.labelTertiary)
    }
    .padding(SK.Space.xl)
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .background(SK.Palette.surfaceSunken)
}

Questions

Is this SwiftUI rating free for commercial apps?
Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
What does it depend on?
SKRating.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
Which iOS versions does it support?
iOS 17 and later, plus macOS 14.

Related components

Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.