各パーツにアニメーションを付与する（SwiftUI）

こんにちは。[フエルマネー](https://apps.apple.com/us/app/%E3%83%95%E3%82%A8%E3%83%AB%E3%83%9E%E3%83%8D%E3%83%BC/id6446435231)開発者です。

[前回](https://qiita.com/yoshi-eng/items/91666637cd7cdd8edf88)はNavigationStackを使った画面遷移設計を行いました。
続いては、Viewに用いる共通パーツ（コンポーネント）を作成していきます。

そうです、ここに来てもまだメインのView構成はしません。周りからしっかりと固めていくのが最近の僕のスタイルです。

# .animation(_:value:)とは
iOS16時点で推奨されているアニメーションのためのモディファイアです。
アニメーションさせたいViewにくっつけて使用します。
![1-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3518353/54c67972-1acc-722c-4bc1-a23ae021c007.png)
まずは以下をご覧ください。
```swift:ContentView.swift
struct ContentView: View {
    @State var offset: CGFloat = 0.0
    
    var body: some View{
        VStack{
            Rectangle()
                .frame(width: 30, height: 30)
                .offset(x: offset)
            Button("ずらす"){
                offset += 50
            }
        }
    }
}
```
**結果**
<img width="250" alt="1-2.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3518353/9eadfd2b-a538-6038-3e3c-d34ccf5918ce.gif">

ボタンを押すたびに、正方形がoffsetされて動くコードですが、ここに先ほどのanimationをつけてみます。

```diff_swift:ContentView.swift
struct ContentView: View {
    @State var offset: CGFloat = 0.0
    
    var body: some View{
        VStack{
            Rectangle()
                .frame(width: 30, height: 30)
                .offset(x: offset)
+                .animation(.linear(duration: 0.4), value: offset)
            Button("ずらす"){
                offset += 50
            }
        }
    }
}
```
**結果**
<img width="250" alt="1-3.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3518353/fc573d06-86e9-a3ab-abd2-007b88b0d106.gif">

アニメーションが付与されて、滑らかに動くようになりました。
一行でアニメーションが作れるなんて、素晴らしい時代です。

```swift
.animation(.linear(duration: 0.4), value: offset)
```
このモディファイアでは、valueに入れた値の変化を監視します。今回で言うと、offsetが変化した時のViewの変化にはanimationを適用しますよという意味です。animationの種類はいくつかあります。
・linear: 一定の速度で変化する
・easeIn: 最初は遅く、だんだん早くなる
・easeOut: 最初は早く、だんだん遅くなる
・easeInOut: 遅い→早い→遅い
・default: Viewごとに決まってるらしいです

durationは、何秒かけてanimationするかです。秒単位で指定します。

# 実践！
それでは実際にフエルマネーで今回使用するアニメーションを作っていきましょう。

何度か使用するようなコンポーネントは、分けてどこかのファイルにまとめて置いとくのがいいと思います。全体の構成を表す大きなViewに画面遷移やらアニメーションやらonAppearやらをたくさん書くのは可読性が落ちますからね。

この記事では、フエルマネーコンポーネントの一つ、見出しテキストを紹介します。

**ちょっと目立つ見出しテキスト**
<img width="250" alt="1-4.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3518353/68dbe62c-4b06-9674-04b5-7c4e644d5945.gif">

この見出しテキストは、最初表示される時にアニメーションがつきます。

まずは完成形をご覧ください。
```swift:FMTitle.swift
struct FMTitle: View{
    let title: String
    @State var isAppeared: Bool = false
    
    init(_ title: String){
        self.title = title
    }
    
    var body: some View{
        VStack(spacing: 0){
            Text(title)
                .font(.title)
            Rectangle()
                .fill(Color.cyan.gradient)
                .frame(height: 3)
        }
        .overlay{
            GeometryReader{ g in
                HStack(spacing: 0){
                    Rectangle()
                        .fill(Color.black)
                        .offset(x: isAppeared ? -g.size.width / 2 : 0)
                    Rectangle()
                        .fill(Color.black)
                        .offset(x: isAppeared ? g.size.width / 2 : 0)
                }
            }
        }
        .clipped()
        .animation(.easeIn(duration: 0.6), value: isAppeared)
        .onAppear(){
            isAppeared.toggle()
        }
    }
}
```
意外と長いんですよね、、、
それでは部分ごとに説明していきます。
```swift
let title: String
@State var isAppeared: Bool = false

init(_ title: String){
    self.title = title
}
```
本Viewの定義部です。titleに入れた文字列を見出しとして表示します。
isAppearedがanimationのトリガーとなる部分です。

```swift
VStack(spacing: 0){
    Text(title)
        .font(.title)
    Rectangle()
        .fill(Color.cyan.gradient)
        .frame(height: 3)
}
```
これはアニメーション関係なく、見た目を作っているところですね。説明はあまりいらないと思います。
```swift
.overlay{
    GeometryReader{ g in
        HStack(spacing: 0){
            Rectangle()
                .fill(Color.black)
                .offset(x: isAppeared ? -g.size.width / 2 : 0)
            Rectangle()
                .fill(Color.black)
                .offset(x: isAppeared ? g.size.width / 2 : 0)
        }
    }
}
.clipped()
```
ここがアニメーションする場所です。今回は中央から見出しが見えるようにアニメーションするので、初期状態では左右からRectangleをoverlayで被せて見出しを隠します。Rectangleは背景色と同色にすることで対応しています。

今回はダークモードなのでblackにしていますが、モードに応じて自動で切り替わるようにassetで背景色を定義しておきましょう。assetでの色定義についてはまた後日、気が向いたら書きます。調べたらすぐ出ると思いますが。

ここで説明するちょいムズ要素は、以下の2つです。
・GeometryReader
・3項演算子（X ? A : B）

```swift
GeometryReader{ g in
}
```
GeometryReaderは、自身の位置やサイズを参照できる画期的なViewです。
引数（上記でいうgの部分）に自身の情報が格納されています。
自身の幅を取得したいときは、g.size.widthを参照すれば良いです。

ただ注意して欲しいのは、GeometryReaderはRectangleやSpacerのように、親Viewに対して満ち満ちに広がります。なので以下のようなコードを書いても、Textの大きさを認識してくれるわけではないということです。

```swift
GeometryReader{ g in
    Text("hoge")
}
```
この場合、g.size.widthは、Text("hoge")ではなく、あくまでGeometryの親ビューによって決まるということです。

もしあなたが、Text("hoge")のサイズを取得したいのであれば、以下のコードで解決できます。
```swift
Text("hoge")
    .overlay{
        GeometryReader{ g in
        }
    }
```
.overlayを使えば、親ビューはText("hoge")と同じサイズになります。
今回フエルマネーの見出しでも、この手法を用いています。

続いて、3項演算子。条件演算子とも言われるみたいですが、以下の形で記述します。
```swift
[条件式] ? [値A] : [値B]
```
条件式がtrueの場合は値A、falseの場合は値Bを返すということです。
if-else文で分岐すると結構かさばるので、これはすごく便利です。
swiftに限らず使われる標準の記述法です。

ではこれらを理解した上でもう一度見てみましょう。
```swift
.overlay{
    GeometryReader{ g in
        HStack(spacing: 0){
            Rectangle()
                .fill(Color.black)
                .offset(x: isAppeared ? -g.size.width / 2 : 0)
            Rectangle()
                .fill(Color.black)
                .offset(x: isAppeared ? g.size.width / 2 : 0)
        }
    }
}
.clipped()
```
まとめると、isAppearedがfalse→trueになると、左のRectangleは左に動き、右のRectangleは右に動く。動く量はTextの大きさに準じて決まるということですね。

最後についているclippedですが、これはoverlayが親ビューのTextからはみ出て表示されないように、はみ出た分は切り取るということです。ビュー全体へのちょっとした配慮ですね。

```swift
.animation(.easeIn(duration: 0.6), value: isAppeared)
.onAppear(){
    isAppeared.toggle()
}
```
そして最後、animationを実行する部分です。
isApperedが変化した時にはeaseInでアニメーションします。
.onAppear、すなわち本Viewが「表示されたとき」にisAppearedがfalse→trueになるので、animationが実行されます。

これで先ほどの見出しテキストが完成です。
<img width="250" alt="1-4.gif" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3518353/68dbe62c-4b06-9674-04b5-7c4e644d5945.gif">

**コード全体**
```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    @State var isPresented = false
    
    var body: some View{
        VStack{
            if isPresented{
                FMTitle("Fuer Money for iOS")
            }
            Button("appear"){
                isPresented.toggle()
            }
        }
    }
}

struct FMTitle: View{
    let title: String
    @State var isAppeared: Bool = false
    
    init(_ title: String){
        self.title = title
    }
    
    var body: some View{
        VStack(spacing: 0){
            Text(title)
                .font(.title)
            Rectangle()
                .fill(Color.cyan.gradient)
                .frame(height: 3)
        }
        .overlay{
            GeometryReader{ g in
                HStack(spacing: 0){
                    Rectangle()
                        .fill(Color.black)
                        .offset(x: isAppeared ? -g.size.width / 2 : 0)
                    Rectangle()
                        .fill(Color.black)
                        .offset(x: isAppeared ? g.size.width / 2 : 0)
                }
            }
        }
        .clipped()
        .animation(.easeIn(duration: 0.6), value: isAppeared)
        .onAppear(){
            isAppeared.toggle()
        }
    }
}
```