🚥Anchor Preferences
SwiftUI ⟩ Data Flow ⟩ View Preferences ⟩
An anchor is a wrapper around a value (e.g., a point) that can be resolved inside the coordinate system of a different view somewhere else in the view hierarchy.
.onPreferenceChange has the requirement that the value conforms to Equatable.
Anchor does not conform to Equatable, use .overlayPreference or .backgroundPreference instead. 👉 Thinking in SwiftUI
The main goal of anchor preferences is to pass layout data like bounds, center coordinates, etc. to its parent view. 👉 Anchor<T>
When using the Anchor<Value> as an index to the GeometryProxy, you get the represented CGRect or CGPoint value. And as a plus, you get it already translated to the coordinate space of the GeometryReader view.
// .anchorPreference(key:value:transform:)
func anchorPreference<A, K: PreferenceKey>(
key _: K.Type = K.self,
value : Anchor<A>.Source,
transform: @escaping (Anchor<A>) -> K.Value
) -> some View
SwiftUI ⟩
View Layout & Presentation ⟩ OutlineGroup ⟩ ViewModifiers ⟩
use GeometryReader to solve an Anchor<T> in the coordinate system of another view.
selected button with underline - get bounds of selected button.
Examples
import SwiftUI
/// 🅿️ HasDefaultValue
public protocol HasDefaultValue {
static var defaultValue: Self { get }
}
// ------------------------------------------
// `HasDefaultValue` conforming types
// ------------------------------------------
/// 🌀 CGSize
extension CGSize: HasDefaultValue {
public static var defaultValue: Self { .zero }
}
/// 🌀 CGRect
extension CGRect: HasDefaultValue {
public static var defaultValue: Self { .zero }
}
// -----------------------
// ViewPreference
// -----------------------
/// 🔸 ViewPreference
public enum ViewPreference {
// 將有關 bounds anchor 的資訊放到 view extension methods 的參數中,
// 盡量讓 method name 不要太長。
public enum BoundsAnchorType {
case first
case last
}
/// ⭐ ViewPreference type aliases
public typealias Size = ViewPreference.First<CGSize>
public typealias FirstBoundsAnchor = ViewPreference.FirstNonNil<Anchor<CGRect>>
public typealias LastBoundsAnchor = ViewPreference.LastNonNil<Anchor<CGRect>>
/// 🔸 ViewPreference.First
public enum First<T: HasDefaultValue>: PreferenceKey {
public typealias Value = T
public static var defaultValue: Value { Value.defaultValue }
public static func reduce(value: inout Value, nextValue: () -> Value) {
// ⭐ ignore all values other than the first
}
}
/// 🔸 ViewPreference.FirstNonNil<T>
public enum FirstNonNil<T>: PreferenceKey {
public typealias Value = T?
public static var defaultValue: Value { nil }
public static func reduce(value: inout Value, nextValue: () -> Value) {
value = value ?? nextValue() // nil or first non-nil value
}
}
/// 🔸 ViewPreference.LastNonNil<T>
public enum LastNonNil<T>: PreferenceKey {
public typealias Value = T?
public static var defaultValue: Value { nil }
public static func reduce(value: inout Value, nextValue: () -> Value) {
value = nextValue() ?? value // nil or last non-nil value
}
}
}
// -----------------------
// View extensions
// -----------------------
/// 🌀 CGRect + ViewPreference
extension View {
/// ⭐ usage: `view.actOnSelfSize { size in ... }`
public func actOnSelfSize(
action: @escaping (CGSize) -> Void
) -> some View
{
// 一般的 view 並不知道自己的 size 資訊,但使用:
// `.background(GeometryReader{ geo in Color.clear })`
// 就可以在不影響自己的佈局與外觀下,透過 `geo` (GeometryProxy) 物件
// 得知自己的 size (geo.size),然後用 .preference() 回報上層。
self.background( GeometryReader { geo in
Color.clear
// ⭐ 下層回報自己的 size
.preference(key: ViewPreference.Size.self, value: geo.size)
})
// ⭐ 上層得知 size,並用 `action` 處理。
.onPreferenceChange(ViewPreference.Size.self, perform: action)
}
// ⭐ local abbreviations
typealias FBA = ViewPreference.FirstBoundsAnchor
typealias LBA = ViewPreference.LastBoundsAnchor
// ---------------------------------
// 下層回報資料的「方法命名原則」:
// 1. 如果只收集其中一個,用:report...()
// 2. 如果收集很多個,就用:collect...()
// ----------------------------------
/// ⭐ report bounds anchor (with anchor type)
@ViewBuilder public func report(
bounds anchorType: ViewPreference.BoundsAnchorType
) -> some View {
switch anchorType {
case .first:
// ⭐ 回報 anchor preference
anchorPreference(key: FBA.self, value: .bounds) { $0 }
// ╰─ 1 ──╯ ╰──2──╯ ╰─3──╯
// 1. 要回報的 anchor preference (K = FirstBoundsAnchor)
// 2. 要回報的 anchor 種類 (.bounds)
// 3. 將 Anchor<A> 轉為要回報的資料: @escaping (Anchor<A>) -> K.Value
// 如果 Anchor<A> 本身就是要回報的資料 (K.Value),直接使用 `$0` 就可以。
case .last:
anchorPreference(key: LBA.self, value: .bounds) { $0 }
}
}
/// ⭐ report bounds anchor
public func reportBounds() -> some View {
// ⭐ 回報 anchor preference
anchorPreference(key: FBA.self, value: .bounds) { $0 }
// ╰─ 1 ──╯ ╰──2──╯ ╰─3──╯
// 1. 要回報的 anchor preference (K = FirstBoundsAnchor)
// 2. 要回報的 anchor 種類 (.bounds)
// 3. 將 Anchor<A> 轉為要回報的資料: @escaping (Anchor<A>) -> K.Value
// 如果 Anchor<A> 本身就是要回報的資料 (K.Value),直接使用 `$0` 就可以。
}
/// ⭐ background by reported bounds (with anchor type)
@ViewBuilder public func background<V: View>(
bounds anchorType: ViewPreference.BoundsAnchorType,
@ViewBuilder content: @escaping (CGRect) -> V
) -> some View
{
switch anchorType {
case .first:
backgroundPreferenceValue(FBA.self) { anchor in
GeometryReader { geo in
if let anchor = anchor { content(geo[anchor]) }
}
}
case .last:
backgroundPreferenceValue(LBA.self) { anchor in
GeometryReader { geo in
if let anchor = anchor { content(geo[anchor]) }
}
}
}
}
/// ⭐ background by reported bounds
public func backgroundByBounds<V: View>(
@ViewBuilder content: @escaping (CGRect) -> V
) -> some View
{
backgroundPreferenceValue(FBA.self) { anchor in
GeometryReader { geo in
if let anchor = anchor { content(geo[anchor]) }
}
}
}
/// ⭐ act on bounds reported
@ViewBuilder public func actOnBounds(
_ anchorType: ViewPreference.BoundsAnchorType,
action: @escaping (Anchor<CGRect>) -> Void
) -> some View
{
switch anchorType {
case .first:
onPreferenceChange(FBA.self) { anchor in
if let anchor = anchor { action(anchor) }
}
case .last:
onPreferenceChange(LBA.self) { anchor in
if let anchor = anchor { action(anchor) }
}
}
}
}
struct ContentView: View {
@State private var isFirst = true
// ⭐ bounds anchor type
var anchorType: ViewPreference.BoundsAnchorType {
isFirst ? .first : .last
}
var body: some View {
// Toggle
Toggle("First/Last Bounds Anchor", isOn: $isFirst)
.padding()
.background(Color.gray)
// ZStack
ZStack {
Color.red
VStack {
Text("Hello")
.padding()
.border(Color.black)
.report(bounds: anchorType) // ⭐ report bounds anchor
Text("Wonderful")
Text("World")
.report(bounds: anchorType) // ⭐ report bounds anchor
}.background(bounds: anchorType) { bounds in // ⭐ background with bounds
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.orange)
.frame(
width: bounds.width/3,
height: bounds.height
)
.offset(x: bounds.minX, y: bounds.minY)
Rectangle()
.fill(Color.purple)
.frame(
width: bounds.width * 2 / 3,
height: bounds.height)
.offset(
x: bounds.width/3 + bounds.minX,
y: bounds.minY)
}
}.actOnBounds(anchorType){ anchor in // ⭐ act on reported bounds
print(anchor)
}
}// ZStack
}// body
}// ContentView
如果子視件 childView 要公開它的 frame 給別的視件使用,可以用下列的語法來宣告:
以下假設 typealias FrameAnchors = AllValues<Anchor<CGRect>>
👉 關於 AllValues<T>,請參閱: PreferenceKey。
// ⭐️ 1. 將自己的 frame (用 value: .bounds 指定) 登記到 FrameAnchors 中
// 2. $0 的類型是 Anchor<CGRect>
// 3. [$0] 是為了配合 FrameAnchors.Value 的類型:[Anchor<CGRect>]
childView.anchorPreference(key: FrameAnchors.self, value: .bounds) { [$0] }
當母視件要用這些 FrameAnchors 來做佈局時,可以用:
parentView
// ⭐️ 把公開的 FrameAnchors 拿來用 (配合:GeometryProxy)
.overlayPreferenceValue(FrameAnchors.self) { (anchors) in
GeometryReader { geo in
// 用 GeometryProxy 的 subscript 語法:
// geo[anchors[i]]
// 取出第 i 個 Anchor<CGRect>
// 在這個座標系統中的 frame (CGRect),
// 然後做相關的佈局。
}
}
這樣做的好處是:我們不需要在 parentView 身上另行定義 CoordinateSpace。
Last updated