SwiftUI 自适应流式布局:深入理解与实践

147 阅读9分钟

SwiftUI 自适应流式布局:深入理解与实践

SwiftUI 的 Layout 协议自 iOS 16 引入以来,彻底改变了我们构建自定义布局的方式。它提供了直接访问 SwiftUI 布局系统的能力,使我们能够创建各种复杂而灵活的自适应布局,而无需依赖 GeometryReader 的黑魔法。本文将深入探讨如何使用 Layout 协议构建自调整大小的流式布局(Flow Layout),这是一种类似于文本自动换行的布局方式,广泛应用于标签云、图库和商品展示等场景。

1 Layout 协议基础

1.1 协议概述

Layout 协议是 SwiftUI 布局系统的核心组成部分,它定义了两个必须实现的方法:


@available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *)

public protocol Layout : Animatable {

// 计算布局所需尺寸

func sizeThatFits(

proposal: ProposedViewSize,

subviews: Subviews,

cache: inout Self.Cache

) -> CGSize

// 放置子视图到指定位置

func placeSubviews(

in bounds: CGRect,

proposal: ProposedViewSize,

subviews: Subviews,

cache: inout Self.Cache

)

}

这两个方法共同工作:sizeThatFits 负责计算布局容器需要占据的空间大小,而 placeSubviews 则负责将每个子视图放置在容器的适当位置。

1.2 核心概念解析

1.2.1 ProposedViewSize

ProposedViewSize 是父视图提供给布局容器的尺寸建议,可以看作是一种"尺寸谈判"机制。它可能包含以下类型的值:

  • .zero: 视图应以最小尺寸响应

  • .infinity: 视图应以最大尺寸响应

  • .unspecified: 视图应以理想尺寸响应

  • 特定尺寸值: 父视图提供的具体宽高建议

1.2.2 Subviews

Subviews 是子视图代理的集合,提供了访问和测量子视图的能力。通过它,我们可以向子视图询问它们在不同提案下的尺寸需求。

1.2.3 缓存机制

缓存(Cache)是优化布局性能的关键机制。通过实现 makeCache(subviews:)updateCache(_:subviews:) 方法,我们可以存储和重复使用那些不依赖于提案而仅依赖于子视图的计算结果,避免重复计算。

2 流式布局的核心算法

2.1 布局逻辑原理

流式布局的核心行为是:水平排列子视图,当当前行剩余空间不足以容纳下一个子视图时,自动换行到下一行。这种布局方式类似于 CSS 中的 flex-wrap: wrap 行为,广泛应用于标签云、图库和商品列表等场景。

算法需要解决两个核心问题:

  1. 如何计算容纳所有子视图所需的总尺寸

  2. 如何确定每个子视图在容器中的具体位置

2.2 尺寸计算实现

下面是 sizeThatFits 方法的一个基础实现,它计算流式布局所需的最终尺寸:


func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {

// 获取所有子视图的理想尺寸

let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

var totalHeight: CGFloat = 0 // 总高度

var totalWidth: CGFloat = 0 // 总宽度(最宽行的宽度)

var lineWidth: CGFloat = 0 // 当前行宽度

var lineHeight: CGFloat = 0 // 当前行高度

// 获取父视图提供的可用宽度(若无限制则使用无穷大)

let availableWidth = proposal.width ?? .infinity

// 遍历所有子视图计算行布局

for size in sizes {

// 检查是否需要换行

if lineWidth + size.width > availableWidth {

// 换行:累加行高,重置行宽和行高

totalHeight += lineHeight

totalWidth = max(totalWidth, lineWidth)

lineWidth = size.width

lineHeight = size.height

} else {

// 继续当前行:累加宽度,更新行高

lineWidth += size.width

lineHeight = max(lineHeight, size.height)

}

}

// 添加最后一行的高度和宽度

totalHeight += lineHeight

totalWidth = max(totalWidth, lineWidth)

return CGSize(width: totalWidth, height: totalHeight)

}

这个实现考虑了父视图提供的宽度约束,当子视图累计宽度超过可用宽度时自动换行,并跟踪每行的最大高度和总高度。

2.3 子视图放置策略

计算出总体尺寸后,我们需要在 placeSubviews 方法中实际放置每个子视图:


func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {

// 预先计算所有子视图的尺寸(可优化为使用缓存)

let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

var lineX = bounds.minX // 当前行的X起始位置

var lineY = bounds.minY // 当前行的Y起始位置

var lineHeight: CGFloat = 0 // 当前行的高度

// 获取可用宽度(考虑bounds的宽度)

let availableWidth = proposal.width ?? bounds.width

// 遍历所有子视图进行放置

for index in subviews.indices {

let size = sizes[index]

// 检查是否需要换行

if lineX + size.width > availableWidth {

lineY += lineHeight // 换行:Y坐标增加行高

lineX = bounds.minX // X坐标重置到起始位置

lineHeight = 0 // 行高重置

}

// 放置子视图(以中心为锚点)

subviews[index].place(

at: CGPoint(

x: lineX + size.width / 2,

y: lineY + size.height / 2

),

anchor: .center,

proposal: ProposedViewSize(size)

)

// 更新当前行状态

lineHeight = max(lineHeight, size.height)

lineX += size.width

}

}

这个方法确保子视图在可用空间内正确排列,并在需要时自动换行。注意我们使用子视图的中心作为锚点进行放置,这可以使布局更加均衡和美观。

3 高级特性与优化

3.1 间距支持

在实际应用中,我们通常需要在子视图之间添加间距。以下是支持水平和垂直间距的增强实现:


struct FlowLayout: Layout {

var horizontalSpacing: CGFloat = 8

var verticalSpacing: CGFloat = 8

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {

let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

var totalHeight: CGFloat = 0

var totalWidth: CGFloat = 0

var lineWidth: CGFloat = 0

var lineHeight: CGFloat = 0

let availableWidth = proposal.width ?? .infinity

for (index, size) in sizes.enumerated() {

// 计算当前子视图需要的总宽度(包括间距)

let itemTotalWidth = size.width + (index > 0 ? horizontalSpacing : 0)

if lineWidth + itemTotalWidth > availableWidth {

// 需要换行

totalHeight += lineHeight + (totalHeight > 0 ? verticalSpacing : 0)

totalWidth = max(totalWidth, lineWidth)

lineWidth = size.width

lineHeight = size.height

} else {

// 继续当前行

lineWidth += itemTotalWidth

lineHeight = max(lineHeight, size.height)

}

}

// 处理最后一行

totalHeight += lineHeight

totalWidth = max(totalWidth, lineWidth)

return CGSize(width: totalWidth, height: totalHeight)

}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {

let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

var lineX = bounds.minX

var lineY = bounds.minY

var lineHeight: CGFloat = 0

let availableWidth = proposal.width ?? bounds.width

for (index, subview) in subviews.enumerated() {

let size = sizes[index]

// 检查是否需要换行(考虑间距)

if index > 0 && lineX + size.width + horizontalSpacing > availableWidth {

lineY += lineHeight + verticalSpacing

lineX = bounds.minX

lineHeight = 0

}

// 放置子视图

subview.place(

at: CGPoint(x: lineX + size.width / 2, y: lineY + size.height / 2),

anchor: .center,

proposal: ProposedViewSize(size)

)

// 更新行状态(考虑间距)

lineX += size.width + horizontalSpacing

lineHeight = max(lineHeight, size.height)

}

}

}

这个实现考虑了子视图之间的水平和垂直间距,使布局更加清晰和美观。

3.2 对齐方式支持

不同的应用场景可能需要不同的对齐方式(leading、center、trailing)。以下是支持多种对齐方式的实现:


extension FlowLayout {

enum Alignment {

case leading

case center

case trailing

}

var alignment: Alignment = .leading

}

在布局过程中,我们需要根据对齐方式调整每行的起始位置:


func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {

let sizes = subviews.map { $0.sizeThatFits(.unspecified) }

// 存储每行的信息

var lines: [[CGSize]] = [[]]

var currentLineWidth: CGFloat = 0

let availableWidth = proposal.width ?? bounds.width

// 第一步:将子视图分组到各行

for size in sizes {

if currentLineWidth + size.width > availableWidth && !lines.last!.isEmpty {

// 需要换行

lines.append([])

currentLineWidth = 0

}

lines[lines.count - 1].append(size)

currentLineWidth += size.width

}

// 第二步:计算每行的Y位置和高度

var lineY = bounds.minY

var lineInfos: [(y: CGFloat, height: CGFloat, widths: [CGFloat])] = []

for line in lines {

let lineHeight = line.map { $0.height }.max() ?? 0

lineInfos.append((y: lineY, height: lineHeight, widths: line.map { $0.width }))

lineY += lineHeight + verticalSpacing

}

// 第三步:放置每个子视图

var viewIndex = 0

for (lineIndex, lineInfo) in lineInfos.enumerated() {

let totalLineWidth = lineInfo.widths.reduce(0, +) +

CGFloat(lineInfo.widths.count - 1) * horizontalSpacing

// 根据对齐方式计算起始X位置

var lineX: CGFloat

switch alignment {

case .leading:

lineX = bounds.minX

case .center:

lineX = bounds.minX + (availableWidth - totalLineWidth) / 2

case .trailing:

lineX = bounds.minX + availableWidth - totalLineWidth

}

// 放置当前行的所有子视图

for width in lineInfo.widths {

let size = sizes[viewIndex]

subviews[viewIndex].place(

at: CGPoint(x: lineX + width / 2, y: lineInfo.y + size.height / 2),

anchor: .center,

proposal: ProposedViewSize(size)

)

lineX += width + horizontalSpacing

viewIndex += 1

}

}

}

这种实现支持多种对齐方式,使流式布局更加灵活和适应不同的设计需求。

3.3 缓存优化

对于复杂的布局或大量子视图的情况,使用缓存可以显著提高性能。以下是实现缓存机制的示例:


struct FlowLayout: Layout {

// 缓存数据结构

struct CacheData {

var sizes: [CGSize] = []

var positions: [CGPoint] = []

}

// 创建缓存

func makeCache(subviews: Subviews) -> CacheData {

return CacheData()

}

// 更新缓存

func updateCache(_ cache: inout CacheData, subviews: Subviews) {

// 当子视图发生变化时更新缓存

cache.sizes = subviews.map { $0.sizeThatFits(.unspecified) }

}

func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize {

// 使用缓存的尺寸数据

let sizes = cache.sizes.isEmpty ?

subviews.map { $0.sizeThatFits(.unspecified) } :

cache.sizes

// ... 尺寸计算逻辑与之前相同

}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {

// 使用缓存的尺寸数据

let sizes = cache.sizes.isEmpty ?

subviews.map { $0.sizeThatFits(.unspecified) } :

cache.sizes

// 计算并缓存位置信息

cache.positions = calculatePositions(sizes: sizes, bounds: bounds, proposal: proposal)

// 使用缓存的位置放置子视图

for (index, subview) in subviews.enumerated() {

subview.place(

at: cache.positions[index],

anchor: .topLeading,

proposal: ProposedViewSize(sizes[index])

)

}

}

// 计算位置的辅助方法

private func calculatePositions(sizes: [CGSize], bounds: CGRect, proposal: ProposedViewSize) -> [CGPoint] {

var positions: [CGPoint] = []

var lineX = bounds.minX

var lineY = bounds.minY

var lineHeight: CGFloat = 0

let availableWidth = proposal.width ?? bounds.width

for size in sizes {

if lineX + size.width > availableWidth {

lineY += lineHeight

lineX = bounds.minX

lineHeight = 0

}

positions.append(CGPoint(x: lineX, y: lineY))

lineX += size.width

lineHeight = max(lineHeight, size.height)

}

return positions

}

}

缓存机制通过存储中间计算结果,避免了在每次布局过程中重复计算子视图尺寸和位置,显著提高了性能,特别是在子视图数量较多或布局复杂的情况下。

4 实战应用与示例

4.1 标签云实现

流式布局最典型的应用场景是标签云(Tag Cloud)。以下是一个完整的标签云实现:


struct TagCloudView: View {

let tags: [String]

@State private var selectedTags: Set<String> = []

var body: some View {

FlowLayout(horizontalSpacing: 8, verticalSpacing: 8) {

ForEach(tags, id: \.self) { tag in

TagView(text: tag, isSelected: selectedTags.contains(tag))

.onTapGesture {

if selectedTags.contains(tag) {

selectedTags.remove(tag)

} else {

selectedTags.insert(tag)

}

}

}

}

.padding()

}

}

  


struct TagView: View {

let text: String

let isSelected: Bool

var body: some View {

Text(text)

.padding(.horizontal, 12)

.padding(.vertical, 8)

.background(isSelected ? Color.blue : Color.gray.opacity(0.2))

.foregroundColor(isSelected ? .white : .primary)

.cornerRadius(16)

.fixedSize() // 防止文本被截断

}

}

  


// 使用示例

struct ContentView: View {

let tags = ["SwiftUI", "iOS", "Layout", "Protocol", "Xcode",

"Development", "Apple", "Mobile", "UIKit", "macOS",

"WatchOS", "tvOS", "Programming", "Code", "Interface"]

var body: some View {

TagCloudView(tags: tags)

.navigationTitle("标签云示例")

}

}

这个示例展示了如何利用流式布局创建交互式标签云,用户可以选择和取消选择标签。

4.2 图库布局

流式布局也非常适合用于图库或照片墙的场景:


struct PhotoGalleryView: View {

let photoNames: [String]

var body: some View {

ScrollView {

FlowLayout(horizontalSpacing: 4, verticalSpacing: 4) {

ForEach(photoNames, id: \.self) { name in

AsyncImage(url: URL(string: name)) { image in

image

.resizable()

.scaledToFill()

.frame(width: 100, height: 100)

.clipped()

.cornerRadius(8)

} placeholder: {

Rectangle()

.fill(Color.gray.opacity(0.3))

.frame(width: 100, height: 100)

.cornerRadius(8)

}

}

}

.padding(4)

}

}

}

4.3 商品网格

电商应用中的商品列表也可以使用流式布局实现:


struct ProductGridView: View {

let products: [Product]

var body: some View {

FlowLayout(horizontalSpacing: 16, verticalSpacing: 16) {

ForEach(products) { product in

ProductCard(product: product)

.frame(width: 160)

}

}

.padding(16)

}

}

  


struct ProductCard: View {

let product: Product

var body: some View {

VStack(alignment: .leading) {

Image(product.imageName)

.resizable()

.scaledToFit()

.frame(height: 120)

.cornerRadius(8)

Text(product.name)

.font(.headline)

.lineLimit(2)

Text("$\(product.price, specifier: "%.2f")")

.font(.subheadline)

.foregroundColor(.secondary)

Spacer()

}

.padding(8)

.background(Color.white)

.cornerRadius(12)

.shadow(radius: 2)

}

}

5 性能优化与最佳实践

5.1 高效布局技巧

  1. 合理使用固定尺寸:对于已知尺寸的子视图,使用 .fixedSize() 修饰符可以避免不必要的尺寸协商。

  2. 避免过度测量:通过缓存机制减少对子视图 sizeThatFits 的调用次数。

  3. 预计算布局信息:在 makeCache 方法中预先计算不依赖于提案的信息。

  4. 使用异步操作:对于复杂计算,考虑使用后台队列进行计算,然后主线程更新UI。

5.2 调试布局问题

当布局行为不符合预期时,可以使用以下技巧进行调试:


// 添加布局调试修饰符

extension View {

func debugLayout() -> some View {

self

.background(Color.red.opacity(0.3))

.border(Color.blue, width: 1)

}

}

  


// 在布局中使用

FlowLayout {

ForEach(items) { item in

ItemView(item: item)

.debugLayout() // 添加调试边框和背景

}

}

5.3 自适应设备方向

为了使流式布局能够适应设备方向变化,需要正确处理尺寸提案:


func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) -> CGSize {

// 考虑设备方向和安全区域

let availableWidth = proposal.width ??

(UIScreen.main.bounds.width - safeAreaInsets.left - safeAreaInsets.right)

// ... 其余计算逻辑

}

6 与其他布局方式对比

6.1 与 LazyVGrid/LazyHGrid 对比

SwiftUI 提供了内置的 LazyVGridLazyHGrid,但它们与流式布局有重要区别:

| 特性 | Flow Layout | LazyVGrid/LazyHGrid |

|------|-------------|---------------------|

| 灵活性 | 子视图大小可变 | 固定单元格大小 |

| 排列方式 | 自动换行 | 严格网格 |

| 性能 | 适合中等数量视图 | 适合大量视图(延迟加载) |

| 复杂度 | 需要自定义实现 | 内置支持 |

6.2 与 UIKit 方案对比

在 UIKit 中,实现流式布局通常需要使用 UICollectionView 和自定义布局对象:


// UIKit 中的流式布局需要更多代码

class FlowLayout: UICollectionViewLayout {

// 需要实现大量方法:prepare(), layoutAttributesForElements(in:), etc.

}

相比之下,SwiftUI 的 Layout 协议提供了更简洁和声明式的 API,使实现自定义布局更加直观和简单。

7 高级主题与扩展

7.1 动画与过渡

为流式布局添加动画效果可以显著提升用户体验:


struct AnimatedFlowLayout: Layout {

// 添加动画支持

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {

withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {

// 布局逻辑

}

}

}

7.2 交互与动态更新

流式布局可以响应数据变化和用户交互:


struct InteractiveFlowLayout: Layout {

var selectedIndex: Int?

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout CacheData) {

for (index, subview) in subviews.enumerated() {

var size = cache.sizes[index]

// 放大选中的子视图

if index == selectedIndex {

size = CGSize(width: size.width * 1.2, height: size.height * 1.2)

}

// 放置逻辑

}

}

}

7.3 与其他布局组合

流式布局可以与其他 SwiftUI 布局容器组合使用:


struct CombinedLayoutView: View {

var body: some View {

VStack {

// 顶部标题

Text("流式布局示例")

.font(.title)

// 流式布局内容

FlowLayout {

// ... 子视图

}

// 底部控制按钮

HStack {

Button("添加") { /* ... */ }

Button("重置") { /* ... */ }

}

}

}

}

总结

SwiftUI 的 Layout 协议为我们提供了构建强大自定义布局的能力,流式布局是其中最具实用性的布局模式之一。

  1. 基础原理Layout 协议的核心方法和布局协商机制。

  2. 核心算法:流式布局的尺寸计算和子视图放置策略。

  3. 高级特性:间距支持、对齐方式、缓存优化等高级功能。

  4. 实战应用:标签云、图库和商品网格等实际应用场景。

  5. 性能优化:缓存机制和最佳实践确保布局性能。

  6. 扩展能力:动画支持、交互响应和与其他布局的组合使用。

原文:xuanhu.info/projects/it…