36
23

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-04-28

はじめに

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

成果物

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

A

Apr-28-2023 01-11-12.gif

B

Apr-28-2023 01-09-34.gif

実装

A

こちらは、CADisplayLinkを使用したアニメーションになります。
※CADisplayLinkについては過去にも記事を書いているので説明は省きます
※AsyncStreamを使用するところはここを参考にしました

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

詳細
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)
        }
    }
}

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

詳細
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
            }
        }
    }
}

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

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がわかりつつ減った感も伝わるアニメーションが仕上がります。

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・・・じゃなくて乞うご期待!←

36
23
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
23