SwiftUIで円形のプログレスバーを作る

こんにちは！
[アイスタイル Advent Calender 2022](https://qiita.com/advent-calendar/2022/istyle) 8日目の記事をを担当します。[hayakawat](https://qiita.com/tomoyuki_HAYAKAWA)です。  
アイスタイルではアプリ開発グループでiOSアプリの開発をしています。  
今回は最近個人で開発したアプリで利用した円形のプログレスバーについて書こうと思います。

## 今回作るもの
円形のプログレスバーとはiPhoneのバッテリーウィジェット等で使われている緑の円のやつです。iPhoneユーザの方は見覚えあるかと思います。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173720/5263fada-0a6f-d8d1-5542-a6376434db0f.png" width="320px">

## 作り方
緑の円と背景のグレーの円をピッタリ重ねることで円が満たされていくように見せています。
ZStackで同じ大きさのCircle()をピッタリ重ねて表示します。

```swift
struct CircleProgressView: View {
    var body: some View {
        ZStack {
            // 下の円
            Circle()
                .stroke(
                    Color.gray,
                    style: StrokeStyle(
                        lineWidth: 15, // 線の太さ
                        lineCap: .round) // 線の端の形状
                )
                .opacity(0.5) // 透明度
                .frame(width: 150, height: 150) // 大きさ
            // 上の円
            Circle()
                .trim(from: 0.0, to: 0.5) // 線のトリム
                .stroke(
                    Color.green,
                    style: StrokeStyle(
                        lineWidth: 15,
                        lineCap: .round)
                )
                .frame(width: 150, height: 150)
        }
    }
}
```

`.stroke`を付けることで塗りつぶしの円から線の円にしています。
上の円に追加した`.trim(from: 0.0, to: 0.5)`は円をどの位置でトリムするかを決めています。
今回の場合だとちょうど半分になります。
`to`の値を変更することで円の進み具合を変更することができます。
するとこんな感じになります。
![スクリーンショット 2022-12-05 1.16.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173720/a8e2cb18-b052-5639-5d0e-1f809a5287ff.png)

90度くらいの位置から始まりちょうど半分まで円が描画されています。
しかし、バッテリーウィジェットは上の位置から始まっているので、今回作っているプログレスバーも上から始まるようにします。
そのために上の円に`.rotationEffect(Angle(degrees: -90))`を追加します。

```swift
struct CircleProgressView: View {
    var body: some View {
        ZStack {
            Circle()
                .stroke(
                    Color.gray,
                    style: StrokeStyle(
                        lineWidth: 15,
                        lineCap: .round)
                )
                .opacity(0.5)
                .frame(width: 150, height: 150)
            Circle()
                .trim(from: 0.0, to: 0.5)
                .stroke(
                    Color.green,
                    style: StrokeStyle(
                        lineWidth: 15,
                        lineCap: .round)
                )
                .frame(width: 150, height: 150)
                .rotationEffect(Angle(degrees: -90)) // 追加
        }
    }
}
```

すると円の上からちょうど半分の真下まで満たされたViewを作成することができました。
![スクリーンショット 2022-12-05 1.21.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173720/8d7a12af-7e81-2a00-db35-d9aade9780ca.png)

## アニメーションさせる
さらに60秒で満たされるプログレスバーの進捗が満たされるようにしていきます。

### ViewModelの作成
まずは進捗の割合などのロジックを記述するViewModelを作成します。

```swift
import Foundation
import SwiftUI
import Combine

final class CircleProgressViewModel: ObservableObject {
    @Published var progressValue: CGFloat = 0.0
    private var timerCount: CGFloat = 0.0
    private var cancellable: AnyCancellable?

    init() {
        startTimer()
    }

    private func startTimer() {
        cancellable = Timer.publish(every: 0.1, on: .main, in: .default)
            .autoconnect()
            .sink { [weak self] _ in
                self?.countProgress()
            }
    }

    private func countProgress() {
        if timerCount > 60 { cancellable?.cancel() }
        timerCount = timerCount + 0.1
        progressValue = timerCount / 60.0
    }
}

```

`progressValue`はView側で監視したいので`@Published`で宣言します。
`startTimer`ではCombineを使用して`countProgress`メソッドを0.1秒ごとに実行します。
`countProgress`内では先頭で`timerCount`で60秒経過したかを判定しtrueならタイマーを破棄するようにしています。
falseなら`timerCount`に0.1を加算してCircleProgressView側の`.trim`の範囲である0.0〜1.0の間に収めるために60.0で割っています。

### CircleProgressView側に反映する
では先ほど作成した`CircleProgressViewModel`内の`progressValue`を`CircleProgressView`で監視してアニメーションするようにします。

```swift
struct CircleProgressView: View {
    // ViewModelをStateObjectで宣言してCircleProgressViewModelを監視できるようにする
    @StateObject var viewModel = CircleProgressViewModel()
    var body: some View {
        ZStack {
            Circle()
                .stroke(
                    Color.gray,
                    style: StrokeStyle(
                        lineWidth: 15,
                        lineCap: .round)
                )
                .opacity(0.5)
                .frame(width: 150, height: 150)
            Circle()
                .trim(from: 0.0, to: viewModel.progressValue) // toの値をViewModelのprogressValueを参照する
                .stroke(
                    Color.green,
                    style: StrokeStyle(
                        lineWidth: 15,
                        lineCap: .round)
                )
                .frame(width: 150, height: 150)
                .rotationEffect(Angle(degrees: -90))
        }
    }
}
```

このように`.trim`のtoの値を`viewModel.progressValue`から参照することで、↓のgifのように緑の円が進んでいく円形のプログレスバーの完成です。
![progress_gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/173720/088842fc-4029-5744-b7b4-658e894cefec.gif)

## まとめ
意外と簡単に円形のプログレスバーを作ることができました。  
もっと応用すれば面白い形のプログレスバーやグラフを描画することができそうですね。
それでは。よいお年を。