概要
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)
}
}
しかし、冷静に考えると .font
は Text
ではなく 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
があります。 .navigationTitle
は NavigationView
のための設定なのに、 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 に準拠するために実装が必要なのは、 defaultValue
と reduce
です。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 の値の設定
それでは、ここまでに準備した MyContainerView
と MyContainerTitleKey
を使って画面を作ってみます。
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
で受け取る
参考
- https://developer.apple.com/documentation/swiftui/preferencekey
- https://developer.apple.com/documentation/swiftui/link/onpreferencechange(_:perform:)
- https://www.objc.io/books/thinking-in-swiftui/
- https://swiftwithmajid.com/2020/01/15/the-magic-of-view-preferences-in-swiftui/
- https://tokizuoh.hatenablog.com/entry/2024/02/05/093152