SwiftUI の Preference の基礎

## 概要

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

## Environment 

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

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

```swift
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 が使われています。

```swift
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 の仕組みを利用して子から値を渡すようになっているためです。

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

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

```swift
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 を作ります。

```swift
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` を作ります。

```swift
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` を使って画面を作ってみます。

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/16fe17a7-87e5-73f0-63c1-be97c6504ad5.png" width="35%">

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

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

- https://developer.apple.com/documentation/swiftui/form/preference(key:value:)

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

```swift
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` では複数の値が設定されることが想定されていました。そのときにどのような振る舞いになるのか見てみます。

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/c5e74ba3-2ed5-5536-a145-5329e50ab4ee.png" width="35%">

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

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/bc3a20b3-717c-99be-d39a-aadd7eaa6541.png" width="35%">


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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159260/1953eea5-d3a9-a00c-8bf8-653dc43e7b24.png" width="35%">

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

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

子の階層の Preference も使いたいというケースでは [`transformPreference`](https://developer.apple.com/documentation/swiftui/view/transformpreference(_:_:)) が利用できます。詳しくは以下の記事が参考になります。

https://tokizuoh.hatenablog.com/entry/2024/02/05/093152

## まとめ

- 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