[SwiftUI] PreferenceKeyを使用して下位Viewから上位Viewへ値を伝える

どうも、モバイルエンジニアのEtsuwoです。

SwiftUIを使ってる中で、親Viewから子Viewや孫Viewのframeへアクセスしたい場合がありました。
その際PreferenceKeyという仕組みを使用すると非常に便利だったので記事にします。

# 1. PreferenceKeyとは
PreferenceKeyは下位のViewからより上位の階層のViewへと値を渡す仕組みです。
これを使うことにより、下位Viewで起こったframeの変更などを上位Viewで取得できるようになります。
二階層以上離れていても取得することができるので、Viewを細かく作りたい場合などに便利です。

# 2. 実装してみる

今回は親、子、孫の3つのViewを作り親Viewで孫ViewのFrameを取得する場合を実装してみます。
以下のように中央の黒い長方形のFrameを表示できたら完成です。

![スクリーンショット 2022-04-11 23.43.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/383380/46bc4b7a-78e3-ec4f-f4af-9fb25038ab46.png)


PreferencyKeyを使用するには以下の3つを行う必要があります。

1. プロトコル`PreferenceKey`を継承するstructの作成
1. 下位Viewで`.preference()`などを使用して上位Viewに渡したい値を入れる
1. 上位Viewで`.onPreferenceChange()`を使用して下位Viewから渡された値を受け取る

それでは順番に見ていきましょう。

### プロトコル`PreferenceKey`を継承するstructの作成

まず`PreferenceKey`を継承するstructを作成し、値を受け渡しするKeyを作成すると共に、どんな値を受け渡しするのか決定します。
`PreferenceKey`では初期値が入る`defaultValue`と更新時に呼ばれる`reduce()`が実装必須となり、これらを実装する際に受け渡しをする値の型が決まります。
今回は、孫ViewのFrameを取得したいので、CGRect型を渡してあげましょう。

```swift:GrandChildView.swift
struct FramePreferenceKey: PreferenceKey {
    static var defaultValue: CGRect = CGRect() // 初期値が入る
    
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) { // valueが更新される時に呼ばれる
        value = nextValue()
    }
}
```

### 下位Viewで`.preference()`などを使用して上位Viewに渡したい値を入れる

作成したGrandChildViewで`.preference()`を使用してFrameを渡してあげます。
これにより、GrandChildViewより上位のViewでGrandChildViewのFrameを取得できるようになります。
```swift:GrandChildView.swift
struct GrandChildView: View {
    var body: some View {
        GeometryReader { geometry in
            Rectangle() // できる限り広がろうとする四角形
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .preference(key: FramePreferenceKey.self, value: geometry.frame(in: .global))
        }
        
    }
}
```

なお、本記事では通常のpreferenceを使用するためにGeometryReaderを使用してFrameの取得を行なっていますが、`.anchorPreference()`を使用するとGeometryReaderを使用する事なしにFrameを取得することができます。

### 上位Viewで`.onPreferenceChange()`を使用して下位Viewから渡された値を受け取る

最後に親Viewと中間の子Viewを作ります。
子Viewでは、Spacerと先ほど作成したGrandChildViewを使ってレイアウトを組んでいます。

```swift:ChildView.swift
struct ChildView: View {
    var body: some View {
        VStack {
            Spacer()
                .frame(height: 100) // テキトーな余白
            HStack {
                Spacer()
                    .frame(width: 50)
                GrandChildView()
                Spacer()
                    .frame(width: 50)
                
            }
            Spacer()
                .frame(height: 100)
        }
    }
}
```

最後に親Viewでは、孫Viewから渡されてきた値を受け取って処理をします。
`.onPreferenceChange()`でGrandChildViewのframeを受け取りTextに入れています。

```swift:ParentView.swift
struct ParentView: View {
    @State private var grandChildViewFrame: CGRect = CGRect()
    
    var body: some View {
        ZStack(alignment: .center) {
            ChildView()
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .onPreferenceChange(FramePreferenceKey.self, perform: { frame in
                    grandChildViewFrame = frame
                })
            Text(grandChildViewFrame.debugDescription)
                .foregroundColor(.white)
        }
    }
}
```

以上です。

# 参考文献
- https://developer.apple.com/documentation/swiftui/preferencekey
- https://swiftontap.com/preferencekey
- https://qiita.com/masa7351/items/4aeb4f21b9e59ddb0c08