SwiftUIのPreferenceKeyを使ったアニメーション実装

# モチベーション

SwiftUIの勉強のため、良質な記事で有名な [The SwiftUI Lab](https://swiftui-lab.com/)さんの記事を読んで＆改造してみて理解を深めつつある今日このごろだが、その中で、**PreferenceKey** なるものが紹介されていたので、これを学んでいきたいと思う。
自身のあやふやな部分を排除出来する目的で、記事にしている。
そして、SwiftUI入門者に知識を共有出来れば幸いだ。

# 環境

PC: Catalina (10.15)
Xcode: 11.1 (11A1027)
iOS: 13.1
実行環境: iPhone11 シミュレーター



# 成果物
今回の最終成果物はこちらになります。
※ソースは末尾に記載しておきます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45426/68658883-416c-d851-592a-5722379868aa.gif" width="50%">

# 輪投げアニメーションの実現

今回は、上記のアニメーションをどのように実現したかを整理しながら **PreferenceKey** の解説をすすめたいと思う。

アニメーションの特徴を整理してみる。

- 図形と図形の間をアニメーションしながら青い枠が移動する
- サイズ、形の異なる図形の間を移動する  *←これは完全オリジナル*

# では実際のコードを見ていくことにする

[チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその2（PreferenceKey編）](https://qiita.com/takaf51/items/de86221493f313ebfc3c) で書かれたコードをベースに再構築したものになる。

## 図形と図形の間をアニメーションしながら青い枠が移動する

クリックした位置に青枠を移動するためには、移動先の図形の、位置、サイズを知る必要がある。
これには、 [SwiftUIの肝となるGeometryReaderについて理解を深める](https://qiita.com/masa7351/items/0567969f93cc88d714ac) で勉強した `GeometryReader` を使うことになる。

ビビッドカラーの一つは以下のコードで実現している。

```swift
Circle()
	.fill(Color.green)
	.frame(width: 100, height: 100)
	.background(BGCircleView(idx: 0)) // この中のGeometryReaderを使い図形の位置、サイズを調べる
	.onTapGesture {
		self.isStarted = true // タップすると初期表示フラグがOFFにする
		self.activeIdx = 0 // 何番目の図形をタップしたか
		self.selectedShapeType = .circle // 図形の形はなにか
	}
```

```swift
/// 円のサイズ＆位置を調べるためのView
struct BGCircleView: View {
    let idx: Int
    var body: some View {
        GeometryReader { geometry in
            Circle()
                .fill(Color.clear)
                .preference(key: EdgePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
        }
    }
}
```

これを見て分かる通り、外枠 `GeometryReader`を使い、座標位置を取得している。**このBGCircleViewはサイズ・位置を知るためのものであり、見える必要がないのでColor.clearを適用している。**

.background + GeometryReader は非常にパワフルなものなので自由自在に扱えると吉。

見慣れない、 `geometry.frame(in: .named("myCoordination"))` が何者かを深堀りしていくことにする。

## 独自座標(myCoordination)はなにか？

早速、Debug View Hierarchy で見てみることにする。

○ 「myCoordination」座標のエリアを指定した図
画面中央の青枠の530ptがこのエリアの座標であることがわかる
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45426/86c8d1ea-1095-8755-28bc-e783751f546d.png">

○ mainView全体
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45426/0022afc8-7cf2-f499-c811-ba80da49d364.png">

こうしてみると、mainViewの一部の領域がmyCoordination座標になっていることがわかる。

ZStackの座標 == 絶対座標 となるようにコードを書き換えて
 `geometry.frame(in: .named("myCoordination"))` を `geometry.frame(in: .global)`に置き換える

```swift
/// 円の位置＆サイズを調べるための図形(見えないようにColor.clearを指定)
struct BGCircleView: View {
    let idx: Int
    var body: some View {
        GeometryReader { geometry in
            Circle()
                .fill(Color.clear)
                .preference(key: EdgePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .global))])
        }
    }
}

```

```swift

    // Body部分
    var body: some View {

        ZStack(alignment: .topLeading) {
            // ビビッドカラーの円、長方形のオブジェクト群
            variousObjects
            // onPreferenceChangeはpreferencenのデータが変わるたびにコールされるので、例えば端末が回転して Viewの構成が変わった場合、自動的に値の更新が行われます。
            .onPreferenceChange(EdgePreferenceKey.self) { preference in
                for p in preference {
                    self.rects[p.idx] = p.rect
                }
            }
            // 青枠の円、長方形
            shapeshifterView
        }
        .edgesIgnoringSafeArea(.all)  // ← 絶対座標として扱うためsafeArea部分をコンテンツ領域として扱う。
    }


    /// ビビッドカラーの円、長方形のオブジェクト群
    var variousObjects: some View {
        VStack(spacing: 20) {
            HStack {
               // (省略)
           }
           Spacer() // ← 上に詰めるため末尾にSpacer()を挿入
        }
    }
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45426/c6a66cad-8741-a4ef-d99b-76213a42c6ff.gif" width="50%">

絶対座標を使ってもアニメーションできることがわかる。

実験として、bodyやvariousObjectsを書き換えないで、`geometry.frame(in: .global)`に置き換えてもらいたい。
こうすると、青枠が図形より上に移動するはずだ。

`geometry.frame(in: .named("任意の名前"))` で指定した座標のframeを取得するのは非常に有効な技ということがわかる。

## PreferenceKeyについて

```swift
.preference(key: EdgePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
```

このビビッドカラーの図形の"myCoordination"座標系の位置＆図形の大きさ、そしてオブジェクトを一意に決めるindex番号をpreferenceDataに登録している。コンテナViewがこれら子供のViewに自由にアクセスできるのは非常に強力だ。

```swift
// PerferenceDataは、Equatableに準拠する必要がある
struct PreferenceData: Equatable {
    let idx: Int
    var rect: CGRect
}

// 親Viewが参照する値
struct EdgePreferenceKey: PreferenceKey {
    // 親Viewに参照させるエイリアス
    typealias Value = [PreferenceData]
    // PreferenceKeyに値が設定されていない場合のデフォルト値
    static var defaultValue: [PreferenceData] = []
    // 親Viewはこのreduce functionを通じて値を取得する
    static func reduce(value: inout [PreferenceData], nextValue: () -> [PreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}
```

特徴的なのは、Viewはtree構造であるが、その各々のデータにアクセスできるするため(tree構造よりフラットな配列構造にしておくほうが高速にデータアクセスできるからだと予想している)、reduce を使って配列に変換している。

## 親からReferenceを参照する

onPreferenceChangeはPreferenceのデータが変わるたびにコールされるので、
例えば端末が回転して Viewの構成が変わった場合、自動的に値の更新が行われる。

```swift
            .onPreferenceChange(EdgePreferenceKey.self) { preference in
                for p in preference {
                    self.rects[p.idx] = p.rect
                }
            }
```

## 形の異なる図形の間を移動する

これを実現するには少し時間を要した。実現方法について簡単に解説したいと思う。

タップすると@Stateの値が更新されることにより自動的に、mainのViewが再描画される。
その過程でshapeshifterView自体も再描画されることになるので、

外枠をZStackで重ねた一つのViewにしてしまいその中に長方形、円を内包しておく。(三角形やPathで記載したものも取り扱えると思う）
それぞれのタップした形の図形と一致する場合は、青色の枠として、一致しない図形は透明の枠にすることで不要な枠を隠している。

```swift
    var shapeshifterView: some View {
        ZStack {
            // 長方形の外枠
            Rectangle()
                .stroke(isRectangleShape ? Color.blue : Color.clear, lineWidth: 5)
                .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
                .offset(x: rects[activeIdx].minX , y: rects[activeIdx].minY)
                .animation(.linear(duration: isStarted && isRectangleShape ? 0.5 : 0))
            // 円の外枠
            Circle()
                .stroke(isCircleShape ? Color.blue : Color.clear, lineWidth: 5)
                .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
                .offset(x: rects[activeIdx].minX , y: rects[activeIdx].minY)
                // タップによる移動以外(回転、初期表示時)はアニメーションさせないように0としている
                .animation(.linear(duration: isStarted && isCircleShape ? 0.5 : 0))
        }
    }
```
animateで、0.5秒の間に、offsetで指定した座標位置に、frameで指定した大きさに変化する。

# ソースコード全文

```swift
/// 図形の間を青枠がアニメーションしながら移動するサンプル(PreferenceKey学習用サンプル)
struct MovingRingView: View {

    // MARK: - Enum
    
    // オブジェクトの形
    enum ShapeType: Int {
        case circle
        case rectangle
    }
    
    // MARK: - State
    
    @State private var activeIdx: Int = 0
    @State private var rects: [CGRect] = Array<CGRect>(repeating: CGRect(), count: 8)
    // TODO: 初期表示時&画面回転時に円がアニメーションをさせないようにするためのフラグ
    // 画面回転することで@Stateが初期化されるのは深堀りしたいところ。
    @State private var isStarted: Bool = false
    @State private var selectedShapeType: ShapeType = .circle

    var isRectangleShape: Bool {
        selectedShapeType.rawValue == ShapeType.rectangle.rawValue
    }

    var isCircleShape: Bool {
        selectedShapeType.rawValue == ShapeType.circle.rawValue
    }
    
    // MARK: - View
    
    /// 青外枠のView
    /// シェイプシフター(形を変える化け物)のように今選択しているオブジェクトの形になる
    /// これを擬似的に表現させるためZStackで表現していて、表示していないオブジェクトの、Colorをclearにして見えないようにしている
    var shapeshifterView: some View {
        ZStack {
            // 長方形の外枠
            Rectangle()
                .stroke(isRectangleShape ? Color.blue : Color.clear, lineWidth: 5)
                .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
                .offset(x: rects[activeIdx].minX , y: rects[activeIdx].minY)
                .animation(.linear(duration: isStarted && isRectangleShape ? 0.5 : 0))
            // 円の外枠
            Circle()
                .stroke(isCircleShape ? Color.blue : Color.clear, lineWidth: 5)
                .frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
                .offset(x: rects[activeIdx].minX , y: rects[activeIdx].minY)
                // タップによる移動以外(回転、初期表示時)はアニメーションさせないように0としている
                .animation(.linear(duration: isStarted && isCircleShape ? 0.5 : 0))
        }
    }

    /// ビビッドカラーの円、長方形のオブジェクト群
    var variousObjects: some View {
        VStack(spacing: 20) {
            HStack {
                Circle()
                    .fill(Color.green)
                    .frame(width: 100, height: 100)
                    .background(BGCircleView(idx: 0))
                    .onTapGesture {
                        self.isStarted = true
                        self.activeIdx = 0
                        self.selectedShapeType = .circle
                    }

                Circle()
                    .fill(Color.pink)
                    .frame(width: 150, height: 150)
                    .background(BGCircleView(idx: 1))
                    .onTapGesture {
                        self.isStarted = true
                        self.activeIdx = 1
                        self.selectedShapeType = .circle
                     }
            }
            
            HStack {
                Circle()
                    .fill(Color.gray)
                    .frame(width: 50, height: 50)
                    .background(BGCircleView(idx: 2))
                    .onTapGesture {
                        self.isStarted = true
                        self.activeIdx = 2
                        self.selectedShapeType = .circle
                     }
                
                Rectangle()
                    .fill(Color.yellow)
                    .frame(width: 250, height: 100)
                    .background(BGRectangleView(idx: 3))
                    .onTapGesture {
                        self.isStarted = true
                        self.activeIdx = 3
                        self.selectedShapeType = .rectangle
                     }
            }

            HStack {
                Rectangle()
                    .fill(Color.purple)
                    .frame(width: 100, height: 30)
                    .background(BGRectangleView(idx: 4))
                    .onTapGesture {
                        self.isStarted = true
                        self.activeIdx = 4
                        self.selectedShapeType = .rectangle
                     }

                Circle()
                    .fill(Color.gray)
                    .frame(width: 70, height: 70)
                    .background(BGCircleView(idx: 5))
                    .onTapGesture {
                        self.isStarted = true
                        self.activeIdx = 5
                        self.selectedShapeType = .circle
                     }
            }
            
            HStack {
                Circle()
                    .fill(Color.orange)
                    .frame(width: 150, height: 150)
                    .background(BGCircleView(idx: 6))
                    .onTapGesture {
                        self.isStarted = true
                        self.activeIdx = 6
                        self.selectedShapeType = .circle
                     }

                Rectangle()
                    .fill(Color.green)
                    .frame(width: 250, height: 100)
                    .background(BGRectangleView(idx: 7))
                    .onTapGesture {
                        self.isStarted = true
                        self.activeIdx = 7
                        self.selectedShapeType = .rectangle
                     }
            }
        }
    }
    
    // Body部分
    var body: some View {

        ZStack(alignment: .topLeading) {
            // ビビッドカラーの円、長方形のオブジェクト群
            variousObjects
            // onPreferenceChangeはpreferencenのデータが変わるたびにコールされるので、例えば端末が回転して Viewの構成が変わった場合、自動的に値の更新が行われます。
            .onPreferenceChange(EdgePreferenceKey.self) { preference in
                for p in preference {
                    self.rects[p.idx] = p.rect
                }
            }
            // 青枠の円
            shapeshifterView
        }
        .coordinateSpace(name: "myCoordination")
        
    }
    
}

// MARK: - Support View

/// 円の位置＆大きさを調べるための図形(見えないようにColor.clearを指定)
struct BGCircleView: View {
    let idx: Int
    var body: some View {
        GeometryReader { geometry in
            Circle()
                .fill(Color.clear)
                .preference(key: EdgePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
        }
    }
}

/// 長方形の位置＆大きさを調べるための図形(見えないようにColor.clearを指定)
struct BGRectangleView: View {
    let idx: Int
    var body: some View {
        GeometryReader { geometry in
            Rectangle()
                .fill(Color.clear)
                .preference(key: EdgePreferenceKey.self, value: [PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
        }
    }
}


// MARK: - PreferenceKey

// PerferenceDataは、Equatableに準拠する必要がある
struct PreferenceData: Equatable {
    let idx: Int
    var rect: CGRect
}

// 親Viewが参照する値
struct EdgePreferenceKey: PreferenceKey {
    // 親Viewに参照させるエイリアス
    typealias Value = [PreferenceData]
    // PreferenceKeyに値が設定されていない場合のデフォルト値
    static var defaultValue: [PreferenceData] = []
    // 親Viewはこのreduce functionを通じて値を取得する
    static func reduce(value: inout [PreferenceData], nextValue: () -> [PreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

// MARK: - Preview

struct MovingRingView_Previews: PreviewProvider {
    static var previews: some View {
        MovingRingView()
    }
}
```

# まとめ
PreferenceKeyは非常に協力なものだと理解した。
コンテナが各子供のViewの位置、サイズを把握していることで、子供のViewのソートや様々なアニメーションが出来そうだ。
今回はサンプルを改造するのみにとどまったが、SwiftUI-Labの記事の内容を組み合わせて有用なUIを作っていきたい。

# 参考
- [Inspecting the View Tree – Part 1: PreferenceKey](https://swiftui-lab.com/communicating-with-the-view-tree-part-1/)
- [チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその2（PreferenceKey編）](https://qiita.com/takaf51/items/de86221493f313ebfc3c)