[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NewsPicksAdvent Calendar 2024

Day 23

SwiftUIのよく使うView要素だけで届ける、某アプリの起動アニメーション

Last updated at Posted at 2024-12-22

ごあいさつ

NewsPicks iOSエンジニアはぐっです!
この記事は NewsPicks Advent Calendar 2024 の23日目の記事です。

はじめに

みなさま、覚えていますか。
あの日晴れてなかったらきっと出会えていなかった、なんてことない日常から作られたあの・・・

猫の手をぉぉぉぉぉぉおおおおお!!!!!!!

Dec-20-2023 02-18-47.gif

そう、トマトマトですね。
https://qiita.com/haguhoms/items/6b005f692fa21d9e4f2a

この猫の手めっちゃかわいくて、作りながらめっちゃテンション上がっていたのを昨日のように覚えています。
これ、さすがに愛着わきすぎて、そのままスマホゲームとして成り立たせて、アプリ公開までいっちゃうんじゃね?
なんなら広告とか入れて、収益化まで目指すんじゃね?
そんで売れに売れて、トマトマトを作った人から怒られたりとかもするんじゃね?(ここまでは思ってない)

そしてはや1年ですか、相変わらずのぐうたらぶりを発揮して、コードは1年前のままでした。

アドベントカレンダーという絶好の機会に、このアプリをさらに進めよう、そんなモチベーションから始まった今回の記事です。

今年やりたかったこと

ゲーム画面は、まぁ調整は全然必要やけどおおまかにはできている。
となると最低限必要なのは、

  • 起動画面
  • プレイヤー数選択画面
  • 結果画面

まぁこの辺だろうと。
で、結果画面なんて、つまりトマトマトのゲームルールを落とし込む、地味っちゃあ地味な作業で。
それは僕じゃなくても書くだろう。
なら何をするべきか、すぐに決まりました。

ってことで選ばれたのは

起動画面

でした。

成果物

しのごの言うてんと、はよ見せんかい!!!

リスナーからお叱りを受けたので、早速成果物から見ていきます。
今回も、いらすとやからの画像と、SwiftUIのView要素だけで作る、シンプルな構成になっています。

Dec-18-2024 02-54-09.gif

トマトとトマトが出会って、ぶつかって、トマトマトになった瞬間、世界が広がって、ゲームが始まるというストーリーです。
これから始まるゲームの世界に引き込むための、人とゲームの最初の出会いの場なので、世界観の一歩目を作れるよう構想しました。

実装

フェーズごとに登場人物がいるので、それぞれで実装を見ていくのですが、前提として、すべてのアニメーションの作り方は同じです。

  • 経過秒数
  • アニメーション時間
  • イージング
  • 初期値と終端値

この辺りの要素を組み合わせてそれぞれのアニメーションが成り立っていて、実装方法で新しいことが何個も出てくるわけではないのでアニメーション実装なんとなく怖いみたいな人も把握しやすい記事になっていると思います。

フェーズはそれぞれ

  • トマトとトマトが出会って、ぶつかって、トマトマトになる
  • 世界が広がる
  • ゲームが始まる

こんな感じでざっくり分けますか。
では、まずは共通部分から

共通部分

アニメーションさせるために必要なものをまず準備します。
(トマトマト自体がSwiftUI+TCA(The Composable Architecture)で作られているので、StartReducerもあるのですが、内容空っぽなので割愛します)
※前提、ダークモード対応とかもなく、iPadの横向きだけ考えて作っています。

StartView.swift
import SwiftUI
import ComposableArchitecture

@ViewAction(for: StartReducer.self)
struct StartView: View {
    @State private var startTime: CFTimeInterval = .zero
    @State private var progress: CFTimeInterval = .zero
    @State private var task: Task<Void, Never>?

    // アニメーション時間
    struct AnimateDuration {
        // 各要素のアニメーション時間
    }

    // アニメーションタイミング
    struct AnimateTiming {
        // 各要素のアニメーションタイミング
    }

    // 各定義値
    struct Const {
        static let screenWidth: CGFloat = UIScreen.main.bounds.width
        static let screenHeight: CGFloat = UIScreen.main.bounds.height
        static let tomatoColor: Color = .init(red: 0.74, green: 0.18, blue: 0.14)
        static let tomatoSize: CGFloat = 64
    }

    let store: StoreOf<StartReducer>
    
    init(store: StoreOf<StartReducer>) {
        self.store = store
    }

    var body: some View {
        ZStack {
            // View要素が入っていく
        }
        .onAppear { start() }
    }
}

extension StartView {
    private func start() {
        task?.cancel()
        task = Task {
            reset()
            for await event in await CADisplayLink.events() {
                progress = event.timestamp - startTime
            }
        }
    }

    private func reset() {
        startTime = CACurrentMediaTime()
        // また最初から開始したいとかなる場合はここで各要素を初期値にする
    }
}

CADisplayLinkとCustomEasingに関しては、前の記事でも話しているので畳んでおきます。

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

あとは今回実装したアニメーションの基本構成を。
以下のコードが構成で、大きくは

  • before animation
  • animation
  • after animation

の3部構成になります。

if progress < AnimateTiming.xx {
    // before animation
    factor = 0
} else if progress <= AnimateTiming.xx + AnimateDuration.xx {
    // animation
    let elapsed: CGFloat = {
        let elapsed = (progress - AnimateTiming.xx) / AnimateDuration.xx
        return .minimum(elapsed, 1)
    }()
    factor = CustomEasing.easeOut.circ.progress(elapsed: elapsed)
} else {
    // after animation
    factor = 1
}

before animation

  • 対象のアニメーションが始まるタイミングまで
  • 対象の値を初期値に

animation

  • 対象のアニメーションが始まるタイミングから、対象のアニメーションのアニメーション時間が経過するまで
  • 対象の値は、指定イージング関数上の経過時間時の値に(上で畳んだCustomEasingでは経過時間を渡すと0-1で値が返ってくるようにしているので、初期値と終端値を組み合わせるとそのタイミングであるべき値が算出される)

after animation

  • 対象のアニメーション時間が経過後
  • 対象の値は終端値

これが、今回のアニメーションの基本構成で、全部こういう構成になっていると思って見るとなるほどなるほどって読み進められると思います。

トマトとトマトが出会って、ぶつかって、トマトマトになる

左トマト右トマトが出現し、衝突する。
アニメーション前は、初期位置は画面外、透明度は0にしておいて、位置と透明度を変えていく感じ。

StartView.swift
struct StartView {
    // 左トマト
    @State private var leftTomatoHorizontalTranslation: CGFloat = .zero
    @State private var leftTomatoOpacity: CGFloat = .zero

    // 右トマト
    @State private var rightTomatoHorizontalTranslation: CGFloat = .zero
    @State private var rightTomatoOpacity: CGFloat = .zero

    struct AnimateDuration {
        // トマト出現
        static let tomatoAppear = 1.0
        // トマト衝突
        static let tomatoClash = 1.5
    }

    struct AnimateTiming {
        // 左トマト出現
        static let leftTomatoAppear = 0.0
        // 右トマト出現
        static let rightTomatoAppear = 1.0
        // トマト衝突
        static let tomatoClash = 2.5
    }

    struct Const {
        // トマト出現
        static let tomatoAppearXOffset: CGFloat = Const.screenWidth / 4
        // トマト衝突
        static let tomatoClashXOffset: CGFloat = Const.screenWidth / 2 - Const.tomatoSize / 2
    }

    var body: some View {
        ZStack {
            HStack(spacing: 0) {
                tomatoImage
                    .offset(x: leftTomatoHorizontalTranslation)
                    .opacity(leftTomatoOpacity)
                Spacer()
            }

            HStack(spacing: 0) {
                Spacer()
                tomatoImage
                    .offset(x: rightTomatoHorizontalTranslation)
                    .opacity(rightTomatoOpacity)
            }
        }
    }

    var tomatoImage: some View {
        Image("tomato")
            .resizable()
            .frame(width: Const.tomatoSize, height: Const.tomatoSize)
    }
}

extension StartView {
    private func start() {
        task?.cancel()
        task = Task {
            reset()
            for await event in await CADisplayLink.events() {
                progress = event.timestamp - startTime

                // 左トマト
                animateLeftTomato()

                // 右トマト
                animateRightTomato()
            }
        }
    }

    // 左トマト
    private func animateLeftTomato() {
        if progress < AnimateTiming.leftTomatoAppear {
            // before animation
            leftTomatoHorizontalTranslation = -Const.tomatoSize
            leftTomatoOpacity = 0
        } else if progress <= AnimateTiming.leftTomatoAppear + AnimateDuration.tomatoAppear {
            // appear animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.leftTomatoAppear) / AnimateDuration.tomatoAppear
                return .minimum(elapsed, 1)
            }()
            leftTomatoHorizontalTranslation = -Const.tomatoSize + CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * (Const.tomatoSize + Const.tomatoAppearXOffset)
            leftTomatoOpacity = CustomEasing.easeInOut.circ.progress(elapsed: elapsed)
        } else if progress <= AnimateTiming.tomatoClash {
            // before clash animation
            leftTomatoHorizontalTranslation = Const.tomatoAppearXOffset
            leftTomatoOpacity = 1
        } else if progress <= AnimateTiming.tomatoClash + AnimateDuration.tomatoClash {
            // clash animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.tomatoClash) / AnimateDuration.tomatoClash
                return .minimum(elapsed, 1)
            }()
            leftTomatoHorizontalTranslation = Const.tomatoAppearXOffset + CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * (Const.tomatoClashXOffset - Const.tomatoAppearXOffset)
            leftTomatoOpacity = 1
        } else {
            // after animation
            leftTomatoHorizontalTranslation = Const.tomatoClashXOffset
            leftTomatoOpacity = 1
        }
    }

    // 右トマト
    private func animateRightTomato() {
        if progress < AnimateTiming.rightTomatoAppear {
            // before animation
            rightTomatoHorizontalTranslation = Const.tomatoSize
            rightTomatoOpacity = 0
        } else if progress <= AnimateTiming.rightTomatoAppear + AnimateDuration.tomatoAppear {
            // appear animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.rightTomatoAppear) / AnimateDuration.tomatoAppear
                return .minimum(elapsed, 1)
            }()
            rightTomatoHorizontalTranslation = Const.tomatoSize - CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * (Const.tomatoSize + Const.tomatoAppearXOffset)
            rightTomatoOpacity = CustomEasing.easeInOut.circ.progress(elapsed: elapsed)
        } else if progress <= AnimateTiming.tomatoClash {
            // before clash animation
            rightTomatoHorizontalTranslation = -Const.tomatoAppearXOffset
            rightTomatoOpacity = 1
        } else if progress <= AnimateTiming.tomatoClash + AnimateDuration.tomatoClash {
            // clash animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.tomatoClash) / AnimateDuration.tomatoClash
                return .minimum(elapsed, 1)
            }()
            rightTomatoHorizontalTranslation = -Const.tomatoAppearXOffset - CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * (Const.tomatoClashXOffset - Const.tomatoAppearXOffset)
            rightTomatoOpacity = 1
        } else {
            // after animation
            rightTomatoHorizontalTranslation = -Const.tomatoClashXOffset
            rightTomatoOpacity = 1
        }
    }

    private func reset() {
        startTime = CACurrentMediaTime()
        // また最初から開始したいとかなる場合はここで各要素を初期値にする
    }
}

世界が広がる

トマトがぶつかったところから3つの円が広がって、最終状態の長方形枠を表示させる。
3つの円は、アニメーション時間は一緒にしてイージングを変えることで、同じ動きをしないけど良い感じのリズムで動いてくれるようにしました。
最初の円は、トマトとトマトがぶつかったことによる衝撃から発生した感じにさせたいので、そういうときはeaseOutにして序盤の変化量大きく。
真ん中はeaseInOutにしてほどよくリズムをつけてくれる感じにして。
最後の円は、次の長方形表示への勢いをつけたいので、終盤の変化量が大きいeaseInで。
で、最後に長方形を表示して、起動画面の最後の形を作る。

StartView.swift
struct StartView: View {
    // circle
    @State private var firstCircleSize: CGFloat = .zero
    @State private var secondCircleSize: CGFloat = .zero
    @State private var thirdCircleSize: CGFloat = .zero

    // rectangle
    @State private var rectangleWidth: CGFloat = .zero
    @State private var rectangleHeight: CGFloat = .zero

    struct AnimateDuration {
        // 円広がる
        static let circleExpand = 1.0
        // 長方形広がる
        static let rectangleExpand = 1.0
    }

    struct AnimateTiming {
        // 円広がる
        static let firstCircleExpand = 3.6
        static let secondCircleExpand = 3.6
        static let thirdCircleExpand = 3.8
        // 長方形広がる
        static let rectangleExpand = 4.5
    }

    struct Const {
        // 円最大値
        // 円で画面全体を覆うようにhypotで算出
        static let circleMaxSize: CGFloat = hypot(Const.screenWidth, screenHeight)
    }

    var body: some View {
        ZStack {
            // 左トマト
            // 右トマト

            Circle()
                .fill(Const.tomatoColor)
                .frame(width: firstCircleSize, height: firstCircleSize)

            Circle()
                .fill(Color.white)
                .frame(width: secondCircleSize, height: secondCircleSize)

            Circle()
                .fill(Const.tomatoColor)
                .frame(width: thirdCircleSize, height: thirdCircleSize)

            Const.tomatoColor
                .frame(width: rectangleWidth, height: rectangleHeight)
                .overlay {
                    Color.white
                        .frame(width: rectangleWidth > 80 ? rectangleWidth - 80 : 0, height: rectangleHeight > 80 ? rectangleHeight - 80 : 0)
                }
        }
    }
}

extension StartView {
    private func start() {
        task?.cancel()
        task = Task {
            reset()
            for await event in await CADisplayLink.events() {
                progress = event.timestamp - startTime

                // 左トマト
                // 右トマト

                // 円
                animateFirstCircle()
                animateSecondCircle()
                animateThirdCircle()

                // 長方形
                animateRectangle()
            }
        }
    }

    // 1つ目の円
    private func animateFirstCircle() {
        if progress < AnimateTiming.firstCircleExpand {
            // before animation
            firstCircleSize = 0
        } else if progress <= AnimateTiming.firstCircleExpand + AnimateDuration.circleExpand {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.firstCircleExpand) / AnimateDuration.circleExpand
                return .minimum(elapsed, 1)
            }()
            firstCircleSize = CustomEasing.easeOut.circ.progress(elapsed: elapsed) * Const.circleMaxSize
        } else {
            // after animation
            firstCircleSize = Const.circleMaxSize
        }
    }

    // 2つ目の円
    private func animateSecondCircle() {
        if progress < AnimateTiming.secondCircleExpand {
            // before animation
            secondCircleSize = 0
        } else if progress <= AnimateTiming.secondCircleExpand + AnimateDuration.circleExpand {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.secondCircleExpand) / AnimateDuration.circleExpand
                return .minimum(elapsed, 1)
            }()
            secondCircleSize = CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * Const.circleMaxSize
        } else {
            // after animation
            secondCircleSize = Const.circleMaxSize
        }
    }

    // 3つ目の円
    private func animateThirdCircle() {
        if progress < AnimateTiming.thirdCircleExpand {
            // before animation
            thirdCircleSize = 0
        } else if progress <= AnimateTiming.thirdCircleExpand + AnimateDuration.circleExpand {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.thirdCircleExpand) / AnimateDuration.circleExpand
                return .minimum(elapsed, 1)
            }()
            thirdCircleSize = CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * Const.circleMaxSize
        } else {
            // after animation
            thirdCircleSize = Const.circleMaxSize
        }
    }

    // 長方形
    private func animateRectangle() {
        if progress < AnimateTiming.rectangleExpand {
            // before animation
            rectangleWidth = 0
            rectangleHeight = 0
        } else if progress <= AnimateTiming.rectangleExpand + AnimateDuration.rectangleExpand {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.rectangleExpand) / AnimateDuration.rectangleExpand
                return .minimum(elapsed, 1)
            }()
            rectangleWidth = CustomEasing.easeOut.circ.progress(elapsed: elapsed) * Const.screenWidth
            rectangleHeight = CustomEasing.easeOut.circ.progress(elapsed: elapsed) * Const.screenHeight
        } else {
            // after animation
            rectangleWidth = Const.screenWidth
            rectangleHeight = Const.screenHeight
        }
    }

    private func reset() {
        startTime = CACurrentMediaTime()
        // また最初から開始したいとかなる場合はここで各要素を初期値にする
    }
}

ゲームが始まる

最後は起動画面の締めということで、スタートボタン含め終着状態になると。
文字は一文字ずつほよほよ出す感じにして、柔らかく出す感じにしました。
で、このゲームの中心であるトマトを出現させて印象づかせて、スタートボタンを表示する。
最後は比較的落ち着いた感じに仕上げて、見る疲れが出ないようにを意識。

StartView
struct StartView: View {
    // title character
    @State private var firstCharacterYOffset: CGFloat = -100
    @State private var secondCharacterYOffset: CGFloat = -100
    @State private var thirdCharacterYOffset: CGFloat = -100
    @State private var fourthCharacterYOffset: CGFloat = -100
    @State private var fifthCharacterYOffset: CGFloat = -100

    // title tomato
    @State private var titleTomatoScale: CGFloat = .zero
    @State private var titleTomatoOpacity: CGFloat = .zero

    // title tomato flash
    @State private var titleTomatoFlashHeight: CGFloat = .zero
    @State private var titleTomatoFlashOffset: CGFloat = .zero

    // title start button
    @State private var titleStartButtonScale: CGFloat = 3
    @State private var titleStartButtonOpacity: CGFloat = .zero

    struct AnimateDuration {
        // タイトル文字出現
        static let characterAppear = 1.0
        // タイトルトマト出現
        static let titleTomatoAppear = 0.3
        // タイトルトマト閃光出現
        static let titleTomatoFlashAppear = 0.4
        // タイトルトマト閃光消失
        static let titleTomatoFlashDisappear = 0.4
        // タイトルボタン出現
        static let titleStartButtonAppear = 0.4
    }
    
    struct AnimateTiming {
        // タイトル文字出現
        static let firstCharacterAppear = 5.4
        static let secondCharacterAppear = 5.6
        static let thirdCharacterAppear = 5.8
        static let fourthCharacterAppear = 6.0
        static let fifthCharacterAppear = 6.2
        // タイトルトマト出現
        static let titleTomatoAppear = 7.2
        // タイトルトマト閃光出現
        static let titleTomatoFlashAppear = 7.1
        // タイトルトマト閃光消失
        static let titleTomatoFlashDisappear = 7.4
        // タイトルボタン出現
        static let titleStartButtonAppear = 7.4
    }

    struct Const {
        // タイトル文字位置
        static let firstCharacterYOffset: CGFloat = screenHeight / 3 - 30
        static let secondCharacterYOffset: CGFloat = screenHeight / 3 - 5
        static let thirdCharacterYOffset: CGFloat = screenHeight / 3 - 15
        static let fourthCharacterYOffset: CGFloat = screenHeight / 3 - 20
        static let fifthCharacterYOffset: CGFloat = screenHeight / 3
        // タイトルトマト閃光高さ
        static let titleTomatoFlashMaxHeight: CGFloat = 40
        // タイトルトマト閃光位置
        static let titleTomatoFlashOffset: CGFloat = 60
    }

    var body: some View {
        ZStack {
            // 左トマト
            // 右トマト
            // 円
            // 長方形
            
            // タイトル文字
            Color.clear
                .frame(width: Const.screenWidth, height: Const.screenHeight)
                .overlay(alignment: .top) {
                    HStack(spacing: 40) {
                        Text("ト")
                            .font(.system(size: 40, weight: .semibold))
                            .foregroundStyle(Const.titleColor)
                            .offset(y: firstCharacterYOffset)
                        Text("マ")
                            .font(.system(size: 40, weight: .semibold))
                            .foregroundStyle(Const.titleColor)
                            .offset(y: secondCharacterYOffset)
                        Text("ト")
                            .font(.system(size: 40, weight: .semibold))
                            .foregroundStyle(Const.titleColor)
                            .offset(y: thirdCharacterYOffset)
                        Text("マ")
                            .font(.system(size: 40, weight: .semibold))
                            .foregroundStyle(Const.titleColor)
                            .offset(y: fourthCharacterYOffset)
                        Text("ト")
                            .font(.system(size: 40, weight: .semibold))
                            .foregroundStyle(Const.titleColor)
                            .offset(y: fifthCharacterYOffset)
                    }
                }

            // タイトルトマト
            tomatoImage
                .scaleEffect(x: titleTomatoScale, y: titleTomatoScale)
                .opacity(titleTomatoOpacity)

            // タイトルトマト閃光
            ZStack {
                Const.tomatoColor
                    .frame(width: 4, height: titleTomatoFlashHeight)
                    .clipShape(RoundedRectangle(cornerRadius: 4))
                    .rotationEffect(.degrees(60))
                    .offset(x: titleTomatoFlashOffset, y: -titleTomatoFlashOffset / 2)
                Const.tomatoColor
                    .frame(width: 4, height: titleTomatoFlashHeight)
                    .clipShape(RoundedRectangle(cornerRadius: 4))
                    .rotationEffect(.degrees(120))
                    .offset(x: titleTomatoFlashOffset, y: titleTomatoFlashOffset / 2)
                Const.tomatoColor
                    .frame(width: 4, height: titleTomatoFlashHeight)
                    .clipShape(RoundedRectangle(cornerRadius: 4))
                    .rotationEffect(.degrees(-60))
                    .offset(x: -titleTomatoFlashOffset, y: -titleTomatoFlashOffset / 2)
                Const.tomatoColor
                    .frame(width: 4, height: titleTomatoFlashHeight)
                    .clipShape(RoundedRectangle(cornerRadius: 4))
                    .rotationEffect(.degrees(-120))
                    .offset(x: -titleTomatoFlashOffset, y: titleTomatoFlashOffset / 2)
            }

            // タイトルボタン
            Color.clear
                .frame(width: Const.screenWidth, height: Const.screenHeight)
                .overlay(alignment: .top) {
                    Button("Start") {
                        send(.startButtonTapped)
                    }
                    .buttonStyle(StartButtonStyle())
                    .offset(y: Const.screenHeight * 2 / 3)
                    .scaleEffect(x: titleStartButtonScale)
                }
                .opacity(titleStartButtonOpacity)
        }
    }
}

private struct StartButtonStyle: ButtonStyle {
    let tomatoColor: Color = .init(red: 0.74, green: 0.18, blue: 0.14)

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .foregroundStyle(Color.white)
            .font(.system(size: 32, weight: .semibold))
            .padding(.horizontal, 56)
            .padding(.vertical, 8)
            .overlay(
                RoundedRectangle(cornerRadius: 4)
                    .stroke(tomatoColor, lineWidth: 1)
            )
            .background(tomatoColor)
            .clipShape(RoundedRectangle(cornerRadius: 4))
            .contentShape(Rectangle())
            .opacity(configuration.isPressed ? 0.75 : 1)
    }
}

private extension View {
    func startButtonStyle() -> some View {
        buttonStyle(StartButtonStyle())
    }
}

extension StartView {
    private func start() {
        task?.cancel()
        task = Task {
            reset()
            for await event in await CADisplayLink.events() {
                progress = event.timestamp - startTime

                // 左トマト
                // 右トマト
                // 円
                // 長方形

                // タイトル
                animateFirstCharacter()
                animateSecondCharacter()
                animateThirdCharacter()
                animateFourthCharacter()
                animateFifthCharacter()
                animateTitleTomato()
                animateTitleTomatoFlash()
                animateTitleStartButton()
            }
        }
    }

    // 1文字目
    private func animateFirstCharacter() {
        if progress < AnimateTiming.firstCharacterAppear {
            // before animation
            firstCharacterYOffset = -100
        } else if progress <= AnimateTiming.firstCharacterAppear + AnimateDuration.characterAppear {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.firstCharacterAppear) / AnimateDuration.characterAppear
                return .minimum(elapsed, 1)
            }()
            firstCharacterYOffset = -100 + CustomEasing.easeOut.back.progress(elapsed: elapsed) * (Const.firstCharacterYOffset + 100)
        } else {
            // after animation
            firstCharacterYOffset = Const.firstCharacterYOffset
        }
    }

    // 2文字目
    private func animateSecondCharacter() {
        if progress < AnimateTiming.secondCharacterAppear {
            // before animation
            secondCharacterYOffset = -100
        } else if progress <= AnimateTiming.secondCharacterAppear + AnimateDuration.characterAppear {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.secondCharacterAppear) / AnimateDuration.characterAppear
                return .minimum(elapsed, 1)
            }()
            secondCharacterYOffset = -100 + CustomEasing.easeOut.back.progress(elapsed: elapsed) * (Const.secondCharacterYOffset + 100)
        } else {
            // after animation
            secondCharacterYOffset = Const.secondCharacterYOffset
        }
    }

    // 3文字目
    private func animateThirdCharacter() {
        if progress < AnimateTiming.thirdCharacterAppear {
            // before animation
            thirdCharacterYOffset = -100
        } else if progress <= AnimateTiming.thirdCharacterAppear + AnimateDuration.characterAppear {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.thirdCharacterAppear) / AnimateDuration.characterAppear
                return .minimum(elapsed, 1)
            }()
            thirdCharacterYOffset = -100 + CustomEasing.easeOut.back.progress(elapsed: elapsed) * (Const.thirdCharacterYOffset + 100)
        } else {
            // after animation
            thirdCharacterYOffset = Const.thirdCharacterYOffset
        }
    }

    // 4文字目
    private func animateFourthCharacter() {
        if progress < AnimateTiming.fourthCharacterAppear {
            // before animation
            fourthCharacterYOffset = -100
        } else if progress <= AnimateTiming.fourthCharacterAppear + AnimateDuration.characterAppear {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.fourthCharacterAppear) / AnimateDuration.characterAppear
                return .minimum(elapsed, 1)
            }()
            fourthCharacterYOffset = -100 + CustomEasing.easeOut.back.progress(elapsed: elapsed) * (Const.fourthCharacterYOffset + 100)
        } else {
            // after animation
            fourthCharacterYOffset = Const.fourthCharacterYOffset
        }
    }

    // 5文字目
    private func animateFifthCharacter() {
        if progress < AnimateTiming.fifthCharacterAppear {
            // before animation
            fifthCharacterYOffset = -100
        } else if progress <= AnimateTiming.fifthCharacterAppear + AnimateDuration.characterAppear {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.fifthCharacterAppear) / AnimateDuration.characterAppear
                return .minimum(elapsed, 1)
            }()
            fifthCharacterYOffset = -100 + CustomEasing.easeOut.back.progress(elapsed: elapsed) * (Const.fifthCharacterYOffset + 100)
        } else {
            // after animation
            fifthCharacterYOffset = Const.fifthCharacterYOffset
        }
    }

    // タイトルトマト
    private func animateTitleTomato() {
        if progress < AnimateTiming.titleTomatoAppear {
            // before animation
            titleTomatoScale = 0
            titleTomatoOpacity = 0
        } else if progress <= AnimateTiming.titleTomatoAppear + AnimateDuration.titleTomatoAppear {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.titleTomatoAppear) / AnimateDuration.titleTomatoAppear
                return .minimum(elapsed, 1)
            }()
            titleTomatoScale = CustomEasing.easeOut.back.progress(elapsed: elapsed)
            titleTomatoOpacity = CustomEasing.easeInOut.circ.progress(elapsed: elapsed)
        } else {
            // after animation
            titleTomatoScale = 1
            titleTomatoOpacity = 1
        }
    }

    // タイトルトマト閃光
    private func animateTitleTomatoFlash() {
        if progress < AnimateTiming.titleTomatoFlashAppear {
            // before animation
            titleTomatoFlashHeight = 0
            titleTomatoFlashOffset = 0
        } else if progress <= AnimateTiming.titleTomatoFlashAppear + AnimateDuration.titleTomatoFlashAppear {
            // appear animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.titleTomatoFlashAppear) / AnimateDuration.titleTomatoFlashAppear
                return .minimum(elapsed, 1)
            }()
            titleTomatoFlashHeight = CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * Const.titleTomatoFlashMaxHeight
            titleTomatoFlashOffset = CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * Const.titleTomatoFlashOffset
        } else if progress <= AnimateTiming.titleTomatoFlashDisappear + AnimateDuration.titleTomatoFlashDisappear {
            // disappear animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.titleTomatoFlashDisappear) / AnimateDuration.titleTomatoFlashDisappear
                return .minimum(elapsed, 1)
            }()
            titleTomatoFlashHeight = Const.titleTomatoFlashMaxHeight - CustomEasing.easeInOut.circ.progress(elapsed: elapsed) * Const.titleTomatoFlashMaxHeight
            titleTomatoFlashOffset = Const.titleTomatoFlashOffset
        } else {
            // after animation
            titleTomatoFlashHeight = 0
            titleTomatoFlashOffset = Const.titleTomatoFlashOffset
        }
    }

    // タイトルボタン
    private func animateTitleStartButton() {
        if progress < AnimateTiming.titleStartButtonAppear {
            // before animation
            titleStartButtonScale = 3
            titleStartButtonOpacity = 0
        } else if progress <= AnimateTiming.titleStartButtonAppear + AnimateDuration.titleStartButtonAppear {
            // animation
            let elapsed: CGFloat = {
                let elapsed = (progress - AnimateTiming.titleStartButtonAppear) / AnimateDuration.titleStartButtonAppear
                return .minimum(elapsed, 1)
            }()
            titleStartButtonScale = 3 - CustomEasing.easeOut.back.progress(elapsed: elapsed) * 2
            titleStartButtonOpacity = CustomEasing.easeInOut.circ.progress(elapsed: elapsed)
        } else {
            // after animation
            titleStartButtonScale = 1
            titleStartButtonOpacity = 1
        }
    }

    private func reset() {
        startTime = CACurrentMediaTime()
        // 各値を初期値にする(割愛)
    }
}

さいごに

っていうのを踏まえて、もう一回成果物を見ておきましょう。
こういう動きをしている要素たちが組み合わさってこういうアニメーションが仕上がるんだ、と、紐解いていってもらえれば嬉しいなと思います。

Dec-18-2024 02-54-09.gif

と、結局まだまだアプリとしては出来上がっていない状態ですが、肝となる部分を作って、個人的には出来上がったぐらいの気持ちありますね。
そして、画像とSwiftUIのView要素だけで作って、デザインの力ってやっぱすごいなって思いました(できあがりがちゃっちい)
逆に、デザインがなくてもこの辺までならできるってところが、新たにアプリを作ろうとしている人へ勇気を与えたりすると嬉しいな〜〜〜

今回の起動画面を作るにあたって、どういうものを作るかというところを1から考えるわけですが、どういう感じにしようかなーと想像しては形にして調整して、違うな〜と思って崩してまた考え直して、みたいなことを繰り返しているこの工程がなんとも職人感あって、こういう機会がアドベントカレンダーによってできるのは良いなあと改めて思いました。

7
4
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
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?