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 可以实现复杂的布局协调、状态同步和视图通信,但要注意避免过度使用,保持代码简洁和高效。
上次更新于