一、需求来源
在开发Flutter项目时有一个高频使用的组件 Wrap,使用场景特别多。在学习SwiftUI 时发现缺少这个组件,就特别别扭,花点时间实现了一个,遵循极简代码原则。
二、使用示例

swift
//
// WrapDemo.swift
// SwiftUITemplet
//
// Created by Bin Shang on 2025/2/27.
//
import SwiftUI
struct WrapDemo: View {
@StateObject private var router = Router.shared
var body: some View {
NavigationStack(path: $router.path) {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 10, content: {
buildWrap(alignment: .leading, itemBgColor: Color.blue);
buildWrap(alignment: .center, itemBgColor: Color.green);
buildWrap(alignment: .trailing, itemBgColor: Color.red);
})
}
.navigationBar(title: "\(clsName)")//自定义导航封装
// .navigationDestination(for: AppPage<AnyView>.self) { page in
// page.makeView()
// }
}
}
private func buildWrap(
alignment: HorizontalAlignment = .leading,
itemBgColor: Color = .blue
) -> some View {
var enumString = ""
switch alignment {
case HorizontalAlignment.center:
enumString = "HorizontalAlignment.center"
case HorizontalAlignment.trailing:
enumString = "HorizontalAlignment.trailing"
default:
enumString = "HorizontalAlignment.leading"
}
return VStack(alignment: alignment, spacing: 10, content: {
Text("\(enumString)").font(.subheadline)
Wrap(
spacing: 10,
runSpacing: 10,
alignment: alignment
) {
ForEach(0..<10) { i in
Text("选项_\(i)\("z" * i)")
.padding(EdgeInsets.symmetric(
horizontal: 8,
vertical: 4))
.background(itemBgColor)
.foregroundColor(.white)
.cornerRadius(8)
.onTapGesture { p in
DDLog("onTapGesture: \(i)")
}
}
}.padding(10)
Divider()
})
}
}
#Preview {
WrapDemo()
}
三、源码
1、Wrap 源码
swift
//
// Wrap.swift
// SwiftUITemplet
//
// Created by Bin Shang on 2025/2/27.
//
import SwiftUI
//Wrap(spacing: 10,
// runSpacing: 10,
// alignment: .leading) {
// ForEach(0..<10) { i in
// Text("选项_\(i)\("z" * i)")
// .padding(EdgeInsets.symmetric(
// horizontal: 8,
// vertical: 4))
// .background(Color.blue)
// .foregroundColor(.white)
// .cornerRadius(8)
// .onTapGesture { p in
// DDLog("onTapGesture: \(i)")
// }
// }
//}
//.padding(10)
/// 折行布局
struct Wrap: Layout {
/// 水平间距
var spacing: CGFloat
/// 竖直间距
var runSpacing: CGFloat
/// 对齐方式
var alignment: HorizontalAlignment
init(spacing: CGFloat = 10, runSpacing: CGFloat = 10, alignment: HorizontalAlignment = .leading) {
self.spacing = spacing
self.runSpacing = runSpacing
self.alignment = alignment
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
// 计算布局所需的总大小
let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxWidth = proposal.width ?? .infinity
var totalHeight: CGFloat = 0
var currentLineWidth: CGFloat = 0
var currentLineHeight: CGFloat = 0
for (index, size) in sizes.enumerated() {
if currentLineWidth + size.width > maxWidth {
// 换行
totalHeight += currentLineHeight + runSpacing
currentLineWidth = size.width
currentLineHeight = size.height
} else {
// 继续当前行
if index > 0 {
currentLineWidth += spacing
}
currentLineWidth += size.width
currentLineHeight = max(currentLineHeight, size.height)
}
}
// 添加最后一行的高度
totalHeight += currentLineHeight
return CGSize(width: maxWidth, height: totalHeight)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let maxWidth = bounds.width
var y = bounds.minY
var currentLineHeight: CGFloat = 0
var currentLineSubviews: [LayoutSubviews.Element] = []
var currentLineWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentLineWidth + size.width > maxWidth {
// 换行,放置当前行的子视图
placeLineSubviews(
in: bounds,
lineSubviews: currentLineSubviews,
lineWidth: currentLineWidth,
lineHeight: currentLineHeight,
y: y
)
y += currentLineHeight + runSpacing
currentLineSubviews = []
currentLineWidth = 0
currentLineHeight = 0
}
// 添加子视图到当前行
currentLineSubviews.append(subview)
if currentLineSubviews.count > 1 {
currentLineWidth += spacing
}
currentLineWidth += size.width
currentLineHeight = max(currentLineHeight, size.height)
}
// 放置最后一行
if !currentLineSubviews.isEmpty {
placeLineSubviews(
in: bounds,
lineSubviews: currentLineSubviews,
lineWidth: currentLineWidth,
lineHeight: currentLineHeight,
y: y
)
}
}
private func placeLineSubviews(
in bounds: CGRect,
lineSubviews: [LayoutSubviews.Element],
lineWidth: CGFloat,
lineHeight: CGFloat,
y: CGFloat
) {
let maxWidth = bounds.width
var x = bounds.minX
// 根据对齐方式调整起始位置
switch alignment {
case .leading:
x = bounds.minX
case .center:
x = bounds.minX + (maxWidth - lineWidth) / 2
case .trailing:
x = bounds.minX + (maxWidth - lineWidth)
default:
x = bounds.minX
}
// 放置当前行的子视图
for subview in lineSubviews {
let size = subview.sizeThatFits(.unspecified)
subview.place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: .unspecified)
x += size.width + spacing
}
}
}
Layout
协议的核心概念:
Layout
主要涉及 3 个核心方法:
-
sizeThatFits(proposal:subviews:cache:)
- 计算布局的尺寸 ,返回整个布局的
CGSize
。
- 计算布局的尺寸 ,返回整个布局的
-
placeSubviews(in:proposal:subviews:cache:)
- 对子视图进行布局,控制子视图的位置。
-
makeCache(subviews:)
(可选)- 缓存计算结果,提高性能。
-
func updateCache(_ cache:, subviews: )
(可选)- 更新缓存计算结果,提高性能。
最后、总结
SwiftUI 的 Layout
协议(iOS 16+)提供了一种 结构化、自定义的方式 来管理视图排列。它比 GeometryReader
更清晰、更高效,适用于 复杂布局需求 。 Layout
的 高级用法 ,包括 自适应布局、缓存优化、动画支持、多行流式布局 等。
Layout
VS GeometryReader
对比项 | Layout 协议 | GeometryReader |
---|---|---|
主要用途 | 适用于自定义布局 | 适用于响应父视图尺寸 |
API 复杂度 | 结构化,API 规范 | 容易造成嵌套过深 |
性能 | 缓存优化,效率更高 | 影响父视图布局,可能降低性能 |
子视图信息 | 直接获取 Subviews |
需要 GeometryProxy 获取 |
适用场景 | 复杂布局(如流式布局) | 适用于 相对布局 |
结论:
Layout
更适合复杂布局 (如 瀑布流 、环形排列)。GeometryReader
更适合响应父视图尺寸 (如自适应布局)。