SwiftUI

Preferences

深入理解 SwiftUI 中的 Preferences 机制,学习如何通过偏好设置将配置信息从子视图传递到容器视图。

SwiftUI 的 Preferences 机制允许子视图向其容器视图传递配置信息。与 Environment 从容器向子视图传递数据相反,Preferences 实现了从子视图向上的数据流动。

核心概念

Environment 用于配置视图的子视图,而 Preferences 用于从子视图向容器发送配置信息。与从一个容器流向多个子视图的配置信息不同,单个容器需要协调来自多个子视图的潜在冲突的偏好设置。

当使用 PreferenceKey 协议定义自定义偏好时,你需要指定如何合并来自多个子视图的偏好。然后可以使用 preference(key:value:) 视图修饰符在视图上设置偏好值。许多内置修饰符,如 navigationTitle(_:),都依赖偏好设置向其容器发送配置信息。

数据流向对比

// Environment: 容器 → 子视图
ParentView()
    .environment(\.colorScheme, .dark)

// Preferences: 子视图 → 容器
ChildView()
    .preference(key: MyPreferenceKey.self, value: someValue)

PreferenceKey 协议

PreferenceKey 协议定义了一个命名的偏好值类型,包含默认值和合并多个值的逻辑。

协议要求

protocol PreferenceKey {
    associatedtype Value
    
    static var defaultValue: Value { get }
    
    static func reduce(value: inout Value, nextValue: () -> Value)
}

定义自定义偏好

struct MaxHeightPreferenceKey: PreferenceKey {
    static var defaultValue: CGFloat = 0
    
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

在这个例子中,reduce 方法选择最大的高度值,这样容器就能知道所有子视图中最高的那个。

复杂类型的偏好

struct ViewSizePreferenceKey: PreferenceKey {
    static var defaultValue: [String: CGSize] = [:]
    
    static func reduce(value: inout [String: CGSize], nextValue: () -> [String: CGSize]) {
        value.merge(nextValue()) { _, new in new }
    }
}

这个偏好键收集多个视图的尺寸信息,使用字典存储每个视图的标识符和尺寸。

设置偏好值

preference(key:value:)

设置指定偏好键的值。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("标题")
                .preference(key: MaxHeightPreferenceKey.self, value: 44)
            
            Text("副标题")
                .preference(key: MaxHeightPreferenceKey.self, value: 32)
        }
    }
}

当多个子视图设置相同的偏好键时,SwiftUI 会调用 reduce 方法合并这些值。

transformPreference(::)

转换现有的偏好值,而不是直接设置新值。

struct ItemCountView: View {
    var body: some View {
        VStack {
            ForEach(items) { item in
                ItemRow(item: item)
                    .transformPreference(CountPreferenceKey.self) { count in
                        count += 1
                    }
            }
        }
    }
}

transformPreference 接收当前值并允许你修改它,这在累积值时特别有用。

实际应用:统计子视图数量

struct CountPreferenceKey: PreferenceKey {
    static var defaultValue: Int = 0
    
    static func reduce(value: inout Int, nextValue: () -> Int) {
        value += nextValue()
    }
}

struct CountingContainer: View {
    @State private var itemCount = 0
    
    var body: some View {
        VStack {
            Text("共有 \(itemCount) 个项目")
            
            ItemList()
                .onPreferenceChange(CountPreferenceKey.self) { count in
                    itemCount = count
                }
        }
    }
}

struct ItemList: View {
    var body: some View {
        VStack {
            ItemView().preference(key: CountPreferenceKey.self, value: 1)
            ItemView().preference(key: CountPreferenceKey.self, value: 1)
            ItemView().preference(key: CountPreferenceKey.self, value: 1)
        }
    }
}

响应偏好变化

onPreferenceChange(_:perform:)

当指定偏好键的值发生变化时执行操作。

struct ParentView: View {
    @State private var maxHeight: CGFloat = 0
    
    var body: some View {
        VStack {
            Text("最大高度: \(maxHeight)")
            
            ChildViews()
                .onPreferenceChange(MaxHeightPreferenceKey.self) { height in
                    maxHeight = height
                }
        }
    }
}

这个修饰符通常应用在容器视图上,用于接收和处理来自子视图的偏好值。

实时更新示例

struct DynamicHeightView: View {
    @State private var contentHeight: CGFloat = 0
    
    var body: some View {
        ScrollView {
            VStack {
                DynamicContent()
                    .background(
                        GeometryReader { geometry in
                            Color.clear
                                .preference(
                                    key: MaxHeightPreferenceKey.self,
                                    value: geometry.size.height
                                )
                        }
                    )
            }
        }
        .onPreferenceChange(MaxHeightPreferenceKey.self) { height in
            contentHeight = height
        }
        .overlay(alignment: .bottom) {
            Text("内容高度: \(Int(contentHeight))")
                .padding()
                .background(.ultraThinMaterial)
        }
    }
}

基于几何的偏好

anchorPreference(key:value:transform:)

设置基于几何值的偏好,允许读取者将几何信息转换到其本地坐标空间。

struct AnchorPreferenceKey: PreferenceKey {
    static var defaultValue: [Anchor<CGRect>] = []
    
    static func reduce(value: inout [Anchor<CGRect>], nextValue: () -> [Anchor<CGRect>]) {
        value.append(contentsOf: nextValue())
    }
}

struct HighlightableView: View {
    var body: some View {
        VStack {
            Text("项目 1")
                .anchorPreference(
                    key: AnchorPreferenceKey.self,
                    value: .bounds
                ) { anchor in
                    [anchor]
                }
            
            Text("项目 2")
                .anchorPreference(
                    key: AnchorPreferenceKey.self,
                    value: .bounds
                ) { anchor in
                    [anchor]
                }
        }
    }
}

Anchor 类型允许你捕获视图的几何信息(如边界、中心点),并在不同的坐标空间中使用它。

transformAnchorPreference(key:value:transform:)

转换现有的锚点偏好值。

struct CollectingView: View {
    var body: some View {
        VStack {
            ForEach(items) { item in
                ItemView(item: item)
                    .transformAnchorPreference(
                        key: AnchorPreferenceKey.self,
                        value: .bounds
                    ) { anchors, anchor in
                        anchors.append(anchor)
                    }
            }
        }
    }
}

实际应用:高亮效果

struct HighlightKey: PreferenceKey {
    static var defaultValue: Anchor<CGRect>?
    
    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = value ?? nextValue()
    }
}

struct HighlightDemo: View {
    @State private var selectedItem: Int?
    
    var body: some View {
        VStack(spacing: 20) {
            ForEach(0..<5) { index in
                Text("项目 \(index)")
                    .padding()
                    .background(Color.blue.opacity(0.1))
                    .anchorPreference(
                        key: HighlightKey.self,
                        value: .bounds
                    ) { selectedItem == index ? $0 : nil }
                    .onTapGesture {
                        selectedItem = index
                    }
            }
        }
        .overlayPreferenceValue(HighlightKey.self) { anchor in
            GeometryReader { geometry in
                if let anchor = anchor {
                    let rect = geometry[anchor]
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(Color.blue, lineWidth: 2)
                        .frame(width: rect.width, height: rect.height)
                        .offset(x: rect.minX, y: rect.minY)
                        .animation(.spring(), value: selectedItem)
                }
            }
        }
    }
}

从偏好生成视图

backgroundPreferenceValue(::)

读取指定的偏好值,使用它生成应用为原始视图背景的第二个视图。

struct BackgroundDemo: View {
    var body: some View {
        VStack {
            Text("内容")
                .preference(key: ColorPreferenceKey.self, value: .blue)
        }
        .backgroundPreferenceValue(ColorPreferenceKey.self) { color in
            color.opacity(0.2)
        }
    }
}

backgroundPreferenceValue(:alignment::)

带对齐参数的背景偏好值版本。

struct AlignedBackgroundDemo: View {
    var body: some View {
        VStack {
            Text("顶部内容")
                .preference(key: ShowIndicatorKey.self, value: true)
        }
        .backgroundPreferenceValue(ShowIndicatorKey.self, alignment: .topLeading) { show in
            if show {
                Circle()
                    .fill(.red)
                    .frame(width: 8, height: 8)
            }
        }
    }
}

overlayPreferenceValue(::)

读取指定的偏好值,使用它生成应用为原始视图覆盖层的第二个视图。

struct OverlayDemo: View {
    var body: some View {
        VStack(spacing: 20) {
            ForEach(items) { item in
                ItemView(item: item)
                    .anchorPreference(
                        key: BoundsPreferenceKey.self,
                        value: .bounds
                    ) { [$0] }
            }
        }
        .overlayPreferenceValue(BoundsPreferenceKey.self) { anchors in
            GeometryReader { geometry in
                ForEach(anchors.indices, id: \.self) { index in
                    let rect = geometry[anchors[index]]
                    Rectangle()
                        .stroke(Color.red, lineWidth: 1)
                        .frame(width: rect.width, height: rect.height)
                        .offset(x: rect.minX, y: rect.minY)
                }
            }
        }
    }
}

overlayPreferenceValue(:alignment::)

带对齐参数的覆盖层偏好值版本。

struct BadgeOverlay: View {
    var body: some View {
        Text("通知")
            .preference(key: BadgeCountKey.self, value: 5)
            .overlayPreferenceValue(BadgeCountKey.self, alignment: .topTrailing) { count in
                if count > 0 {
                    Text("\(count)")
                        .font(.caption2)
                        .padding(4)
                        .background(Circle().fill(.red))
                        .foregroundColor(.white)
                        .offset(x: 8, y: -8)
                }
            }
    }
}

实际应用场景

场景一:自适应布局

根据子视图的尺寸调整容器布局。

struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        let next = nextValue()
        value = CGSize(
            width: max(value.width, next.width),
            height: max(value.height, next.height)
        )
    }
}

struct AdaptiveContainer: View {
    @State private var contentSize: CGSize = .zero
    
    var body: some View {
        VStack {
            Text("容器尺寸: \(Int(contentSize.width)) x \(Int(contentSize.height))")
            
            DynamicContent()
                .background(
                    GeometryReader { geometry in
                        Color.clear
                            .preference(
                                key: SizePreferenceKey.self,
                                value: geometry.size
                            )
                    }
                )
                .onPreferenceChange(SizePreferenceKey.self) { size in
                    contentSize = size
                }
        }
        .frame(width: contentSize.width + 40, height: contentSize.height + 60)
        .background(Color.gray.opacity(0.1))
    }
}

场景二:滚动位置追踪

追踪列表中特定项目的位置。

struct ScrollOffsetKey: PreferenceKey {
    static var defaultValue: [String: CGFloat] = [:]
    
    static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
        value.merge(nextValue()) { $1 }
    }
}

struct ScrollTrackingList: View {
    @State private var offsets: [String: CGFloat] = [:]
    let items = ["A", "B", "C", "D", "E"]
    
    var body: some View {
        ScrollView {
            VStack(spacing: 0) {
                ForEach(items, id: \.self) { item in
                    Text("项目 \(item)")
                        .frame(height: 100)
                        .frame(maxWidth: .infinity)
                        .background(Color.blue.opacity(0.1))
                        .background(
                            GeometryReader { geometry in
                                Color.clear
                                    .preference(
                                        key: ScrollOffsetKey.self,
                                        value: [item: geometry.frame(in: .named("scroll")).minY]
                                    )
                            }
                        )
                }
            }
        }
        .coordinateSpace(name: "scroll")
        .onPreferenceChange(ScrollOffsetKey.self) { newOffsets in
            offsets = newOffsets
        }
        .overlay(alignment: .topTrailing) {
            VStack(alignment: .leading) {
                ForEach(items, id: \.self) { item in
                    if let offset = offsets[item] {
                        Text("\(item): \(Int(offset))")
                            .font(.caption)
                    }
                }
            }
            .padding()
            .background(.ultraThinMaterial)
        }
    }
}

场景三:自定义导航标题

模拟 navigationTitle 的实现方式。

struct TitlePreferenceKey: PreferenceKey {
    static var defaultValue: String = ""
    
    static func reduce(value: inout String, nextValue: () -> String) {
        let next = nextValue()
        if !next.isEmpty {
            value = next
        }
    }
}

extension View {
    func customNavigationTitle(_ title: String) -> some View {
        preference(key: TitlePreferenceKey.self, value: title)
    }
}

struct CustomNavigationView<Content: View>: View {
    @State private var title: String = ""
    let content: Content
    
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    var body: some View {
        VStack(spacing: 0) {
            Text(title)
                .font(.headline)
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.gray.opacity(0.1))
            
            content
                .onPreferenceChange(TitlePreferenceKey.self) { newTitle in
                    title = newTitle
                }
        }
    }
}

struct ContentPage: View {
    var body: some View {
        CustomNavigationView {
            VStack {
                Text("页面内容")
            }
            .customNavigationTitle("我的页面")
        }
    }
}

场景四:视图可见性检测

检测视图是否在可见区域内。

struct VisibilityKey: PreferenceKey {
    static var defaultValue: [String: Bool] = [:]
    
    static func reduce(value: inout [String: Bool], nextValue: () -> [String: Bool]) {
        value.merge(nextValue()) { $1 }
    }
}

struct VisibilityTracker: View {
    @State private var visibleItems: Set<String> = []
    let items = Array(0..<20).map { "Item \($0)" }
    
    var body: some View {
        VStack {
            Text("可见项目: \(visibleItems.sorted().joined(separator: ", "))")
                .padding()
            
            ScrollView {
                LazyVStack(spacing: 10) {
                    ForEach(items, id: \.self) { item in
                        Text(item)
                            .frame(height: 80)
                            .frame(maxWidth: .infinity)
                            .background(
                                visibleItems.contains(item) ? Color.green.opacity(0.2) : Color.gray.opacity(0.1)
                            )
                            .background(
                                GeometryReader { geometry in
                                    Color.clear
                                        .preference(
                                            key: VisibilityKey.self,
                                            value: [item: isVisible(geometry)]
                                        )
                                }
                            )
                    }
                }
            }
            .coordinateSpace(name: "scroll")
            .onPreferenceChange(VisibilityKey.self) { visibility in
                visibleItems = Set(visibility.filter { $0.value }.keys)
            }
        }
    }
    
    private func isVisible(_ geometry: GeometryProxy) -> Bool {
        let frame = geometry.frame(in: .named("scroll"))
        return frame.minY >= 0 && frame.maxY <= UIScreen.main.bounds.height
    }
}

最佳实践

1. 合理选择默认值

// ✅ 好的做法 - 有意义的默认值
struct MaxValueKey: PreferenceKey {
    static var defaultValue: CGFloat = 0  // 最小可能值
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

// ✅ 好的做法 - 使用可选类型表示无值
struct FirstValueKey: PreferenceKey {
    static var defaultValue: String?
    static func reduce(value: inout String?, nextValue: () -> String?) {
        value = value ?? nextValue()
    }
}

2. 高效的 reduce 实现

// ✅ 好的做法 - 延迟计算
struct LazyReduceKey: PreferenceKey {
    static var defaultValue: [String] = []
    
    static func reduce(value: inout [String], nextValue: () -> [String]) {
        // nextValue() 是一个闭包,只在需要时调用
        let next = nextValue()
        if !next.isEmpty {
            value.append(contentsOf: next)
        }
    }
}

// ❌ 避免 - 不必要的计算
struct InefficientKey: PreferenceKey {
    static var defaultValue: [String] = []
    
    static func reduce(value: inout [String], nextValue: () -> [String]) {
        // 即使不需要也会计算
        value.append(contentsOf: nextValue())
    }
}

3. 类型安全的偏好键

// ✅ 好的做法 - 使用强类型
struct TypedPreferenceKey<T>: PreferenceKey {
    static var defaultValue: T? { nil }
    
    static func reduce(value: inout T?, nextValue: () -> T?) {
        value = value ?? nextValue()
    }
}

// 为特定类型创建别名
typealias StringPreferenceKey = TypedPreferenceKey<String>
typealias IntPreferenceKey = TypedPreferenceKey<Int>

4. 避免过度使用

// ❌ 避免 - 简单的父子通信应该使用 Binding
struct BadExample: View {
    @State private var text = ""
    
    var body: some View {
        VStack {
            TextField("输入", text: $text)
                .preference(key: TextPreferenceKey.self, value: text)
        }
        .onPreferenceChange(TextPreferenceKey.self) { newText in
            // 不必要的复杂性
        }
    }
}

// ✅ 好的做法 - 直接使用 State
struct GoodExample: View {
    @State private var text = ""
    
    var body: some View {
        VStack {
            TextField("输入", text: $text)
            Text("输入的内容: \(text)")
        }
    }
}

5. 性能优化

// ✅ 好的做法 - 只在必要时更新
struct OptimizedView: View {
    @State private var preferenceValue: CGFloat = 0
    
    var body: some View {
        content
            .onPreferenceChange(SizePreferenceKey.self) { newValue in
                // 避免不必要的状态更新
                if abs(newValue - preferenceValue) > 1.0 {
                    preferenceValue = newValue
                }
            }
    }
}

总结

SwiftUI 的 Preferences 机制提供了强大的从子视图向容器视图传递信息的能力:

  • PreferenceKey 定义自定义偏好类型和合并逻辑
  • preference(key:value:) 设置偏好值
  • transformPreference 转换现有偏好值
  • onPreferenceChange 响应偏好值变化
  • anchorPreference 传递基于几何的偏好信息
  • backgroundPreferenceValue / overlayPreferenceValue 从偏好值生成视图

合理使用 Preferences 可以实现复杂的布局协调、状态同步和视图通信,但要注意避免过度使用,保持代码简洁和高效。

在 GitHub 上编辑

上次更新于