ごあいさつ
NewsPicks iOSエンジニアはぐっです!
この記事は NewsPicks Advent Calendar 2024 の23日目の記事です。
はじめに
みなさま、覚えていますか。
あの日晴れてなかったらきっと出会えていなかった、なんてことない日常から作られたあの・・・
猫の手をぉぉぉぉぉぉおおおおお!!!!!!!
そう、トマトマトですね。
https://qiita.com/haguhoms/items/6b005f692fa21d9e4f2a
この猫の手めっちゃかわいくて、作りながらめっちゃテンション上がっていたのを昨日のように覚えています。
これ、さすがに愛着わきすぎて、そのままスマホゲームとして成り立たせて、アプリ公開までいっちゃうんじゃね?
なんなら広告とか入れて、収益化まで目指すんじゃね?
そんで売れに売れて、トマトマトを作った人から怒られたりとかもするんじゃね?(ここまでは思ってない)
そしてはや1年ですか、相変わらずのぐうたらぶりを発揮して、コードは1年前のままでした。
アドベントカレンダーという絶好の機会に、このアプリをさらに進めよう、そんなモチベーションから始まった今回の記事です。
今年やりたかったこと
ゲーム画面は、まぁ調整は全然必要やけどおおまかにはできている。
となると最低限必要なのは、
- 起動画面
- プレイヤー数選択画面
- 結果画面
まぁこの辺だろうと。
で、結果画面なんて、つまりトマトマトのゲームルールを落とし込む、地味っちゃあ地味な作業で。
それは僕じゃなくても書くだろう。
なら何をするべきか、すぐに決まりました。
ってことで選ばれたのは
起動画面
でした。
成果物
しのごの言うてんと、はよ見せんかい!!!
リスナーからお叱りを受けたので、早速成果物から見ていきます。
今回も、いらすとやからの画像と、SwiftUIのView要素だけで作る、シンプルな構成になっています。
トマトとトマトが出会って、ぶつかって、トマトマトになった瞬間、世界が広がって、ゲームが始まるというストーリーです。
これから始まるゲームの世界に引き込むための、人とゲームの最初の出会いの場なので、世界観の一歩目を作れるよう構想しました。
実装
フェーズごとに登場人物がいるので、それぞれで実装を見ていくのですが、前提として、すべてのアニメーションの作り方は同じです。
- 経過秒数
- アニメーション時間
- イージング
- 初期値と終端値
この辺りの要素を組み合わせてそれぞれのアニメーションが成り立っていて、実装方法で新しいことが何個も出てくるわけではないのでアニメーション実装なんとなく怖いみたいな人も把握しやすい記事になっていると思います。
フェーズはそれぞれ
- トマトとトマトが出会って、ぶつかって、トマトマトになる
- 世界が広がる
- ゲームが始まる
こんな感じでざっくり分けますか。
では、まずは共通部分から
共通部分
アニメーションさせるために必要なものをまず準備します。
(トマトマト自体がSwiftUI+TCA(The Composable Architecture)で作られているので、StartReducerもあるのですが、内容空っぽなので割愛します)
※前提、ダークモード対応とかもなく、iPadの横向きだけ考えて作っています。
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の詳細
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の詳細
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にしておいて、位置と透明度を変えていく感じ。
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で。
で、最後に長方形を表示して、起動画面の最後の形を作る。
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()
// また最初から開始したいとかなる場合はここで各要素を初期値にする
}
}
ゲームが始まる
最後は起動画面の締めということで、スタートボタン含め終着状態になると。
文字は一文字ずつほよほよ出す感じにして、柔らかく出す感じにしました。
で、このゲームの中心であるトマトを出現させて印象づかせて、スタートボタンを表示する。
最後は比較的落ち着いた感じに仕上げて、見る疲れが出ないようにを意識。
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()
// 各値を初期値にする(割愛)
}
}
さいごに
っていうのを踏まえて、もう一回成果物を見ておきましょう。
こういう動きをしている要素たちが組み合わさってこういうアニメーションが仕上がるんだ、と、紐解いていってもらえれば嬉しいなと思います。
と、結局まだまだアプリとしては出来上がっていない状態ですが、肝となる部分を作って、個人的には出来上がったぐらいの気持ちありますね。
そして、画像とSwiftUIのView要素だけで作って、デザインの力ってやっぱすごいなって思いました(できあがりがちゃっちい)
逆に、デザインがなくてもこの辺までならできるってところが、新たにアプリを作ろうとしている人へ勇気を与えたりすると嬉しいな〜〜〜
今回の起動画面を作るにあたって、どういうものを作るかというところを1から考えるわけですが、どういう感じにしようかなーと想像しては形にして調整して、違うな〜と思って崩してまた考え直して、みたいなことを繰り返しているこの工程がなんとも職人感あって、こういう機会がアドベントカレンダーによってできるのは良いなあと改めて思いました。