I need to present accurate star rating like 3.1, 4.8 using SwiftUI.
The desired result should be like this:
I need to present accurate star rating like 3.1, 4.8 using SwiftUI.
The desired result should be like this:
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.
, id: \.self to this code-snippet to silence an Xcode warning, thanks for this solution.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)
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:
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))
)
}
)
}
}
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)
}
}
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)
}