SwiftUIであの体力ゲージを作ってみる

## はじめに

さて、世には体力という概念があるゲームがさまざまありますね。
そんな中、多くの人が通ったんじゃないかなーと思うものを参考に、何かを思わせる体力ゲージをSwiftUIで作ってみました。
この記事は、気づいた人からああ、あれか！と楽しめるタイプの記事となっております。

## 成果物

何を彷彿させるかは置いといて、一旦AとBとします。
最後に答え合わせ・・・は気が向いたらします。

### A

![Apr-28-2023 01-11-12.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81434/9af1cc38-e247-a538-dbfc-e6d9eef8abe4.gif)

### B

![Apr-28-2023 01-09-34.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81434/754c0606-7c00-ae92-1b94-f398fe831d17.gif)


## 実装

### A

こちらは、CADisplayLinkを使用したアニメーションになります。
※CADisplayLinkについては過去にも記事を書いているので説明は省きます
※AsyncStreamを使用するところは[ここ](https://stackoverflow.com/a/75514757)を参考にしました

本題じゃないので、折りたたんでおく

<details>
<summary>詳細</summary>

```swift:CADisplayLink+.swift
import UIKit

@MainActor
extension CADisplayLink {
    static func events() -> AsyncStream<CADisplayLink> {
        AsyncStream { continuation in
            let displayLink = DisplayLink { displayLink in
                continuation.yield(displayLink)
            }

            continuation.onTermination = { _ in
                Task { await displayLink.stop() }
            }
        }
    }
}

private extension CADisplayLink {
    @MainActor
    private class DisplayLink: NSObject {
        private var displayLink: CADisplayLink!
        private let handler: (CADisplayLink) -> Void

        init(mode: RunLoop.Mode = .default, handler: @escaping (CADisplayLink) -> Void) {
            self.handler = handler
            super.init()

            displayLink = CADisplayLink(target: self, selector: #selector(handle(displayLink:)))
            displayLink.add(to: .main, forMode: mode)
        }

        func stop() {
            displayLink.invalidate()
        }

        @objc func handle(displayLink: CADisplayLink) {
            handler(displayLink)
        }
    }
}
```

</details>

で、EasingをSwiftUIで使えるように定義したもの
(長いのでこちらも)

<details>
<summary>詳細</summary>

```swift:CustomEasing.swift
import CoreGraphics
import SwiftUI

enum CustomEasing {
    enum easeIn {
        case sine
        case quad
        case cubic
        case quart
        case quint
        case expo
        case circ
        case back

        func timingCurve(duration: CGFloat = 0.2) -> Animation {
            switch self {
            case .sine: return .timingCurve(0.12, 0, 0.39, 0, duration: duration)
            case .quad: return .timingCurve(0.11, 0, 0.5, 0, duration: duration)
            case .cubic: return .timingCurve(0.32, 0, 0.67, 0, duration: duration)
            case .quart: return .timingCurve(0.5, 0, 0.75, 0, duration: duration)
            case .quint: return .timingCurve(0.64, 0, 0.78, 0, duration: duration)
            case .expo: return .timingCurve(0.7, 0, 0.84, 0, duration: duration)
            case .circ: return .timingCurve(0.55, 0, 1, 0.45, duration: duration)
            case .back: return .timingCurve(0.36, 0, 0.66, -0.56, duration: duration)
            }
        }

        func progress(elapsed: CGFloat) -> CGFloat {
            switch self {
            case .sine:
                return 1 - cos((elapsed * .pi) / 2)
            case .quad:
                return elapsed * elapsed
            case .cubic:
                return elapsed * elapsed * elapsed
            case .quart:
                return elapsed * elapsed * elapsed * elapsed
            case .quint:
                return elapsed * elapsed * elapsed * elapsed * elapsed
            case .expo:
                return elapsed == 0 ? 0 : pow(2, 10 * elapsed - 10)
            case .circ:
                return 1 - sqrt(1 - pow(elapsed, 2))
            case .back:
                let c1 = 1.70158
                let c3 = c1 + 1
                return c3 * elapsed * elapsed * elapsed - c1 * elapsed * elapsed
            }
        }
    }

    enum easeOut {
        case sine
        case quad
        case cubic
        case quart
        case quint
        case expo
        case circ
        case back

        func timingCurve(duration: CGFloat) -> Animation {
            switch self {
            case .sine: return .timingCurve(0.61, 1, 0.88, 1, duration: duration)
            case .quad: return .timingCurve(0.5, 1, 0.89, 1, duration: duration)
            case .cubic: return .timingCurve(0.33, 1, 0.68, 1, duration: duration)
            case .quart: return .timingCurve(0.25, 1, 0.5, 1, duration: duration)
            case .quint: return .timingCurve(0.22, 1, 0.36, 1, duration: duration)
            case .expo: return .timingCurve(0.16, 1, 0.3, 1, duration: duration)
            case .circ: return .timingCurve(0, 0.55, 0.45, 1, duration: duration)
            case .back: return .timingCurve(0.34, 1.56, 0.64, 1, duration: duration)
            }
        }

        func progress(elapsed: CGFloat) -> CGFloat {
            switch self {
            case .sine:
                return sin((elapsed * .pi) / 2)
            case .quad:
                return 1 - (1 - elapsed) * (1 - elapsed)
            case .cubic:
                return 1 - pow(1 - elapsed, 3)
            case .quart:
                return 1 - pow(1 - elapsed, 4)
            case .quint:
                return 1 - pow(1 - elapsed, 5)
            case .expo:
                return elapsed == 1 ? 1 : 1 - pow(2, -10 * elapsed)
            case .circ:
                return sqrt(1 - pow(elapsed - 1, 2))
            case .back:
                let c1 = 1.70158
                let c3 = c1 + 1

                return 1 + c3 * pow(elapsed - 1, 3) + c1 * pow(elapsed - 1, 2)
            }
        }
    }

    enum easeInOut {
        case sine
        case quad
        case cubic
        case quart
        case quint
        case expo
        case circ
        case back

        func timingCurve(duration: CGFloat) -> Animation {
            switch self {
            case .sine: return .timingCurve(0.37, 0, 0.63, 1, duration: duration)
            case .quad: return .timingCurve(0.45, 0, 0.55, 1, duration: duration)
            case .cubic: return .timingCurve(0.65, 0, 0.35, 1, duration: duration)
            case .quart: return .timingCurve(0.76, 0, 0.24, 1, duration: duration)
            case .quint: return .timingCurve(0.83, 0, 0.17, 1, duration: duration)
            case .expo: return .timingCurve(0.87, 0, 0.13, 1, duration: duration)
            case .circ: return .timingCurve(0.85, 0, 0.15, 1, duration: duration)
            case .back: return .timingCurve(0.68, -0.6, 0.32, 1.6, duration: duration)
            }
        }

        func progress(elapsed: CGFloat) -> CGFloat {
            switch self {
            case .sine:
                return -(cos(.pi * elapsed) - 1) / 2
            case .quad:
                return elapsed < 0.5 ? 2 * elapsed * elapsed : 1 - pow(-2 * elapsed + 2, 2) / 2
            case .cubic:
                return elapsed < 0.5 ? 4 * elapsed * elapsed * elapsed : 1 - pow(-2 * elapsed + 2, 3) / 2
            case .quart:
                return elapsed < 0.5 ? 8 * elapsed * elapsed * elapsed * elapsed : 1 - pow(-2 * elapsed + 2, 4) / 2
            case .quint:
                return elapsed < 0.5 ? 16 * elapsed * elapsed * elapsed * elapsed * elapsed : 1 - pow(-2 * elapsed + 2, 5) / 2
            case .expo:
                return elapsed == 0
                  ? 0
                  : elapsed == 1
                  ? 1
                  : elapsed < 0.5 ? pow(2, 20 * elapsed - 10) / 2
                  : (2 - pow(2, -20 * elapsed + 10)) / 2
            case .circ:
                return elapsed < 0.5
                  ? (1 - sqrt(1 - pow(2 * elapsed, 2))) / 2
                  : (sqrt(1 - pow(-2 * elapsed + 2, 2)) + 1) / 2
            case .back:
                let c1 = 1.70158
                let c2 = c1 * 1.525

                return elapsed < 0.5
                  ? (pow(2 * elapsed, 2) * ((c2 + 1) * 2 * elapsed - c2)) / 2
                  : (pow(2 * elapsed - 2, 2) * ((c2 + 1) * (elapsed * 2 - 2) + c2) + 2) / 2
            }
        }
    }
}
```
</details>

で、ついに本題。
ストリームでどんどんtimestampを更新して、そのtimestampから進捗を算出。
その進捗をeaseOutCircに乗せて、その時点でのHPゲージおよび数字を算出して、Viewに反映する、と。
フレームごとに算出しているから、途中時点での色変更も容易にできちゃう。

```swift:PoxxxxxHitPointView.swift
import SwiftUI

struct PoxxxxxHitPointView: View {
    @State private var percentage: CGFloat = 1.0
    @State private var fillColor: Color = .green
    @State private var startTime: CFTimeInterval = .zero
    @State private var progress: CFTimeInterval = .zero
    @State private var duration: CGFloat = 5
    @State private var maxHP: Int = 315
    @State private var currentHP: Int = 315
    @State private var task: Task<Void, Never>?

    var body: some View {
        VStack {
            Capsule()
                .fill(.gray.opacity(0.2))
                .frame(width: 300, height: 8)
                .overlay(alignment: .leading) {
                    Rectangle()
                        .fill(fillColor)
                        .frame(width: 300 * percentage)
                }
                .cornerRadius(4)

            Text("\(currentHP)/\(maxHP)")
                .font(.footnote.bold())
                .kerning(2)
                .padding(.bottom, 24)

            Button(action: {
                start()
            }, label: {
                Text("Start")
                    .foregroundColor(.white)
                    .padding(.vertical, 8)
                    .padding(.horizontal, 16)
                    .background(Color.blue)
                    .cornerRadius(20)
            })

            Button(action: {
                stop()
            }, label: {
                Text("Stop")
                    .foregroundColor(.white)
                    .padding(.vertical, 8)
                    .padding(.horizontal, 16)
                    .background(Color.blue)
                    .cornerRadius(20)
            })
        }
    }

    private func start() {
        task?.cancel()
        task = Task {
            reset()
            for await event in await CADisplayLink.events() {
                // 進捗を算出
                progress = (event.timestamp - startTime) / duration
                // progressが1を超えたら終了
                if progress > 1 {
                    stop()
                }
                // ゲージの残り%を算出
                percentage = 1 - CustomEasing.easeOut.circ.progress(elapsed: progress)
                // 残りHPを算出
                currentHP = Int(CGFloat(maxHP) * (1.0 - (CustomEasing.easeOut.circ.progress(elapsed: progress))))
                // percentageに合わせて、色を変更
                fillColor = {
                    if percentage > 0.5 {
                        return .green // 「元気」色
                    } else if percentage > 0.2 {
                        return .yellow // 「そろそろやばい」色
                    } else {
                        return .red // 「いや、もうまじでやばい」色
                    }
                }()
            }
        }
    }

    private func stop() {
        task?.cancel()
        task = nil
    }

    private func reset() {
        startTime = CACurrentMediaTime()
        progress = .zero
        currentHP = maxHP
    }
}

struct PoxxxxxHitPointView_Previews: PreviewProvider {
    static var previews: some View {
        PoxxxxxHitPointView()
            .previewLayout(.sizeThatFits)
    }
}
```

まぁ、答えは言わないけど、Viewの名前でわかっちゃうかな〜〜〜〜

### B

続いて、Bのアニメーション。
こちらは技術自体はシンプルに、Keyframeチックにしてみようかなーという思いでいきます。
って思ったんですが、結果結構違うことになり、パンチごとに(？？)ダメージを喰らう感じに、percentageの配列を持つ形にします。で、ボタンタップの度に要素をずらす。

緑のゲージと赤のゲージをそれぞれ用意し、緑と赤のduration(アニメーションの時間)を変更することで、即座に残りHPがわかりつつ減った感も伝わるアニメーションが仕上がります。

```swift:StxxxxFixxxxxHitPointView
import SwiftUI

struct StxxxxFixxxxxHitPointView: View {
    @State private var counter: Int = 0
    @State private var percentage: CGFloat = 1.0
    private var percentages: [CGFloat] = [1, 0.9, 0.7, 0.2, 0]

    var body: some View {
        VStack {
            Capsule()
                .fill(.gray.opacity(0.2))
                .frame(width: 300, height: 8)
                .overlay(alignment: .leading) {
                    Rectangle()
                        .fill(.red)
                        .frame(width: 300 * percentage)
                        .animation(CustomEasing.easeOut.circ.timingCurve(duration: 2.5), value: percentage) // durationを変更することで追っていく感じに
                }
                .overlay(alignment: .leading) {
                    Rectangle()
                        .fill(.green)
                        .frame(width: 300 * percentage)
                        .animation(CustomEasing.easeOut.circ.timingCurve(duration: 0.4), value: percentage) // こっちはほぼ即座に(ゲームでは即時がいいと思うけど、まぁここはデモということで多少遊ばせてる)
                }
                .cornerRadius(4)
                .padding(.bottom, 24)

            Button(action: {
                attack()
            }, label: {
                Text("Attack")
                    .foregroundColor(.white)
                    .padding(.vertical, 8)
                    .padding(.horizontal, 16)
                    .background(Color.blue)
                    .cornerRadius(20)
            })

            Button(action: {
                reset()
            }, label: {
                Text("Reset")
                    .foregroundColor(.white)
                    .padding(.vertical, 8)
                    .padding(.horizontal, 16)
                    .background(Color.blue)
                    .cornerRadius(20)
            })
        }
    }

    private func attack() {
        guard counter < percentages.count - 1 else { return } // ほんまはButtonのdisabledを制御して押せないUIにするのが正しいけど今回は省く
        counter += 1
        setPercentage()
    }

    private func reset() {
        counter = 0
        setPercentage()
    }

    private func setPercentage() {
        percentage = percentages[counter]
    }
}

struct StxxxxFixxxxxHitPointView_Previews: PreviewProvider {
    static var previews: some View {
        StxxxxFixxxxxHitPointView()
            .previewLayout(.sizeThatFits)
    }
}
```

こっちもわかっちゃう人にはわかっちゃうな〜〜〜〜〜

※ちなみにどっちも、ナンバリング等でアニメーション違うので、ある時点でのぐらいで認識してもらえると

## まとめ

気が向かなかったので答えは書きませんが、動きとテレパシーで何の世界の体力ゲージか伝わってたら良いなと思います。

プロダクトコードにするならもっとさまざま工夫必要ですが、
こういった体力ゲージアニメーションも、SwiftUI、結構さくっと実装できるな〜という感想。(根本、進捗から算出して〜っていうロジック自体はUIKit時代からやってきてるからかもしれんけど)

## 余談

SwiftUI周りの記事もっと書きたいのあるから、どんどん書いていきたいと思うのでちゃんねるとうr・・・じゃなくて乞うご期待！←