🚥Anchor Preferences

SwiftUIData FlowView 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.

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) }
            }
        }
    }
}

Last updated