🚥Anchor Preferences
SwiftUI ⟩ Data Flow ⟩ View Preferences ⟩
// .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 給別的視件使用,可以用下列的語法來宣告:
// ⭐️  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
Was this helpful?