8

I need to present accurate star rating like 3.1, 4.8 using SwiftUI.

The desired result should be like this:

Desired result

2
  • 1
    For anyone who is looking for half/full accuracy star rating, there is an open source swiftUI control here: github.com/dkk/StarRating Commented Feb 12, 2021 at 16:07
  • Actually, StarRating now also allows exact ratings Commented Feb 22, 2021 at 9:37

5 Answers 5

23

Your general approach is good, but I believe it can be made much simpler.

The below code adapts to whatever size it is placed in (so if you want a specific size, put it in a frame).

Note that the internal ZStack isn't required in iOS 14, but GeometryReader still doesn't document its layout behavior (except in an Xcode 12 release note), so this makes it explicit.

struct StarsView: View {
    var rating: CGFloat
    var maxRating: Int

    var body: some View {
        let stars = HStack(spacing: 0) {
            ForEach(0..<maxRating, id: \.self) { _ in
                Image(systemName: "star.fill")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
            }
        }

        stars.overlay(
            GeometryReader { g in
                let width = rating / CGFloat(maxRating) * g.size.width
                ZStack(alignment: .leading) {
                    Rectangle()
                        .frame(width: width)
                        .foregroundColor(.yellow)
                }
            }
            .mask(stars)
        )
        .foregroundColor(.gray)
    }
}

This draws all the stars in gray, and then creates a yellow rectangle of the correct width, masks it to the stars, and draws that on top as an overlay. Overlays are automatically the same size as the view they're attached to, so you don't need all the frames to make the sizes match they way you do with a ZStack.

Sign up to request clarification or add additional context in comments.

1 Comment

I've added , id: \.self to this code-snippet to silence an Xcode warning, thanks for this solution.
4

After spending some time I did found a solution.

struct StarsView: View {
    let rating: CGFloat
    let maxRating: CGFloat
    
    private let size: CGFloat = 12
    var body: some View {
        let text = HStack(spacing: 0) {
            Image(systemName: "star.fill")
                .resizable()
                .frame(width: size, height: size, alignment: .center)
            Image(systemName: "star.fill")
                .resizable()
                .frame(width: size, height: size, alignment: .center)
            Image(systemName: "star.fill")
                .resizable()
                .frame(width: size, height: size, alignment: .center)
            Image(systemName: "star.fill")
                .resizable()
                .frame(width: size, height: size, alignment: .center)
            Image(systemName: "star.fill")
                .resizable()
                .frame(width: size, height: size, alignment: .center)
        }

        ZStack {
            text
            HStack(content: {
                GeometryReader(content: { geometry in
                    HStack(spacing: 0, content: {
                        let width1 = self.valueForWidth(geometry.size.width, value: rating)
                        let width2 = self.valueForWidth(geometry.size.width, value: (maxRating - rating))
                        Rectangle()
                            .frame(width: width1, height: geometry.size.height, alignment: .center)
                            .foregroundColor(.yellow)
                        
                        Rectangle()
                            .frame(width: width2, height: geometry.size.height, alignment: .center)
                            .foregroundColor(.gray)
                    })
                })
                .frame(width: size * maxRating, height: size, alignment: .trailing)
            })
            .mask(
                text
            )
        }
        .frame(width: size * maxRating, height: size, alignment: .leading)
    }
    
    func valueForWidth(_ width: CGFloat, value: CGFloat) -> CGFloat {
        value * width / maxRating
    }
}

Usage:

StarsView(rating: 2.4, maxRating: 5)

2 Comments

You can replace those 5 Images with a single one in a ForEach(0..<5) { _ in Image()... } loop.
Nice solution! I agree with the previous comment ForEach
2

Another simple solution that enables star fill level through clipping. You can use it with following API:

/// Pass in rating as a Binding or provide a constant
StarRating(rating: .constant(1.6), maxRating: 5)
    .font(.title2) // Provide font for sizing

Which looks like:

StarRating View

Implementation:

struct StarRating: View {
    struct ClipShape: Shape {
        let width: Double
        
        func path(in rect: CGRect) -> Path {
            Path(CGRect(x: rect.minX, y: rect.minY, width: width, height: rect.height))
        }
    }
    
    @Binding var rating: Double
    let maxRating: Int
    
    init(rating: Binding<Double>, maxRating: Int) {
        self.maxRating = maxRating
        self._rating = rating
    }
    
    var body: some View {
        HStack(spacing: 0) {
            ForEach(0..<maxRating, id: \.self) { _ in
                Text(Image(systemName: "star"))
                    .foregroundColor(.blue)
                    .aspectRatio(contentMode: .fill)
            }
        }.overlay(
            GeometryReader { reader in
                HStack(spacing: 0) {
                    ForEach(0..<maxRating, id: \.self) { _ in
                        Image(systemName: "star.fill")
                            .foregroundColor(.blue)
                            .aspectRatio(contentMode: .fit)
                    }
                }
                .clipShape(
                    ClipShape(width: (reader.size.width / CGFloat(maxRating)) * CGFloat(rating))
                )
            }
        )
    }
}

Comments

1

This solution utilizes mask in SwiftUI to fill each star independently, providing precise control over the spacing and fill-level of each star. It offers two functionalities: displaying a static rating by setting a constant for the rating parameter, or acting as an interactive rating tool where users can set the rating by tapping on stars. This is achieved by setting isEditable to true and passing a @State variable to the rating parameter.

Using a font for the star icons enhances accessibility, as it allows the icons to scale according to the device's content settings.

Example Usage

struct ContentView: View {

  @State private var rating: Double = .zero

  var body: some View {
    StarsRatingView(rating: .constant(2.4))

    StarsRatingView(rating: $rating, isEditable: true)
  }
}

Definition of StarsRatingView

struct StarsRatingView: View {

  private let maxRating = 5
  private let spacing: CGFloat = 0

  @Binding var rating: Double
  var isEditable: Bool

  init(
    rating: Binding<Double>,
    isEditable: Bool = false
  ) {
    _rating = rating
    self.isEditable = isEditable
  }

  var body: some View {
    HStack(spacing: 1) {
      ForEach(.zero..<maxRating, id: \.self) { index in
        Image(systemName: "star.fill")
          .font(.body)
          .overlay(
            GeometryReader { proxy in
              Rectangle()
                .foregroundStyle(Color.yellow)
                .frame(
                  width: proxy.size.width * fillStar(at: index),
                  height: proxy.size.height
                )
            }.mask(Image(systemName: "star.fill").font(.body))
          )
          .onTapGesture { if isEditable { rating = Double(index) + 1 }}
      }
      .foregroundStyle(Color.gray)
    }
  }

  private func fillStar(at index: Int) -> CGFloat {
    return rating >= Double(index) + 1
    ? 1
    : (rating > Double(index) ? CGFloat(rating - Double(index)) : .zero)
  }
}

Comments

1

Only one geometryReader is used, and it calculates the colorized start using .tint.

struct StarsStripView: View {
    private let rating: Double
    private let range: ClosedRange<Double>
    
    init(
        rating: Double = .zero,
        range: ClosedRange<Double> = 0.0...5.0
    ) {
        self.rating = rating
        self.range = range
    }
    
    private var maxRating: Int {
        Int(range.upperBound)
    }
    
    private var normalizedRating: Double {
        min(max(rating, range.lowerBound), range.upperBound)
    }
    
    var body: some View {
        HStack(spacing: 4) {
            ForEach(0..<maxRating, id: \.self) { _ in
                Image(systemName: "star.fill")
                    .font(.body)
            }
        }
        .foregroundStyle(.quaternary)
        .overlay(
            HStack(spacing: 4) {
                ForEach(0..<maxRating, id: \.self) { _ in
                    Image(systemName: "star.fill")
                        .font(.body)
                }
            }
            .foregroundStyle(.tint)
            .mask(
                GeometryReader { geometry in
                    Rectangle()
                        .frame(width: (normalizedRating / range.upperBound) * geometry.size.width)
                }
            )
        )
    }
}

#Preview {
    
    StarsStripView(rating: 3.5)
        .tint(.orange)
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.