Create a star rating SwiftUI component

Create an interactive star rating SwiftUI component. This article will use the star cutout shape with rounded corners as well as elements of the custom slider to create a star rating component in SwiftUI.



Row of Star shapes

Here is the Star shape that was created in Create a Star cutout shape in SwiftUI and Star with rounded corners in SwiftUI.

 1struct StarShape: Shape {
 2    var points = 5
 3    var cornerRadius = 3.0
 4    var isCutout = false
 5    var isCircleOutline = false
 6    
 7    func path(in rect: CGRect) -> Path {
 8        // centre of the containing rect
 9        var center = CGPoint(x: rect.width/2.0, y: rect.height/2.0)
10        // Adjust center down for odd number of sides less than 8
11        if points%2 == 1 && points < 8 && !isCircleOutline {
12            center = CGPoint(x: center.x, y: center.y * ((Double(points) * (-0.04)) + 1.3))
13        }
14        
15        // radius of a circle that will fit in the rect with some padding
16        let outerRadius = (Double(min(rect.width,rect.height)) / 2.0) * 0.9
17        let innerRadius = outerRadius * 0.4
18        let offsetAngle = Double.pi * (-0.5)
19        
20        var starSegments:[Segment] = []
21        for i in 0..<(points){
22            let angle1 = (2.0 * Double.pi/Double(points)) * Double(i)  + offsetAngle
23            let outerPoint = Cartesian(length: outerRadius, angle: angle1)
24            let angle2 = (2.0 * Double.pi/Double(points)) * (Double(i) + 0.5)  + offsetAngle
25            let innerPoint = Cartesian(length: (innerRadius), angle: (angle2))
26            
27            let segment = Segment(
28                outerCenter: CGPoint(x: outerPoint.x + center.x,
29                                     y: outerPoint.y + center.y),
30                outerAngle: angle1,
31                outerRadius: cornerRadius,
32                innerCenter: CGPoint(x: innerPoint.x + center.x,
33                                     y: innerPoint.y + center.y),
34                innerAngle: angle2)
35            starSegments.append(segment)
36        }
37        
38        let path = Path() { path in
39            if isCutout {
40                if isCircleOutline {
41                    path.addPath(Circle().path(in: rect))
42                    
43                } else {
44                    path.addPath(Rectangle().path(in: rect))
45                }
46            }
47            for (n, seg) in starSegments.enumerated() {
48                n == 0 ? path.move(to: seg.line) : path.addLine(to: seg.line)
49                path.addArc(center: seg.outerCenter,
50                            radius: seg.outerRadius,
51                            startAngle: Angle(radians: seg.outerStartAngle),
52                            endAngle: Angle(radians: seg.outerEndAngle),
53                            clockwise: false)
54                path.addLine(to: seg.line2)
55                path.addArc(center: seg.innerCenter,
56                            radius: seg.outerRadius,
57                            startAngle: Angle(radians: seg.innerStartAngle),
58                            endAngle: Angle(radians: seg.innerEndAngle),
59                            clockwise: true)
60            }
61            path.closeSubpath()
62        }
63        return path
64    }
65}

These star shapes can be places in a HStack as cutout stars with a clear background.

1    HStack(spacing:0) {
2        ForEach(1...5, id:\.self) { _ in
3            StarShape(points: 5, cornerRadius: 1, isCutout: true)
4                .fill(Color.gray, style: FillStyle(eoFill: true, antialiased: true))
5                .frame(width: 50, height: 50, alignment: .center)
6        }
7    }

Five stars in a HStack with sharp corners and rounded corners
Five stars in a HStack with sharp corners and rounded corners



Set color for star rating

Embed the row of Star cutout shapes into a separate view StarRatingView. This view takes the value of the star rating as well as an optional value of the total number of starts. The default for the number of stars is five, but different numbers could be used, such as ten to display a component with a rating out of ten stars. A background is set in a ZStack behind the stars composed of two rectangles in a HStack. The color of the first Rectangle is set to yellow and its width is set to the proportion of the value of the star rating. The remaining space is filled by the second Rectangle with a transparent fill color. In this way, the stars are filled in proportional to the star rating.

 1struct StarRatingView: View {
 2    var value: Double
 3    var stars: Int = 5
 4    
 5    init(value: Double) {
 6        self.init(value: value, stars: 5)
 7    }
 8    
 9    init(value: Double, stars: Int) {
10        self.value = min(value, Double(stars))
11        self.stars = stars
12    }
13    
14    var body: some View {
15        GeometryReader { gr in
16            let ratingWidth = gr.size.width * value / Double(stars)
17            let starWidth = gr.size.width / Double(stars)
18            let radius = starWidth * 0.01
19            ZStack {
20                HStack(spacing:0) {
21                    Rectangle()
22                        .fill(.yellow)
23                        .frame(width: ratingWidth)
24                    Rectangle()
25                        .fill(.clear)
26                }
27                
28                HStack(spacing:0) {
29                    ForEach(1...stars, id:\.self) { _ in
30                        StarShape(points: 5, cornerRadius: radius, isCutout: true)
31                            .fill(Color.gray, style: FillStyle(eoFill: true, antialiased: true))
32                            .frame(width: starWidth, height: gr.size.height, alignment: .center)
33                    }
34                }
35            }
36        }
37    }
38}

Here are examples of using the star rating with different number of stars as well as laying them out in different sizes.

 1struct ContentView: View {
 2    @State private var currentValue = 4.0
 3    
 4    var body: some View {
 5        ZStack {
 6            Color(red: 214/255, green: 232/255, blue: 248/255)
 7                .edgesIgnoringSafeArea(.all)
 8            
 9            VStack(spacing:40) {
10                VStack(spacing:0) {
11                    let rating = 2.5
12                    Text("Star Rating = \(rating, specifier: "%.1F") out of 5")
13                    VStack {
14                        StarRatingView(value: rating, stars: 5)
15                            .frame(width: 250, height: 50, alignment: .center)
16                    }
17                }
18
19                VStack(spacing:0) {
20                    let rating = 6.3
21                    Text("Star Rating = \(rating, specifier: "%.1F") out of 10")
22                    VStack {
23                        StarRatingView(value: rating, stars: 10)
24                            .frame(width: 250, height: 30, alignment: .center)
25                    }
26                }
27
28                VStack(spacing:0) {
29                    let rating = 2.4
30                    Text("Star Rating = \(rating, specifier: "%.1F") out of 3")
31                    VStack {
32                        StarRatingView(value: rating, stars: 3)
33                            .frame(width: 250, height: 100, alignment: .center)
34                    }
35                }
36
37                Spacer()
38            }
39        }
40    }
41}

Set star rating with yellow background proportional to rating
Set star rating with yellow background proportional to rating



Make the star rating interactive

The Star Rating SwiftUI view can be made interactive with the use of a DragGesture, similar to the sliders in How to customise the Slider in SwiftUI. The DragGesture is added to the HStack of stars so that any drag along the stars will update the star rating.

 1struct StarRatingView: View {
 2    @Binding var value: Double
 3    var stars: Int = 5
 4    
 5    @State var lastCoordinateValue: CGFloat = 0.0
 6    
 7    var body: some View {
 8        GeometryReader { gr in
 9            let ratingWidth = gr.size.width * value / Double(stars)
10            let starWidth = gr.size.width / Double(stars)
11            let radius = starWidth * 0.01
12            
13            let maxValue = gr.size.width
14            let scaleFactor = maxValue / Double(stars)
15            let sliderVal = self.value * scaleFactor
16            
17            ZStack {
18                HStack(spacing:0) {
19                    Rectangle()
20                        .fill(.yellow)
21                        .frame(width: ratingWidth)
22                    Rectangle()
23                        .fill(.clear)
24                }
25                
26                HStack(spacing:0) {
27                    ForEach(1...stars, id:\.self) { _ in
28                        StarShape(points: 5, cornerRadius: radius, isCutout: true)
29                            .fill(Color.gray, style: FillStyle(eoFill: true, antialiased: true))
30                            .frame(width: starWidth, height: gr.size.height, alignment: .center)
31                    }
32                }
33                .gesture(
34                    DragGesture(minimumDistance: 0)
35                        .onChanged { v in
36                            if (abs(v.translation.width) < 0.1) {
37                                self.lastCoordinateValue = sliderVal
38                            }
39                            if v.translation.width > 0 {
40                                let nextCoordinateValue = min(maxValue, self.lastCoordinateValue + v.translation.width)
41                                self.value = (nextCoordinateValue / scaleFactor)
42                            } else {
43                                let nextCoordinateValue = max(0.0, self.lastCoordinateValue + v.translation.width)
44                                self.value = (nextCoordinateValue / scaleFactor)
45                            }
46                        }
47                )
48            }
49        }
50    }
51}

The ContentView is similar to before, except a new rating is added with a circle showing the chosen star rating.

 1struct ContentView: View {
 2    @State private var rating1 = 2.5
 3    @State private var rating2 = 6.3
 4    @State private var rating3 = 2.4
 5    @State private var rating4 = 2.4
 6    
 7    var body: some View {
 8        ZStack {
 9            Color(red: 214/255, green: 232/255, blue: 248/255)
10                .edgesIgnoringSafeArea(.all)
11            
12            VStack(spacing:40) {
13                VStack(spacing:0) {
14                    Text("Star Rating = \(rating1, specifier: "%.1F") out of 5")
15                    StarRatingView(value: $rating1, stars: 5)
16                        .frame(width: 250, height: 50, alignment: .center)
17                }
18                
19                VStack(spacing:0) {
20                    Text("Star Rating = \(rating2, specifier: "%.1F") out of 10")
21                    StarRatingView(value: $rating2, stars: 10)
22                        .frame(width: 250, height: 30, alignment: .center)
23                }
24                
25                VStack(spacing:0) {
26                    Text("Star Rating = \(rating3, specifier: "%.1F") out of 3")
27                    StarRatingView(value: $rating3, stars: 3)
28                        .frame(width: 250, height: 100, alignment: .center)
29                }
30                
31                HStack(spacing:10) {
32                    StarRatingView(value: $rating4, stars: 5)
33                        .frame(width: 200, height: 40, alignment: .center)
34                    Circle()
35                        .fill(.gray)
36                        .frame(width: 40, height: 40, alignment: .center)
37                        .overlay(
38                            Text("\(rating4, specifier: "%.1F")")
39                                .foregroundColor(.white)
40                                .fontWeight(.bold)
41                        )
42                }
43                
44                Spacer()
45            }
46        }
47    }
48}

Set star rating with yellow color changing with Drag Gesture
Set star rating with yellow color changing with Drag Gesture



Set the star rating on initial touch point

The star rating is adjusted with the DragGesture, but it starts from it's current position and slides as the finger is dragged across the screen. This can create a disconnect between the user and the star rating component. The expectation is that the current star rating would match the touch-point of the finger. This is a throw-back to treating the DragGesture on the stars as a slider. The fix is to set the lastCoordinateValue to the x coordinate of the current location when the DragGesture changes.

 1struct StarRatingView: View {
 2    @Binding var value: Double
 3    var stars: Int = 5
 4    
 5    @State var lastCoordinateValue: CGFloat = 0.0
 6    
 7    var body: some View {
 8        GeometryReader { gr in
 9            let ratingWidth = gr.size.width * value / Double(stars)
10            let starWidth = gr.size.width / Double(stars)
11            let radius = starWidth * 0.01
12            
13            let maxValue = gr.size.width
14            let scaleFactor = maxValue / Double(stars)
15            
16            ZStack {
17                HStack(spacing:0) {
18                    Rectangle()
19                        .fill(.yellow)
20                        .frame(width: ratingWidth)
21                    Rectangle()
22                        .fill(.clear)
23                }
24                
25                HStack(spacing:0) {
26                    ForEach(1...stars, id:\.self) { _ in
27                        StarShape(points: 5, cornerRadius: radius, isCutout: true)
28                            .fill(Color.gray, style: FillStyle(eoFill: true, antialiased: true))
29                            .frame(width: starWidth, height: gr.size.height, alignment: .center)
30                    }
31                }
32                .gesture(
33                    DragGesture(minimumDistance: 0)
34                        .onChanged { v in
35                            if (abs(v.translation.width) < 0.1) {
36                                self.lastCoordinateValue = v.location.x
37                            }
38                            if v.translation.width > 0 {
39                                let nextCoordinateValue = min(maxValue, self.lastCoordinateValue + v.translation.width)
40                                self.value = (nextCoordinateValue / scaleFactor)
41                            } else {
42                                let nextCoordinateValue = max(0.0, self.lastCoordinateValue + v.translation.width)
43                                self.value = (nextCoordinateValue / scaleFactor)
44                            }
45                        }
46                )
47            }
48        }
49    }
50}

No change is needed to the ContentView and the behavior is that the star rating and yellow color jump to the finger location and move with the DragGesture.

Interactive Star Rating with yellow color changing with Drag Gesture
Interactive Star Rating with yellow color changing with Drag Gesture



Interactive Star Rating changing with Drag Gesture

Interactive Star Rating changing with Drag Gesture





Conclusion

This article built on the work done in Create a Star cutout shape in SwiftUI and Star with rounded corners in SwiftUI to create a star rating SwiftUI view. The number of stars in the view can be set as well as the value for the star rating. A DragGesture is used to make the star rating interactive with the value bound to the calling view.