レビュー用 ５つ星UIの実装 (SwiftUI)


# 概要
よくあるレビュー用の星の実装をSwiftUIでしてみました。
`4.3/5`の様なキリの悪いレートも表示できます。

タップ、スライドでユーザがレートを操作することも可能です。

# 動作環境
iOS16　以降

# 動作
![画面収録 2024-02-25 17.03.15.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/158647/9140aad5-40a5-ca01-996d-58b4a9b8f056.gif)



# 実装
```swift
import SwiftUI

@available(iOS 16.0, *)
public struct ReviewRateView: View {

    @Binding var reviewRate: CGFloat
    let eachStarSize: CGFloat
    let maximumStar: CGFloat
    let space: CGFloat
    let viewWidth: CGFloat
    let range: Range<Int>

    public init(
        reviewRate: Binding<CGFloat>, // 0.0 〜 1.0
        eachStarSize: CGFloat = 40,
        maximumStar _maximumStar: Int = 5,
        space: CGFloat = 5
    ) {
        _reviewRate = reviewRate
        self.eachStarSize = eachStarSize
        self.maximumStar = CGFloat(_maximumStar)
        self.space = space
        self.viewWidth = eachStarSize * maximumStar + (space * (maximumStar - 1))
        self.range = 0 ..< Int(maximumStar)
    }

    @ViewBuilder
    public var body: some View {
        ZStack {
            Color.gray
                .frame(width: viewWidth, height: eachStarSize)
                .overlay(alignment: .leading) {
                    Color.yellow.frame(width: viewRatio * viewWidth, height: eachStarSize)
                }
                .mask {
                    HStack(spacing: space) {
                        ForEach(Array(range), id: \.self) { _ in
                            Image(systemName: "star.fill")
                                .resizable()
                                .scaledToFit()
                                .frame(width: eachStarSize)
                        }
                    }
                }
                .onTapGesture(perform: { point in
                    updateReviewRate(from: point.x)
                })
                .gesture(
                    DragGesture(minimumDistance: 0)
                        .onChanged({ value in
                            updateReviewRate(from: value.location.x)
                        })
                        .onEnded({ value in
                            updateReviewRate(from: value.location.x)
                        })
                )

        }
    }

    var viewRatio: CGFloat {
        let maxStarRatio = (eachStarSize * maximumStar) / viewWidth
        let eachSpaceRatio = space / viewWidth
        let spaceRatio = eachSpaceRatio * CGFloat(Int(reviewRate * maximumStar))
        let viewRatio = spaceRatio + (reviewRate * maxStarRatio)
        return min(1, max(viewRatio, 0))
    }

    func updateReviewRate(from pointX: CGFloat) {
        let _reviewRate = CGFloat(max(1, Int(pointX / (eachStarSize + space))  + 1))
        withAnimation(.spring) {
            reviewRate = _reviewRate / maximumStar
        }
    }
}

```

## 使う側のコード

```swift
@available(iOS 16.0, *)
public struct ContentView: View {

    @State var reviewRate1: CGFloat = 4.3 / 5
    @State var reviewRate2: CGFloat = 5 / 8
    @State var reviewRate3: CGFloat = 1.5 / 3

    public init() {

    }

    public var body: some View {
        VStack {
            ReviewRateView(reviewRate: $reviewRate1)


            ReviewRateView(reviewRate: $reviewRate2, eachStarSize: 20, maximumStar: 8, space: 10)

            // 操作不可にする場合
            ReviewRateView(reviewRate: .init(get: { reviewRate3 }, set: { _ in }), eachStarSize: 20, maximumStar: 3, space: 30)
        }
    }
}

```

# 実装のポイント
* 黄色の四角いバーに星でマスクすることで実現しています。

* 以前UIKit同じようなUIを実装したときは半分のスターの画像を用意したり、各それぞれの星画像にタップイベントを仕込んだりして煩雑なコードになってた気がします。
SwiftUIではアニメーション、ユーザーインタラクションも含めて70行程度で非常にクールに書けました。
ただ、レートを描画するとき、その割合をそのまま表示するのではなく、スペースが占領する割合も考慮して計算したところは思ったより複雑になりました。

* 全体のサイズは星のサイズとスペースから割り出されるので、可変にするためには外側でGeometoryReaderなどでもろもろ計算して渡してあげる必要があるでしょう。