モチベーション
Inspecting the View Tree – Part 2: AnchorPreferences を元に、
PreferenceKey と PreferenceKey + Anchor の違いを整理する目的で記事を作成した。
オリジナルの記事が簡潔に正確に書かれているので、本記事を読んだ方は是非そちらも訪れてみてください。
また、本記事は GeometryReader
および Preference
を知っている前提に説明を端折っているため、理解されていない方はリンクを貼っておきますので以下の記事も参照ください。
Let's start
上、下で Preference
と AnchorPreference
を並びて比較していく。
PreferenceData
// Preference
private struct MyTextPreferenceData: Equatable {
let viewIdx: Int
let rect: CGRect
}
// AnchorPreference
struct MyTextPreferenceDataAnchor {
let viewIdx: Int
let bounds: Anchor<CGRect>
}
この Anchor<T>
の T
には CGRect
と CGPoint
を受け取ることが可能である。
タップされるView ( January, February ...)
BackgroundにViewを追加 + 追加されるViewの中でGeometryReaderを使うことで、対象のViewの座標(CGRect)をPreferenceによって親から参照できるようにしている。CGRectは、カスタム座標(ここでは、myZstack
)を指定する。
そして、座標取得用のView自体は見える必要がないので、Color.clear 指定で透明にしている。←このテクニックは頻繁に使うので覚えておくと良い。
// Preference
private struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
.background(MyPreferenceViewSetter(idx: idx))
.onTapGesture { self.activeMonth = self.idx } // @Bindingを通じて親Viewの@Stateの値を書き換えて更新を走らせている。
}
}
private struct MyPreferenceViewSetter: View {
let idx: Int
var body: some View {
GeometryReader { geometry in
Rectangle()
.fill(Color.clear)
.preference(key: MyTextPreferenceKey.self,
value: [MyTextPreferenceData(viewIdx: self.idx, rect: geometry.frame(in: .named("myZstack")))])
}
}
}
続いて、 AnchorPreference
を見ていく。こちらはすごくスッキリしている。
Backgroundの中にViewを追加する必要も、 GeometryReader
処理も、 カスタム座標(ここでは、myZstack
)の設定も行っていない。
これらがない代わりに、 anchorPreference
が追加されている。
この1行を書くことで、CGRectとして親Viewから座標・サイズの取得が可能になる。第2引数を value: .bounds
と指定することでCGRectの値が取得できる。
// AnchorPreference
private struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
.anchorPreference(key: MyTextPreferenceKey.self, value: .bounds, transform: { [MyTextPreferenceDataAnchor(viewIdx: self.idx, bounds: $0)] })
.onTapGesture { self.activeMonth = self.idx }
}
}
枠表示
緑の枠を表示している処理を見ていく。
Preferenceで記述する場合は以下のようにして表示している。
- Body直下にZStackを全体に囲んでいて、その中で、緑枠 と MonthlyViewを配置する
- レイアウトに変化があった場合に呼ばれるこのメソッド(onPreferenceChange)内で、子Viewの座標を rectsプロパティに登録する
- ZStackに
coordinateSpace(name: "myZstack")
を指定しておりこの範囲を独自の座標系として設定する - 緑の枠のViewはoffset、frameで上記でrectsに指定した値を元に描画する
// Preference
var body: some View {
ZStack(alignment: .topLeading) {
// 緑の枠
RoundedRectangle(cornerRadius: 15).stroke(lineWidth: 3.0).foregroundColor(Color.green)
.frame(width: rects[activeIdx].size.width, height: rects[activeIdx].size.height)
.offset(x: rects[activeIdx].minX, y: rects[activeIdx].minY)
.animation(.easeInOut(duration: 1.0))
VStack {
// MonthView表示エリア
....
}.onPreferenceChange(MyTextPreferenceKey.self) { preferences in
// 画面初期表示時、画面回転時、画面のレイアウトが変わった時に呼ばれる
// ここでrectsにMonthView郡の座標・大きさをrectsに登録
for p in preferences {
self.rects[p.viewIdx] = p.rect
}
}
}.coordinateSpace(name: "myZstack")
}
ZStackは 下に書いたものが優先されるので、
枠を優先させたい場合は、MonthView表示エリア の下に移動する。(この例では枠が図形の前後どちらにあっても大差ないと判断してZStackの並び順を意識していない気がしている。)
次に、Anchorを見ていくことにする。
// AnchorPreference
var body: some View {
VStack {
...
}.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
return GeometryReader { geometry in
ZStack(alignment: .topLeading) {
self.createBorder(geometry, preferences)
HStack { Spacer() } // makes the ZStack to expand horizontally
VStack { Spacer() } // makes the ZStack to expand vertically
}.frame(alignment: .topLeading)
}
}
}
Body直下にZStackは存在しておらず、 preference
の代わりに、 backgroundPreferenceValue
を記述する。
この backgroundPreferenceValue
はMonthViewをパラメータとして受け取るIFとなっている。
Preferenceは、自身の座標を取得するのに Backgroundの中で、 GeometryReader を指定して取得していたが、ここでは、 backgroundPreferenceValue であり、 この 「background」がキーとなると記憶すると良さそうだ。
ちょっと横道に反れるが
HStack { Spacer() } // makes the ZStack to expand horizontally
VStack { Spacer() } // makes the ZStack to expand vertically
上記のように書くことで、はZStackを画面全体(NavigationAreaは含まない)に広げることが出来る。
func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceDataAnchor]) -> some View {
let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
let bounds = p != nil ? geometry[p!.bounds] : .zero
return RoundedRectangle(cornerRadius: 15)
.stroke(lineWidth: 3.0)
.foregroundColor(Color.green)
.frame(width: bounds.size.width, height: bounds.size.height)
.fixedSize()
.offset(x: bounds.minX, y: bounds.minY)
.animation(.easeInOut(duration: 1.0))
}
ここでは、backgroundPreferenceValue
は すべての MonthView
が引数として渡ってくるので、その中で、選択した図形のindexと一致しないものをフィルターして、一致したものを緑枠を表示するようにしている。
let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
ソース全体(AnchorReference)
struct ContentView : View {
@State private var activeIdx: Int = 0
var body: some View {
VStack {
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "January", idx: 0)
MonthView(activeMonth: $activeIdx, label: "February", idx: 1)
MonthView(activeMonth: $activeIdx, label: "March", idx: 2)
MonthView(activeMonth: $activeIdx, label: "April", idx: 3)
}
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "May", idx: 4)
MonthView(activeMonth: $activeIdx, label: "June", idx: 5)
MonthView(activeMonth: $activeIdx, label: "July", idx: 6)
MonthView(activeMonth: $activeIdx, label: "August", idx: 7)
}
Spacer()
HStack {
MonthView(activeMonth: $activeIdx, label: "September", idx: 8)
MonthView(activeMonth: $activeIdx, label: "October", idx: 9)
MonthView(activeMonth: $activeIdx, label: "November", idx: 10)
MonthView(activeMonth: $activeIdx, label: "December", idx: 11)
}
Spacer()
}.backgroundPreferenceValue(MyTextPreferenceKey.self) { preferences in
return GeometryReader { geometry in
ZStack(alignment: .topLeading) {
self.createBorder(geometry, preferences)
HStack { Spacer() } // makes the ZStack to expand horizontally
VStack { Spacer() } // makes the ZStack to expand vertically
}.frame(alignment: .topLeading)
}
}
}
// CGRect
func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceDataAnchor]) -> some View {
let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
let bounds = p != nil ? geometry[p!.bounds] : .zero
return RoundedRectangle(cornerRadius: 15)
.stroke(lineWidth: 3.0)
.foregroundColor(Color.green)
.frame(width: bounds.size.width, height: bounds.size.height)
.fixedSize()
.offset(x: bounds.minX, y: bounds.minY)
.animation(.easeInOut(duration: 1.0))
}
}
// CGRect
struct MyTextPreferenceDataAnchor {
let viewIdx: Int
let bounds: Anchor<CGRect>
}
private struct MyTextPreferenceKey: PreferenceKey {
typealias Value = [MyTextPreferenceDataAnchor]
static var defaultValue: [MyTextPreferenceDataAnchor] = []
static func reduce(value: inout [MyTextPreferenceDataAnchor], nextValue: () -> [MyTextPreferenceDataAnchor]) {
value.append(contentsOf: nextValue())
}
}
private struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
.anchorPreference(key: MyTextPreferenceKey.self, value: .bounds, transform: { [MyTextPreferenceDataAnchor(viewIdx: self.idx, bounds: $0)] })
.onTapGesture { self.activeMonth = self.idx }
}
}
Anchor< CGPoint >の例を見ていく
上記ではAnchor< CGRect > の例であったので、筆者がより複雑な2つのAnchorを使った例を説明するために記載している。
ここでは、スマートなのは1つのAnchorで表現する方法だが見ていくことにする。
struct MyTextPreferenceData {
let viewIdx: Int
var topLeading: Anchor<CGPoint>? = nil
var bottomTrailing: Anchor<CGPoint>? = nil
}
Optionalにしているのは、初期化時に、topLeadingとbottomTrailingを同時に行わないようにしているためである。
struct MonthView: View {
@Binding var activeMonth: Int
let label: String
let idx: Int
var body: some View {
Text(label)
.padding(10)
// topLeadingをセット
.anchorPreference(key: MyTextPreferenceKey.self, value: .topLeading, transform: { [MyTextPreferenceData(viewIdx: self.idx, topLeading: $0)] })
// bottomTrailingに値をセット
.transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in
value[0].bottomTrailing = anchor
})
.onTapGesture { self.activeMonth = self.idx }
}
}
topLeadingに値を設定
.anchorPreference(key: MyTextPreferenceKey.self, value: .topLeading, transform: { [MyTextPreferenceData(viewIdx: self.idx, topLeading: $0)] })
定義を見ると、
@inlinable public func anchorPreference<A, K>(key _: K.Type = K.self, value: Anchor<A>.Source, transform: @escaping (Anchor<A>) -> K.Value) -> some View where K : PreferenceKey
無名引数とreturnを省略しなければ以下のように記述できる。
.anchorPreference(key: MyTextPreferenceKey.self,
value: .topLeading,
transform: { anchor in
return [MyTextPreferenceDataAnchor(viewIdx: self.idx, topLeading: anchor)]
})
続いて、bottomTrailingをセットしている以下を見るが、
ここでは、先の anchorPreference
の処理で、 MyTextPreferenceDataAnchor で作成したstructに対して、 bottomTrailingを追加登録しているが
.transformAnchorPreference(key: MyTextPreferenceKey.self, value: .bottomTrailing, transform: { ( value: inout [MyTextPreferenceData], anchor: Anchor<CGPoint>) in
value[0].bottomTrailing = anchor
})
定義を見ると、以下のようになっており、 inout 値型の参照渡し
により引数に渡したものを変更できるようにしており、
この例では、 [MyTextPreferenceData]
に対してbottomTrailing に anchorの値を設定している。
補足しておくと、この例では、 value[0]
としているのは、同時に選択されることはなく、単一のものであるため。
@inlinable public func transformAnchorPreference<A, K>(key _: K.Type = K.self, value: Anchor<A>.Source, transform: @escaping (inout K.Value, Anchor<A>) -> Void) -> some View where K : PreferenceKey
枠を表示している部分はCGRectからCGPointに変わったため多少変わっているがほとんど同じである。
func createBorder(_ geometry: GeometryProxy, _ preferences: [MyTextPreferenceDataAnchor]) -> some View {
let p = preferences.first(where: { $0.viewIdx == self.activeIdx })
// 下記の4行が異なる
let aTopLeading = p?.topLeading
let aBottomTrailing = p?.bottomTrailing
let topLeading = aTopLeading != nil ? geometry[aTopLeading!] : .zero
let bottomTrailing = aBottomTrailing != nil ? geometry[aBottomTrailing!] : .zero
return RoundedRectangle(cornerRadius: 15)
.stroke(lineWidth: 3.0)
.foregroundColor(Color.green)
.frame(width: bottomTrailing.x - topLeading.x, height: bottomTrailing.y - topLeading.y) // ここが変わっている
.fixedSize()
.offset(x: topLeading.x, y: topLeading.y) // ここが変わっている
.animation(.easeInOut(duration: 1.0))
}
まとめ
Referenceスゴイ!!と思っていたが、Anchor Referenceに置き換えることでより簡潔にコードが書けるようになることを学んだ。
SwiftUI-Labさんはまだ数日分記事があるので引き続き読み込んでいきたい。