62
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUI の Preference の基礎

Last updated at Posted at 2021-12-11

概要

SwiftUI の Environment は View のツリーの親から子へ値を渡す仕組みです。一方で、子から親に値を渡す Preference という仕組みもあるのですが、あまり馴染みがなかったのでまとめておきます。 .anchorPreference.overlayPreferenceValue などの API についてはまた別にするとして、この記事では Preference の概念と基本的な使い方について書きます。

Environment

まずは Preference の対になる Environment について簡単にまとめておきます。 Environment の仕組みを使うと、直接の子 View だけでなく自分の子となる View ツリーのすべての View に値を渡すことができます。自作して自分の好きな値を Environment にすることも可能ですが、普段の開発では SwiftUI に組み込みのものを使うことが多いでしょう。

例えば、以下のように書くと Text のフォントは .largeTitle に従って大きく表示されます。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello")
            Text("World")
        }
        .font(.largeTitle)
    }
}

しかし、冷静に考えると .fontText ではなく VStack に設定されているのに Text に影響を与えられているのは不思議にも思えます。これは、 .font が Environment を設定しており、 VStack だけでなく、その View ツリー上の子すべてから値を取得することが可能になっているからです。

実際、上記のコードは以下のように明示的に .environment を使って書いた場合と同等です。このように、Swift では我々が意識しない形でも Environment が使われています。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello")
            Text("World")
        }
        .environment(\.font, .largeTitle)
    }
}

Preference

Preference は Environment と逆で、自分の View ツリー上の親に値を渡すための仕組みです。身近な例として .navigationTitle があります。 .navigationTitleNavigationView のための設定なのに、 NavigationView ではなくその子に modifier をつけますよね。これは .navigationTitle が Preference の仕組みを利用して子から値を渡すようになっているためです。

struct ContentView: View {
    var body: some View {
        NavigationView {
            Text("Hello")
                .navigationTitle("Title")
        }
        // .navigationTitle("Title")
        // ^ ここに書くと適用されない!
    }
}

直接の子からでなくても値を渡せるので、より NavigationView の View ツリー上の子であればどこからでも .navigationTitle は設定できます。

struct ContentView: View {
    var body: some View {
        NavigationView {
            VStack {
                Text("Hello")
                    .navigationTitle("Title")
            }
        }
    }
}

Preference を自作する

Environment と同じく Preference も自分で作ることができます。 .navigationTitle と同じような仕組みを作ってみましょう。 NavigationView に対応する View と .navigationTitle に対応する PreferenceKey が必要です。

PreferenceKey

まずは、PreferenceKey からです。PreferenceKey に準拠する struct を作ります。

struct MyContainerTitleKey: PreferenceKey {
    static var defaultValue: String = ""
    static func reduce(value: inout String, nextValue: () -> String) {
        value = nextValue()
    }
}

PreferenceKey に準拠するために実装が必要なのは、 defaultValuereduce です。PreferenceKey は associatedtype として Preference に入れる値の型である Value を持ちますが、上記の例では String を指定しています。

まず defaultValue は文字通り該当の Preference に何も値を入れなかったときに使われるデフォルト値です。今回作る Key はタイトルに相当する値を入れるためのものなので空文字にしておきます。

reduce には子 View が入れた値から親 View が受け取る値への変換処理を書きます。reduce の考え方は Array の reduce メソッドと同様です。同じ階層に複数の値が設定された場合、その回数だけ reduce が呼ばれその値を返す関数が nextValue に入っており、 value に設定したい値を代入します。上記の例ではどんどん新しい値で上書きされることになります。

Preference を利用する View

続いて、この Preference を使う側の親 View として MyContainerView を作ります。

struct MyContainerView<Content: View>: View {
    @ViewBuilder var content: () -> Content
    @State var title: String = ""
    
    var body: some View {
        VStack {
            Spacer()
            content()
                .onPreferenceChange(MyContainerTitleKey.self) {
                    title = $0
                }
            Spacer()
            
            Text(title)
                .font(.largeTitle)
                .bold()
                .frame(height: 44)
        }
    }
}

NavigationView と同じようにコンテナとして働く View で、渡された content を表示するのに加えて画面下部にタイトルを表示します。このタイトルには content 以下の子 View から Preference の MyContainerTitleKey に設定されたものを表示するため、 content に Preference 設定時に呼ばれる onPreferenceChange modifier をつけて値を取得しています。

Preference の値の設定

それでは、ここまでに準備した MyContainerViewMyContainerTitleKey を使って画面を作ってみます。

struct ContentView: View {
    var body: some View {
        MyContainerView {
            Text("Hello")
                .preference(key: MyContainerTitleKey.self, value: "My Title")
        }
    }
}

子 View で Preference を通じて設定した値がちゃんと表示されていますね。

Preference の設定には上記のように preference modifier をつけます。

.navigationTitle と同じような書き味にするためには、以下のような extension を作ってあげるだけで大丈夫です。

extension View {
    func myContainerTitle(_ title: String) -> some View {
        preference(key: MyContainerTitleKey.self, value: title)
    }
}

struct ContentView: View {
    var body: some View {
        MyContainerView {
            Text("Hello")
                .myContainerTitle("My Title")
        }
    }
}

Preference が複数設定された場合

PreferenceKey の reduce では複数の値が設定されることが想定されていました。そのときにどのような振る舞いになるのか見てみます。

struct ContentView: View {
    var body: some View {
        MyContainerView {
            VStack {
                Text("Hello")
                    .myContainerTitle("Title Hello")
                Text("World")
                    .myContainerTitle("Title World")
            }
        }
    }
}

2つ目の Text で設定した値が優先されています。これは現状の reduce が以下のように新しい値で既存の値を上書きするようになっているからです。ドキュメントには見つけられませんでしたが、複数の Preference はコード上で評価される順に並ぶようです。

struct MyContainerTitleKey: PreferenceKey {
    // ...
    static func reduce(value: inout String, nextValue: () -> String) {
        value = nextValue()
    }
}

最初に設定された値を優先するように変えてみましょう。既存の値は value に入っているので単に nextValue を使わなければ大丈夫です。

struct MyContainerTitleKey: PreferenceKey {
    // ...
    static func reduce(value: inout String, nextValue: () -> String) {
        // do nothing
    }
}

すべての値を何らかの形で使うようなことも簡単で、今回の例で言うと見つかった値をすべて連結すると以下のようになります。

struct MyContainerTitleKey: PreferenceKey {
    // ...
    static func reduce(value: inout String, nextValue: () -> String) {
        value += " \(nextValue())"
    }
}

以上のように reduce で振る舞いをコントロールできるのはあくまで同じ階層で複数の値が設定されている場合で、異なる階層から同じ Preference に値を設定すると、より親側で設定した値が優先されるようです。たとえば、以下のようにするとタイトルが3つ連結されるとのではなく VStack に設定したタイトルだけが表示されます。

struct ContentView: View {
    var body: some View {
        MyContainerView {
            VStack {
                Text("Hello")
                    .myContainerTitle("Title Hello")
                Text("World")
                    .myContainerTitle("Title World")
            }
            .myContainerTitle("Title VStack")
        }
    }
}

子の階層の Preference も使いたいというケースでは transformPreference が利用できます。詳しくは以下の記事が参考になります。

まとめ

  • Preference は View ツリー上の子から親に値を渡す仕組みで、例えば NavigationView にタイトルを設定する .navigationTitle に使われている
  • 自分で Preference を書くこともできる。 PreferenceKey を実装して子側で値を入れ、親側で onPreferenceChange で受け取る

参考

62
22
1

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
62
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?