はじめに
前回のチュートリアルから一歩踏み出したSwiftUIのCustom Viewの作り方ーその1(GeometryReader編)から引き続きチュートリアルから一歩踏み出したSwiftUIのCustom Viewを作ってみたいと思います。
今回も右記の素晴らしいブログを参考にしています。GeometryReader to the Rescue
今回のCustom View
円をクリックすると円型の枠がオーバーレイのアニメーションをしながらクリックした側に移動します。
大雑把なしくみ
まずはViewの構造をみてみましょう。
緑と赤の円はHStack
下にあって、それと円の周りを囲むアニメーションで移動するView(以下、円周View)がZStack
下で重なっています。円周Viewはクリックされた円の場所にオフセットを利用して移動します。
BGViewは円Viewのframe
(サイズおよび位置)取得のための透明なViewでそれぞれの円Viewのbackground
に設定されます
そのためには、二つの円Viewの位置、サイズ情報(frame
)を取得し、円周Viewを移動させるために、その位置情報を親Viewからアクセス出来なければなりません。またその位置情報はデバイスが回転した場合には変化するので、自動で変化時には位置情報の書き換えをする必要があります。
前回の記事を読んでいただいたはお分かりだと思いますが、frame
の取得にはGeometryReader
を使います。その情報の共有と更新には今回のメイントピックPreferenceKey
を使います。
ちなみに、@Binding
を使って親ViewとGeometryReader
で取得した位置、サイズ情報を共有すればシンプルなのでは?と思われるかもしれませんが、**Modifying state during view update, this will cause undefined behavior.**というエラーが出て上手くいきません。
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
です。
struct PreferenceData: Equatable {
let idx: Int
var rect: CGRect
}
次に独自のPreferenceKeyを実装します。上記で説明した3つを実装します。今回は複数の子ViewのPreference
(上記で作成したPreferenceData
タイプ)を保持したいので、配列としています。
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
modifierによって先ほど作ったpreferenceDataとして設定しています。
[PreferenceData(idx: self.idx, rect: geometry.frame(in: .named("myCoordination")))])
frame
の(in: .named("myCoordination"))
は独自の座標系を設定しています。(後述)
これでpreferenceの値の設定が終わりました。
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の構成が変わった場合、自動的に値の更新が行われます。
@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の位置、大きさを変更しアニメーションさせます。
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
取得時に利用しています。
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)