SwiftUIで共通コンポーネント・アニメーションを作る

どうも、はぐっです。
本記事は、[Uzabase Advent Calendar 2021](https://qiita.com/advent-calendar/2021/uzabase) 13日目の記事です。

## はじめに

アプリを作る上で、様々な画面で使うViewに関しては共通のコンポーネントを作って、それを使い回す形にすることが多いと思います。
各画面でよく使うViewがあったとして

**デザイン面**
各画面でバラバラのサイズや色を使うより統一感を持たせた方が、アプリ全体としてまとまるし、高品質感を感じます。もしサイズ感や色が違ったボタンが存在する場合、違う見た目ってことは違う動作したり、違う意味合いがあるものなのかな？と、本来感じさせたくない感情や働かせたくない思考を働かせてしまう恐れがあります。

**開発面**
同じパーツを使い回せるなら、１つパーツを作ってそれを使い回すことで開発効率の向上が見込めます。また、そのコンポーネントを修正する際に、各画面でバラバラに作っていた場合それぞれを修正しないとですが、共通コンポーネントとなっていたらそれだけ修正すれば各画面に反映されるので、保守性も高まります。

といった感じで、共通コンポーネントを作ることはメリットが大きいです。
また、アニメーションに関しても、**このUIはこのアニメーションを付与する**、が統一されている方がわかりやすく、使い勝手が良いと思います。(特別なボタンに特別なアニメーションを付与するのは大いにありえるし効果的になりうるので良き)

共通コンポーネントを作ることはUIKitの時にもやってきたと思うのですが、SwiftUIでもやっていこう、が今回のモチベーションですね！

## 共通コンポーネントを作る

ってことで、作っていきます、と。
今回は、

- レイアウト関連
- アニメーション関連

を別個で書いていきます！

### レイアウト関連

レイアウト関連は`ButtonStyle`を新たに作る形でいきます。
まずはコードをどーん！
※今回はサイズのみ考慮してます

```swift:RectangleButtonStyle.swift
import SwiftUI

struct RectangleButtonStyle: ButtonStyle {
    let size: Size

    enum Size {
        case large
        case small

        var fontSize: CGFloat {
            switch self {
            case .large: return 14
            case .small: return 12
            }
        }

        var width: CGFloat {
            switch self {
            case .large: return 200
            case .small: return 100
            }
        }

        var height: CGFloat {
            switch self {
            case .large: return 40
            case .small: return 16
            }
        }
    }

    func makeBody(configuration: Configuration) -> some View {
        configuration.label
            .font(.system(size: size.fontSize))
            .frame(width: size.width, height: size.height)
    }
}
```

今回は、大きいボタン・小さいボタンという二種類の分け方をする想定で書きました。
それぞれのボタンについて、fontSize、width、heightが決まっているとしたときに、enumでそれぞれ指定していくと。
使うときはこんな感じです。

```swift
struct RectangleButtonStyle_Previews: PreviewProvider {
    static var previews: some View {
        VStack {
            Button(action: {
                print("hoge")
            }, label: {
                Text("Button")
            })
            .padding()
            .background(Color.white)
            .buttonStyle(RectangleButtonStyle(size: .large))

            Button(action: {
                print("hoge")
            }, label: {
                Text("Button")
            })
            .padding()
            .background(Color.white)
            .buttonStyle(RectangleButtonStyle(size: .small))
        }
        .padding()
        .background(Color.gray)
        .previewLayout(.sizeThatFits)
    }
}
```

`buttonStyle`に、今回作った`RectangleButtonStyle`を使い、sizeを指定すると。
これによって、使う側ではフォントサイズや幅・高さを指定せずに、共通コンポーネントを使用できるようになりました！

出来上がりはこんな感じです。
![スクリーンショット 2021-12-08 13.40.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81434/a6626c96-6775-c4c0-3dc9-a94bdc047053.png)

サイズ違いで同じ形のボタンが何種類もある場合に関しても、enumのcaseを増やしていけばいいので、この指定方法で大丈夫そうですね！


### アニメーション関連

アニメーションに関しても同様で、一定のパターンを用意していると開発が楽です。
今回は

- 透明度を変える
- 色を乗せてハイライトする
- 拡縮する

という3つのパターンを見ていきます。

#### 透明度を変える

透過させるっていうのは非常にありがちなパターンですね。(標準のパーツでもよくあるやつ)

出来上がりはこんな感じ

![Dec-09-2021 02-14-11.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81434/dded2626-c5c6-aae7-66c8-c97a433185d4.gif)

コードをどーん！

```swift:OpacityTapGesture.swift
import SwiftUI

struct OpacityTapGesture: ViewModifier {
    var action: () -> Void = {}

    func body(content: Content) -> some View {
        Button(action: {
            action()
        }, label: {
            content
        })
        .buttonStyle(OpacityButtonStyle())
    }
}

struct OpacityButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .opacity(configuration.isPressed ? 0.75 : 1)
            .animation(.easeOut, value: configuration.isPressed)
    }
}

extension View {
    func onTapWithOpacity(
        action: @escaping() -> Void = {}
    ) -> some View {
        modifier(OpacityTapGesture(action: action))
    }
}
```

押下されている状態だと、`configuration.isPressed`が`true`になるので、その時に`opacity`を任意の値にします。すると、押している間は透過し、外すと元に戻るという感じになりますね！
ただ、透過って、このアニメーションをどの要素に適用してもきれいに想定通りになるか、っていうとそうでもなくて。
これは直面した時に考えればいい問題だと思うんですが、透過にしたいViewの上にいろんな要素が載っていて、単に透過しちゃうと気持ち悪い感じになってしまうことがあります。よくよく考えたら、透過って微妙に使い勝手がアレなんですよね・・・。
ってことで、次のハイライトするやつを使うことも検討するのが良いと思っています！

#### 色を乗せてハイライトする

色を乗せることで、押下時に押下していることがわかるようにするイメージですね！

できあがりはこちら

![Dec-09-2021 02-15-30.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81434/ebe564d4-f9f6-1b95-953e-fe3592d4031f.gif)


コードをどーん！

```swift:OverlayTapGesture
import SwiftUI

struct OverlayTapGesture: ViewModifier {
    var overlayColor: Color
    var action: () -> Void = {}

    func body(content: Content) -> some View {
        Button(action: {
            action()
        }, label: {
            content
        })
        .buttonStyle(OverlayButtonStyle(overlayColor: overlayColor))
    }
}

struct OverlayButtonStyle: ButtonStyle {
    var overlayColor: Color

    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .overlay(
                overlayColor
                    .opacity(configuration.isPressed ? 0.4 : 0)
                    .animation(.easeOut, value: configuration.isPressed)
            )
    }
}

extension View {
    func onTapWithOverlay(
        overlayColor: Color,
        action: @escaping() -> Void = {}
    ) -> some View {
        modifier(OverlayTapGesture(overlayColor: overlayColor, action: action))
    }
}
```

これは、上に少し透明な指定色を乗せることで押下していることがわかるようにするものです。
また、このViewの背景にある色を指定することで、透過と同じような効果を得ることもできます。
こんな感じに。

![Dec-09-2021 02-15-01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81434/5c95941e-fd37-f7bc-6886-48418392dbfb.gif)

ただの透過よりは使いやすいかもしれない、これもまた時と場合によって使えるアニメーションになります。

#### 拡縮する

押した時に縮小させ、離したら拡大(元に戻る)する、というアニメーションです！

できあがりはこちら(ちょっとぎこちなく見えるけど、動画のせいです・・・！)

![Dec-09-2021 02-15-51.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81434/570b7533-3834-2533-8a6c-984c94d0068c.gif)


コードをどーん！

```swift
import SwiftUI

struct SpringTapGesture: ViewModifier {
    var action: () -> Void = {}

    func body(content: Content) -> some View {
        Button(action: {
            action()
        }, label: {
            content
        })
        .buttonStyle(SpringButtonStyle())
    }
}

struct SpringButtonStyle: ButtonStyle {
    func makeBody(configuration: Self.Configuration) -> some View {
        configuration.label
            .scaleEffect(configuration.isPressed ? 0.9 : 1)
            .animation(.interactiveSpring(
                        response: 0.4,
                        dampingFraction: 0.45,
                        blendDuration: 0
            ), value: configuration.isPressed)
    }
}

extension View {
    func onTapWithSpring(
        action: @escaping() -> Void = {}
    ) -> some View {
        modifier(SpringTapGesture(action: action))
    }
}
```

言っちゃえば、`scaleEffect`を変更させるだけなのですが、そのままサイズ変えるだけってのも味気ないので、バネ的な動きも加味しています。

```swift
.animation(.interactiveSpring(
            response: 0.4,
            dampingFraction: 0.45,
            blendDuration: 0
), value: configuration.isPressed)
```

の部分ですね！
`.interactiveSpring`を指定することで、バネの動きを再現します。
それぞれ引数として指定しているのはこんな感じ。

- response: 反応速度。小さいほど速い。
- dampingFraction: バネの動きを押し止める動き。小さいほどバネが止まりにくい。
- blendDuration: 説明しづらいので一旦割愛。詳しく知りたい方は試してみるのが良いかも。

ポップなアニメーションができあがったので、トンマナがそういうテイストのアプリならば各所に使えそうなものですね！
(`interactiveSpring`と`spring`というのがあるので、用途によって使い分けてみてください)

## おわりに

共通コンポーネントを作って、それぞれの画面に適用していく。SwiftUIでもこうしてレイアウト用やアニメーション用のパターンを定義しておくだけで開発はしやすくなること間違いなしなので、デザイナーと協力してコンポーネント・パターン作りをやっていければ良い世界になるなあと思います。

エンジニアとデザイナーがそれぞれ別の考え方で、別のやり方で仕事をするより、それぞれの仕事を多少でも理解してお互い歩み寄りながら開発を進めることが、チーム開発としてとても有意義だと思っています。
今回の共通コンポーネントの話に関しても、エンジニア側だけでそれを作ってもデザイナーに伝わっていなかったら共通コンポーネントとして成り立たなくなるし、逆も然りでデザイナーが共通コンポーネントを作っていてもエンジニア側がそれを共通コンポーネントとして開発していなければ(例えばそれぞれの画面でそれぞれ作るとか)、修正した時にルールを逸脱したパーツができあがったり、と、チームの意思疎通ができていないことでミスや無駄が発生してしまいます。

長くなりましたが、そんなことを考えているエンジニアが、SwiftUIで共通コンポーネントを作ってみた話でした！