モチベーション
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の一部の領域が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を作っていきたい。