30
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2019-10-10

モチベーション

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

環境

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

成果物

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

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

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

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

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

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

チュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその2(PreferenceKey編) で書かれたコードをベースに再構築したものになる。

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

クリックした位置に青枠を移動するためには、移動先の図形の、位置、サイズを知る必要がある。
これには、 SwiftUIの肝となるGeometryReaderについて理解を深める で勉強した GeometryReader を使うことになる。

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

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 // 図形の形はなにか
	}
/// 円のサイズ&位置を調べるための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がこのエリアの座標であることがわかる

○ mainView全体

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

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

/// 円の位置&サイズを調べるための図形(見えないように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))])
        }
    }
}


    // 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()を挿入
        }
    }

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

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

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

PreferenceKeyについて

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

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

// 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の構成が変わった場合、自動的に値の更新が行われる。

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

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

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

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

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

    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で指定した大きさに変化する。

ソースコード全文

/// 図形の間を青枠がアニメーションしながら移動するサンプル(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を作っていきたい。

参考

30
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?