Home
avatar

SNOUX·雪飞

SwiftUI 子视图上报布局:用 PreferenceKey + Anchor 实现时间轴

在 SwiftUI 中,很多布局问题并不适合让子视图自己“猜位置”。比如时间轴里的圆点和连接线:圆点在每一行内部,但连接线需要跨行绘制,如果每一行都自己计算高度和偏移量,代码很容易变复杂。

更自然的方式是:子视图只负责把自己的位置信息上报给父视图,父视图拿到所有位置后统一处理布局效果。这就是本文要介绍的 PreferenceKey + Anchor 写法。

效果图

资源下载

提取码:4bv3

为什么换一种写法

之前的时间轴实现是通过 GeometryReader 读取每一行的高度,再手动计算连接线的高度和偏移量:

@State private var whole_height: CGFloat = 0
@State private var top_height: CGFloat = 0

private var connectionLineOffset: CGSize {
    CGSize(
        width: (CircleWidth - 2) / 2,
        height: CircleWidth + (top_height - CircleWidth) / 2 + stroke_width / 2
    )
}

这种方式可以实现效果,但它有几个明显问题:

  • 子视图既要负责展示内容,又要负责测量高度和计算连接线位置,职责比较混杂。
  • 需要额外的 @State 保存测量值,布局变化时还要监听 onAppearonChange
  • 偏移量公式不够直观,很难一眼看懂为什么要这么算。
  • 连接线本质上是“多行之间的关系”,但旧写法把它放在每一行内部单独计算,后期维护不方便。

PreferenceKey + Anchor 的思路不同:圆点在哪里,由圆点自己上报;线怎么连,由父视图统一决定。

核心思路

本文的时间轴仍然由三个部分组成:

  • TimeLineView:外层页面,负责准备数据和滚动容器。
  • GeneraTimeLineView:时间轴父视图,负责展示所有条目,并统一绘制连接线。
  • timeLineSubView:单条时间轴内容,负责展示圆点、标题、时间和详情。

新写法数据流:

子视图中的圆点
    ↓ anchorPreference 上报 bounds
PreferenceKey 收集所有圆点位置
    ↓ overlayPreferenceValue 读取
父视图统一绘制连接线

定义上报的数据

先定义结构体,用来描述“某个圆点的布局信息”:

private struct TimelineDotBoundsPreference {
    let id: UUID
    let bounds: Anchor<CGRect>
}

字段:

  • id:用来和时间轴数据模型对应,保证圆点可以按数据顺序连接。
  • bounds:圆点的边界锚点,也就是这个圆点在布局中的位置引用。

注意,Anchor<CGRect> 实际为“位置凭证”,而非真实位置,需要放到父视图的 GeometryProxy 里解析,才能得到真正的坐标。

定义 PreferenceKey

PreferenceKey 是 SwiftUI 中子视图向父视图传值的机制:

private struct TimelineDotBoundsPreferenceKey: PreferenceKey {
    static var defaultValue: [TimelineDotBoundsPreference] = []
    
    static func reduce(
        value: inout [TimelineDotBoundsPreference],
        nextValue: () -> [TimelineDotBoundsPreference]
    ) {
        value.append(contentsOf: nextValue())
    }
}

因为时间轴中有多个圆点,所以这里的 defaultValue 是数组。

reduce 的作用也很简单:每个子视图都会上报一个圆点信息,reduce 负责把这些信息合并到同一个数组里。

子视图上报圆点位置

在单条时间轴视图里,圆点通过 anchorPreference 上报自己的 bounds

Circle()
    .stroke(lineWidth: strokeWidth)
    .foregroundStyle(Color(.systemOrange))
    .opacity(isFirst ? 1 : 0.4)
    .frame(width: CircleWidth, height: CircleWidth)
    .anchorPreference(
        key: TimelineDotBoundsPreferenceKey.self,
        value: .bounds
    ) { bounds in
        [TimelineDotBoundsPreference(id: model.id, bounds: bounds)]
    }

子视图不再计算连接线偏移,只做一件事:把圆点的位置上报出去。

父视图读取所有圆点

父视图通过 overlayPreferenceValue 读取所有子视图上报的圆点锚点:

LazyVStack(spacing: 0) {
    ForEach(items) { model in
        timeLineSubView(
            model: model,
            isFirst: model == items.first
        )
    }
}
.overlayPreferenceValue(TimelineDotBoundsPreferenceKey.self) { preferences in
    GeometryReader { proxy in
        connectionLines(with: preferences, proxy: proxy)
    }
}

这里为什么还需要 GeometryReader

因为 Anchor<CGRect> 需要通过 GeometryProxy 才能解析成真正的位置:

let rect = proxy[preference.bounds]

也就是说,子视图上报的是“锚点”,父视图通过自己的坐标系统把锚点转换成可用的 CGRect

按数据顺序整理圆点

虽然 PreferenceKey 可以收集到所有圆点,但我们不应该依赖它返回的顺序。因此先按照 items 的顺序重新整理:

private func orderedDotFrames(
    from preferences: [TimelineDotBoundsPreference],
    proxy: GeometryProxy
) -> [CGRect] {
    items.compactMap { item in
        guard let preference = preferences.first(where: { $0.id == item.id }) else {
            return nil
        }
        
        return proxy[preference.bounds]
    }
}

这样做以后,dotFrames[0]dotFrames[1]dotFrames[2] 就一定对应时间轴数据的第 1、2、3 条。

绘制连接线

拿到每个圆点的真实位置后进行连接线的绘制:

@ViewBuilder
private func connectionLines(
    with preferences: [TimelineDotBoundsPreference],
    proxy: GeometryProxy
) -> some View {
    let dotFrames = orderedDotFrames(from: preferences, proxy: proxy)
    
    ForEach(Array(dotFrames.indices.dropLast()), id: \.self) { index in
        let currentDot = dotFrames[index]
        let nextDot = dotFrames[index + 1]
        let lineHeight = max(0, nextDot.minY - currentDot.maxY)
        
        Rectangle()
            .frame(width: 2, height: lineHeight)
            .foregroundStyle(Color(.systemOrange))
            .opacity(index == 0 ? 1 : 0.4)
            .position(
                x: currentDot.midX,
                y: currentDot.maxY + lineHeight / 2
            )
    }
}

整体的计算比旧写法会直观很多

  • 当前圆点:currentDot
  • 下一个圆点:nextDot
  • 连接线高度:nextDot.minY - currentDot.maxY
  • 连接线横向位置:currentDot.midX
  • 连接线纵向位置:当前圆点底部到下一个圆点顶部的中间

新旧写法对比

对比项动态计算高度和偏移PreferenceKey + Anchor
实现思路每一行自己测量高度并计算线条位置子视图上报圆点位置,父视图统一绘制
状态管理需要 @State 保存高度不需要保存测量状态
代码理解偏移公式比较绕根据两个圆点的真实位置连线
职责划分子视图同时负责内容和线条布局子视图展示内容,父视图处理跨行布局
适配动态内容可实现,但需要维护测量逻辑圆点位置变化后自动重新解析
调试难度需要检查高度、偏移、描边等细节主要检查锚点是否正确上报

新写法的优点

1. 代码更符合 SwiftUI 的数据流

SwiftUI 中很多布局信息是从子视图产生的,但最终效果往往需要父视图统一处理。PreferenceKey 正好解决了这个问题。

时间轴里,圆点属于单条内容,但连接线属于整个列表。让父视图统一画线,职责更清晰。

2. 不需要保存临时高度状态

旧写法需要:

@State private var whole_height: CGFloat = 0
@State private var top_height: CGFloat = 0

新写法不需要这些状态。布局发生变化时,SwiftUI 会重新计算锚点,父视图拿到新的位置后重新绘制连接线。

3. 对动态内容更友好

如果某一条时间轴详情变成多行,或者字号变化,圆点的位置会随布局自动更新。连接线是根据圆点之间的真实距离绘制的,所以不需要额外修改高度公式。

4. 可复用性更强

这个模式并不只适合时间轴。只要遇到“子视图知道自己的位置,父视图需要统一处理”的场景,都可以考虑使用。

新写法的注意点

PreferenceKey + Anchor 也不是所有场景都必须用。它适合解决跨视图布局关系,但比普通布局写法稍微抽象一些。

使用时需要注意:

  • Anchor<CGRect> 要通过 GeometryProxy 解析,不能直接当作 CGRect 使用。
  • 多个子视图上报值时,最好带上 id,不要依赖收集顺序。
  • 如果只是普通的单个视图布局,直接使用 VStackHStackalignmentGuide 可能更简单。
  • 如果需要监听滚动偏移,通常会上报一个 CGFloat 或某个锚点,再由父视图计算偏移。

还能用在哪些场景

1. 高亮当前 section

例如文章目录、设置页分组、商品详情页楼层导航,都可以让每个 section 上报自己的位置。

父视图根据 section 的 minY 判断哪个 section 最接近顶部,然后高亮对应的目录项。

适合场景:

  • 文章阅读目录高亮
  • 商品详情页 tab 跟随滚动高亮
  • 设置页左侧分类和右侧内容联动

2. 监听 ScrollView 偏移量

SwiftUI 的 ScrollView 本身没有直接暴露滚动偏移。可以通过 PreferenceKey 上报它位置变化。

父视图拿到这个位置后,就能得到当前滚动距离。

适合场景:

  • 导航栏滚动渐变
  • 下拉时改变头图大小
  • 滚动到一定距离后显示“回到顶部”按钮

3. 做自定义下划线或选中背景

比如自定义 Tab、分段控件、菜单栏,可以让每个按钮上报自己的 bounds

父视图拿到当前选中按钮的位置后,绘制一个跟随移动的下划线或背景块。

适合场景:

  • 自定义顶部分类 tab
  • 可滑动菜单选中指示器
  • 表单中当前输入项高亮边框

4. 做跨子视图的装饰线

时间轴连接线就是这种场景。类似的还有:

  • 步骤条连接线
  • 流程图节点连线
  • 列表中相邻元素之间的自定义分隔效果

这些效果都不是单个子视图能独立决定的,更适合让父视图收集位置后统一绘制。

5. 获取动态尺寸并同步给父视图

有些组件的高度由内容决定,但父视图需要知道这个高度,比如弹窗、底部面板、可折叠区域。

这时可以用 PreferenceKey 上报尺寸,再让父视图根据尺寸调整外层布局。

总结

旧写法通过 GeometryReader + @State + offset 也能完成时间轴,但本质上是在手动维护布局细节,代码会随着需求增加变得越来越难读。

PreferenceKey + Anchor 的写法更像是把布局关系说清楚:

圆点负责报告自己的位置
父视图负责根据所有圆点画连接线

对于时间轴、步骤条、滚动高亮、选中指示器、滚动偏移监听这类场景,这种“子视图上报布局,父视图统一处理”的模式会更清晰,也更符合 SwiftUI 的声明式思路。

SwiftUI iOS 技术