チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその2（PreferenceKey編）

# はじめに
前回の[チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその１（GeometryReader編）](https://qiita.com/takaf51/items/a67db8bbc42a4c82b1f0)から引き続きチュートリアルから一歩踏み出したSwiftUIのCustom Viewを作ってみたいと思います。

今回も右記の素晴らしいブログを参考にしています。[GeometryReader to the Rescue](https://swiftui-lab.com/communicating-with-the-view-tree-part-1/)

# 今回のCustom View
円をクリックすると円型の枠がオーバーレイのアニメーションをしながらクリックした側に移動します。
![ezgif.com-resize-2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24726/16ae844b-3309-831b-b0d4-81a492d8e810.gif)

# 大雑把なしくみ
まずはViewの構造をみてみましょう。
<img width="201" alt="Screen Shot 2019-07-23 at 1.45.02 pm.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/24726/157cf377-8129-e4d9-a84d-ecc926dff839.png">
緑と赤の円は`HStack`下にあって、それと円の周りを囲むアニメーションで移動するView（以下、円周View）が`ZStack`下で重なっています。円周Viewはクリックされた円の場所にオフセットを利用して移動します。
BGViewは円Viewの`frame`（サイズおよび位置）取得のための透明なViewでそれぞれの円Viewの`background`に設定されます

そのためには、二つの円Viewの位置、サイズ情報(`frame`)を取得し、円周Viewを移動させるために、その位置情報を親Viewからアクセス出来なければなりません。またその位置情報はデバイスが回転した場合には変化するので、自動で変化時には位置情報の書き換えをする必要があります。

前回の記事を読んでいただいたはお分かりだと思いますが、`frame`の取得には`GeometryReader`を使います。その情報の共有と更新には今回のメイントピック[`PreferenceKey`](https://developer.apple.com/documentation/swiftui/view/3278633-preference)を使います。

ちなみに、`@Binding`を使って親Viewと`GeometryReader`で取得した位置、サイズ情報を共有すればシンプルなのでは？と思われるかもしれませんが、**Modifying state during view update, this will cause undefined behavior.**というエラーが出て上手くいきません。

```swift:Binding.swift
DispatchQueue.main.async {
    self.rects[self.idx] = geometry.frame(in: .named("myCoordinate"))
}
```
のようにすれば一見うまくいくように見えますが、根本的な解決には至りません。

# PreferenceKeyとは
ユーザーが作成したViewのデータ（Preference)を保持、親Viewからアクセスさせることを可能にするプロトコルです。下記を実装する必要があります。
 - `associatedtype Value`
 　保持するPreferenceのタイプ
 - `static var defaultValue: Self.Value`
 　そのPreferenceのデフォルト値
 - `static func reduce(value: inout Self.Value, nextValue: () -> Self.Value)`
 　親Viewからアクセスする時に、Preferenceを持つ複数の子ViewのPreferenceをどのようにまとめ上げるか。valueは今までの値、nextValueは次の値。
例えば、Arrayでまとめ上げるとすると、valueは今までのPreferenceを要素として持つ配列、nextValueは次の値なので、このクロージャを`value.append(contentsOf: nextValue())`とすれば全てのこのPreferenceを配列の要素としてまとめ上げ、親Viewからアクセスできることになります。

# 実装

では、早速今回のユーザーデータを作ってみましょう。Preferenceのデータとして使うにはEquatableである必要があります。中身はどの円Viewをタップしたか知るための番号(`idx`)と`frame`を保持するための`CGRect`型の`rect`です。

```swift:PreferenceData.swift
struct PreferenceData: Equatable {
    let idx: Int
    var rect: CGRect
}
```

次に独自のPreferenceKeyを実装します。上記で説明した３つを実装します。今回は複数の子Viewの`Preference`(上記で作成した`PreferenceData`タイプ）を保持したいので、配列としています。

```swift:CirclePreferenceKey.swift
struct CirclePreferenceKey: PreferenceKey {
    typealias Value = [PreferenceData]

    static var defaultValue: [PreferenceData] = []
    
    static func reduce(value: inout [PreferenceData], nextValue: () -> [PreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}
```

上記で作成したPreferenceに円Viewの`frame`を設定しています。このView円Viewの`background`で円Viewと同じ`frame`を持つ透明なViewです。
GeometryReaderを使って取得したViewの`frame`と番号(idx)を[`preference`](https://developer.apple.com/documentation/swiftui/view/3278633-preference) modifierによって先ほど作ったpreferenceDataとして設定しています。
`[PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])`
`frame`の`(in: .named("myCoordination"))`は独自の座標系を設定しています。（後述）
これでpreferenceの値の設定が終わりました。

```swift:BGView.swift
struct BGView: View {
    let idx: Int
    var body: some View {
        GeometryReader { geometry in
            Circle()
                .fill(Color.clear)
                .preference(key: CirclePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
        }
    }
}
```

読出し部分ですがその前にそれぞれの円Viewのbackgroudに上記のBGViewを設定しています。
そして読み出した値を保持するための配列を作成しています。
`@State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 2)`
次に円View達の親Viewである`HStack`にて`onPreferenceChange` modifierを使ってpreferenceのデータを上記で作成した配列に突っ込みます。`onPreferenceChange`はpreferencenのデータが変わるたびにコールされるので、例えば端末が回転して Viewの構成が変わった場合、自動的に値の更新が行われます。

```swift:CirclePreferenceView.swift
@State private var activeIdx: Int = 0
@State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 2)

HStack {
    Circle()
        .fill(Color.green)
        .frame(width: 100, height: 100)
        .background(BGView(idx: 0))
        .tapAction {
            self.activeIdx = 0
        }
        .padding()
        Circle()
            .fill(Color.pink)
            .frame(width: 150, height: 150)
            .background(BGView(idx: 1))
            .tapAction {
                self.activeIdx = 1
            }
            .padding()
    }
    .onPreferenceChange(CirclePreferenceKey.self) {  preference in
        for p in preference {
            self.rects[p.idx] = p.rect
        }
    }
```

円Viewタップ時に、上記で取得した円Viewの`frame`情報を元に円周Viewの位置、大きさを変更しアニメーションさせます。

```swift:stroke.swift
Circle()
    .stroke(Color.blue, lineWidth: 10)
    .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
    .offset(x: rects[activeIdx].minX , y: rects[activeIdx].minY)
```

# 全体のコード
詳しくは説明しませんが、座標系を統一するためにZStackに`.coordinateSpace(name: "myCoordination")`で独自の座標系を設定し、`frame`取得時に利用しています。

```swift:.swift
import SwiftUI

struct PreferenceData: Equatable {
    let idx: Int
    var rect: CGRect
}

struct CirclePreferenceKey: PreferenceKey {
    typealias Value = [PreferenceData]

    static var defaultValue: [PreferenceData] = []
    
    static func reduce(value: inout [PreferenceData], nextValue: () -> [PreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

struct CirclePreferenceView: View {
    
    @State private var activeIdx: Int = 0
    @State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 2)
    @State var isStarted:Bool = false
    
    var body: some View {

        ZStack(alignment: .topLeading) {
            HStack {
                Circle()
                    .fill(Color.green)
                    .frame(width: 100, height: 100)
                    .background(BGView(idx: 0))
                    .tapAction {
                        self.isStarted = true
                        self.activeIdx = 0
                    }
                    .padding()
                    Circle()
                        .fill(Color.pink)
                        .frame(width: 150, height: 150)
                        .background(BGView(idx: 1))
                        .tapAction {
                            self.isStarted = true
                            self.activeIdx = 1
                        }
                        .padding()
                }
                .onPreferenceChange(CirclePreferenceKey.self) {  preference in
                    for p in preference {
                        self.rects[p.idx] = p.rect
                    }
                }
            
Circle()
    .stroke(Color.blue, lineWidth: 10)
    .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
    .offset(x: rects[activeIdx].minX , y: rects[activeIdx].minY)
    .animation(.linear(duration: isStarted ? 0.5 : 0))
            
        }.coordinateSpace(name: "myCoordination")
    }
}

struct BGView: View {
    let idx: Int
    var body: some View {
        GeometryReader { geometry in
            Circle()
                .fill(Color.clear)
                .preference(key: CirclePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
        }
    }
}


#if DEBUG
struct CirclePreferenceView_Previews: PreviewProvider {
    static var previews: some View {
        CirclePreferenceView()
    }
}
#endif
```

# 最後に
PreferenceKey、ちょっと理解しづらいところもありますが、色々使えそうです。
座標系のせいか何故かLiveViewではエラーが出て表示できません。シミュレーターか実機で確認してください。
View名がContnetViewではないので、SceneDelegateのwindow.rootViewControllerの書き換えを忘れないように。（CirclePreferenceView）