🖌️

[SwiftUI] Modifierをもっと便利に使う

に公開

最初に

SwiftUIでは、Viewに対してModifierを付与することによって、そのレイアウトを調整したり挙動を変更してアプリを構築していきます。
そのModifierには、paddingframeforegroundStyleなどが標準で提供されていますが、ViewModifierプロトコルを使って自作することも可能です。
https://developer.apple.com/documentation/swiftui/viewmodifier

本記事では、ちょっと踏み込んだModifierの作り方をViewModifierを使ったり使わなかったりして解説していきます!

カスタムModifierを作る

今回は次の画像Viewを例にカスタムModifierの作り方を見ていきます

struct ContentView: View {
    var body: some View {
        Image(systemName: "hand.thumbsup.fill")
            .renderingMode(.template)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 50, height: 50)
            .foregroundStyle(.blue)
    }
}

上記の画像Viewには多くのModifierが設定されているようです。
もしアプリ内のあらゆる画像に対して一貫したスタイル適用をしなければいけない場合、Modifierをつけ忘れてしまったり、単純にコード量が増えたりでメンテナンス性が下がってしまうかもしれません。

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "hand.thumbsup.fill")
                .renderingMode(.template)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 50, height: 50)
                .foregroundStyle(.blue)

            Image(systemName: "pencil.circle.fill")
                .renderingMode(.template)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 50, height: 50)
                .foregroundStyle(.blue)

            Image(systemName: "person.crop.circle.fill")
                .renderingMode(.template)
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 50, height: 50)
                .foregroundStyle(.blue)
        }
    }
}

一貫したスタイルを適用する際には、それをカスタムModifierとしてまとめることが一般的に有効です。
それで実際にメンテナンス性の向上ができるか試してみましょう!

基本的なViewModifierを使ったカスタムModifierの作り方

ViewModifierを使ったカスタムModifierは次のようにして作ることができます

struct CustomImageThemeModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .aspectRatio(contentMode: .fit)
            .frame(width: 50, height: 50)
            .foregroundStyle(.blue)
    }
}

extension View {
    func customImageTheme() -> some View {
        self
            .modifier(CustomImageThemeModifier())
    }
}

struct ContentView: View {
    var body: some View {
        Image(systemName: "hand.thumbsup.fill")
            .renderingMode(.template)
            .resizable()
            .customImageTheme()
    }
}

これでaspectRatioframeforegroundStylecustomImageModifier()にまとめることができましたが、renderingModeresizableの2つはImageから生えているModifierであるため、Viewのextensionから生えているcustomImageModifier()では扱うことができませんでした。
数行分だけModifierの削減はできましたが、もう少しやりようがありそうです。

固有ViewのModifierのまとめ方

固有のViewから生えているModifierをまとめる方法はシンプルです。
先ほどViewのextensionでcustomImageTheme()を生やしたように、その固有Viewのextensionから関数を生やしてあげましょう。

extension Image {
    func customImageTheme() -> some View {
        self
            .renderingMode(.template)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(width: 50, height: 50)
            .foregroundStyle(.blue)
    }
}

struct ContentView: View {
    var body: some View {
        Image(systemName: "hand.thumbsup.fill")
            .customImageTheme()
    }
}

これでImageに適用するModifierはcustomImageTheme()の一つだけになり、可読性の問題は解決されていそうです!

しかしこのままではまだアプリ内全てのImageに対してcustomImageTheme()を付与する必要があり、「Modifierをつけ忘れてしまう」問題に関してはまだ解決できていません。

SwiftUIでは、このような共通の設定をアプリ全体に対して適用するための仕組みが確かありましたよね?
そう、Environmentです

Environmentを使ってアプリ全体にThemeを流し込む

一つの操作でアプリ全体の画像に一貫したThemeを設定できるように、次のようにRoot部分にModifierを設定することで完結する形を目指して作成していきます!

struct ContentView: View {
    var body: some View {
        HStack {
            Image(systemName: "hand.thumbsup.fill") // customImageThemeが適用される

            Image(systemName: "pencil.circle.fill") // customImageThemeが適用される

            Image(systemName: "person.crop.circle.fill") // customImageThemeが適用される
        }
        .customImageTheme()
    }
}

まずはEnvironmentValuesと、その値を受け取って使うViewを用意しましょう。

EnvironmentValuesではsome Viewを扱うことができないことに注意が必要です。
そのためAnyViewで型消しして渡してあげるようにします

extension EnvironmentValues {
    @Entry var customThemeImage: (_ image: Image) -> AnyView = { AnyView($0) }
}

View側は、生のImageがThemeを受け取れるのが理想的ですが、Environmentの値をImageに直接渡せないため、カスタムViewを作ってこれを経由して受け取れるようにします。
今回はThemedImageViewと名付けてみました

struct ThemedImageView: View {
    @Environment(\.customThemeImage) private var customThemeImage // : (Image) -> AnyView

    let systemName: String

    var body: some View {
        customThemeImage(Image(systemName: systemName))
    }
}

受け取るViewも作成できたので、Root部分から流しこむためのModifierを作りましょう!
Root部分はViewなので、今までと同じようにViewのextensionから生やします。

extension View {
    func applyCustomImageTheme() -> some View {
        self
            .environment(\.customizingImage) { image in
                let imageCustomized = image
                    .renderingMode(.template)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 50, height: 50)
                    .foregroundStyle(.blue)

                return AnyView(imageCustomized)
            }
    }
}

これにて、Envrionmentを活用して、一つの操作でアプリ全体の画像に一貫したThemeを設定できるようになりました!
全体像は↓です


struct ContentView: View {
    var body: some View {
        HStack {
            ThemedImageView(systemName: "hand.thumbsup.fill")

            ThemedImageView(systemName: "pencil.circle.fill")

            ThemedImageView(systemName: "person.crop.circle.fill")
        }
        .applyCustomImageTheme()
    }
}

extension EnvironmentValues {
    @Entry var customThemeImage: (_ image: Image) -> AnyView = { AnyView($0) }
}

struct ThemedImageView: View {
    @Environment(\.customThemeImage) private var customThemeImage // : (Image) -> AnyView

    let systemName: String

    var body: some View {
        customThemeImage(Image(systemName: systemName))
    }
}

extension View {
    func applyCustomImageTheme() -> some View {
        self
            .environment(\.customThemeImage) { image in
                let imageCustomized = image
                    .renderingMode(.template)
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(width: 50, height: 50)
                    .foregroundStyle(.blue)

                return AnyView(imageCustomized)
            }
    }
}

終わりに

基本的なカスタムModifierの作り方と、Environmentを使ったアプリ全体への流し込み方を解説しました。

実は、今回扱ったImageにおけるresizableのようなView固有のModifierでなければ、Environmentを使うにしてもEnvironmentalModifierを使うなどして、もう少し簡潔に書く事もできたかと思います。まあ今回はエッジケースの紹介ということで。。。

本記事が誰かの課題を解決したり、実装のアイデアになることがあれば幸いです。
その時にはいいねください!

Discussion