自定义布局
深入理解 SwiftUI 的 Layout 协议,掌握创建自定义布局容器的方法
SwiftUI 提供了丰富的内置布局容器(如 HStack、VStack、Grid),但当这些容器无法满足特定需求时,可以通过实现 Layout 协议来创建自定义布局容器。本文详细介绍 SwiftUI 自定义布局的核心概念和实现方法。
概述
自定义布局允许你完全控制子视图的尺寸计算和位置放置。通过实现 Layout 协议,你可以:
- 创建符合特定设计需求的布局容器
- 实现内置容器无法完成的复杂排列逻辑
- 在不同布局类型之间创建动画转换
Layout 协议
Layout 协议是创建自定义布局的核心,它定义了布局容器的几何特性。
核心方法
实现自定义布局至少需要实现两个核心方法:
struct BasicVStack: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// 计算并返回布局容器的尺寸
let sizes = subviews.map { $0.sizeThatFits(proposal) }
let width = sizes.map { $0.width }.max() ?? 0
let height = sizes.map { $0.height }.reduce(0, +)
return CGSize(width: width, height: height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// 告诉每个子视图应该出现的位置
var y = bounds.minY
for subview in subviews {
let size = subview.sizeThatFits(proposal)
subview.place(
at: CGPoint(x: bounds.minX, y: y),
proposal: ProposedViewSize(size)
)
y += size.height
}
}
}使用自定义布局的方式与内置容器相同:
BasicVStack {
Text("第一个子视图")
Text("第二个子视图")
Image(systemName: "star.fill")
}添加布局参数
可以为布局添加参数来控制其行为:
struct FlexibleVStack: Layout {
var alignment: HorizontalAlignment
var spacing: CGFloat
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let sizes = subviews.map { $0.sizeThatFits(proposal) }
let width = sizes.map { $0.width }.max() ?? 0
let height = sizes.map { $0.height }.reduce(0, +)
+ spacing * CGFloat(max(0, subviews.count - 1))
return CGSize(width: width, height: height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var y = bounds.minY
for subview in subviews {
let size = subview.sizeThatFits(proposal)
let x: CGFloat
switch alignment {
case .leading:
x = bounds.minX
case .trailing:
x = bounds.maxX - size.width
default:
x = bounds.midX - size.width / 2
}
subview.place(
at: CGPoint(x: x, y: y),
proposal: ProposedViewSize(size)
)
y += size.height + spacing
}
}
}使用时传入参数:
FlexibleVStack(alignment: .leading, spacing: 10) {
Text("左对齐")
Text("带间距的布局")
}可选方法
Layout 协议提供了其他可选方法来支持更多功能:
显式对齐
func explicitAlignment(
of guide: HorizontalAlignment,
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGFloat? {
// 返回指定对齐指南的位置
return nil // 使用默认行为
}间距偏好
func spacing(
subviews: Subviews,
cache: inout ()
) -> ViewSpacing {
// 返回布局容器的间距偏好
return ViewSpacing()
}布局属性
var layoutProperties: LayoutProperties {
var properties = LayoutProperties()
properties.stackOrientation = .vertical
return properties
}缓存机制
对于复杂的布局计算,可以使用缓存来提升性能:
struct CachedLayout: Layout {
struct Cache {
var sizes: [CGSize] = []
}
func makeCache(subviews: Subviews) -> Cache {
Cache()
}
func updateCache(_ cache: inout Cache, subviews: Subviews) {
cache.sizes = subviews.map {
$0.sizeThatFits(.unspecified)
}
}
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize {
// 使用缓存的尺寸信息
let width = cache.sizes.map { $0.width }.max() ?? 0
let height = cache.sizes.map { $0.height }.reduce(0, +)
return CGSize(width: width, height: height)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) {
var y = bounds.minY
for (index, subview) in subviews.enumerated() {
subview.place(
at: CGPoint(x: bounds.minX, y: y),
proposal: ProposedViewSize(cache.sizes[index])
)
y += cache.sizes[index].height
}
}
}布局子视图代理
LayoutSubview
LayoutSubview 是子视图的代理,提供了访问子视图信息和放置子视图的方法。
获取子视图尺寸
let size = subview.sizeThatFits(proposal)
let dimensions = subview.dimensions(in: proposal)放置子视图
subview.place(
at: CGPoint(x: 100, y: 100),
anchor: .topLeading,
proposal: ProposedViewSize(width: 200, height: 100)
)访问布局优先级
let priority = subview.priorityLayoutSubviews
LayoutSubviews 是 LayoutSubview 的集合,支持标准的集合操作:
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// 遍历所有子视图
for (index, subview) in subviews.enumerated() {
// 处理每个子视图
}
// 访问特定子视图
if let first = subviews.first {
// 处理第一个子视图
}
// 获取布局方向
let direction = subviews.layoutDirection
}布局配置
ProposedViewSize
ProposedViewSize 表示对视图尺寸的提议,是布局系统中尺寸协商的核心概念。
标准提议
// 零尺寸提议
let zero = ProposedViewSize.zero
// 无限尺寸提议
let infinity = ProposedViewSize.infinity
// 未指定尺寸提议
let unspecified = ProposedViewSize.unspecified自定义提议
// 指定宽度和高度
let proposal = ProposedViewSize(width: 200, height: 100)
// 从 CGSize 创建
let size = CGSize(width: 300, height: 150)
let proposal = ProposedViewSize(size)
// 替换未指定的维度
let modified = proposal.replacingUnspecifiedDimensions(
by: CGSize(width: 100, height: 50)
)使用示例
struct AdaptiveLayout: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// 根据提议的尺寸调整布局策略
if let width = proposal.width, width < 300 {
// 窄屏幕布局
return compactLayout(subviews: subviews)
} else {
// 宽屏幕布局
return regularLayout(subviews: subviews)
}
}
private func compactLayout(subviews: Subviews) -> CGSize {
// 实现紧凑布局
CGSize(width: 200, height: 400)
}
private func regularLayout(subviews: Subviews) -> CGSize {
// 实现常规布局
CGSize(width: 400, height: 200)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// 放置子视图
}
}LayoutProperties
LayoutProperties 描述布局容器的特性:
struct StackLikeLayout: Layout {
var layoutProperties: LayoutProperties {
var properties = LayoutProperties()
properties.stackOrientation = .vertical
return properties
}
// 实现其他必需方法...
}ViewSpacing
ViewSpacing 存储视图的间距偏好,可以为不同边缘和不同类型的相邻视图指定不同的间距值:
func spacing(
subviews: Subviews,
cache: inout ()
) -> ViewSpacing {
var spacing = ViewSpacing()
// 可以根据子视图的间距偏好计算容器的间距
for subview in subviews {
let subviewSpacing = subview.spacing
// 合并间距信息
}
return spacing
}自定义布局值
通过 LayoutValueKey 协议,可以为子视图定义自定义的布局属性。
定义布局值键
private struct Flexibility: LayoutValueKey {
static let defaultValue: CGFloat? = nil
}创建便捷修饰符
extension View {
func layoutFlexibility(_ value: CGFloat?) -> some View {
layoutValue(key: Flexibility.self, value: value)
}
}在视图中设置值
FlexibleLayout {
Text("固定视图")
Text("灵活视图")
.layoutFlexibility(2.0)
Text("更灵活的视图")
.layoutFlexibility(3.0)
}在布局中读取值
struct FlexibleLayout: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// 读取每个子视图的灵活度
let flexibilities = subviews.map { subview in
subview[Flexibility.self] ?? 1.0
}
// 根据灵活度计算尺寸
let totalFlexibility = flexibilities.reduce(0, +)
// 计算并返回容器尺寸
return CGSize(width: 300, height: 200)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let flexibilities = subviews.map {
$0[Flexibility.self] ?? 1.0
}
let totalFlexibility = flexibilities.reduce(0, +)
var y = bounds.minY
for (index, subview) in subviews.enumerated() {
let flexibility = flexibilities[index]
let height = bounds.height * (flexibility / totalFlexibility)
subview.place(
at: CGPoint(x: bounds.minX, y: y),
proposal: ProposedViewSize(
width: bounds.width,
height: height
)
)
y += height
}
}
}布局类型转换
AnyLayout
AnyLayout 是类型擦除的布局实例,允许在不同布局类型之间动态切换,同时保持子视图的状态。
基本用法
struct DynamicLayoutExample: View {
@Environment(\.dynamicTypeSize) var dynamicTypeSize
var body: some View {
let layout = dynamicTypeSize <= .medium ?
AnyLayout(HStackLayout()) : AnyLayout(VStackLayout())
layout {
Text("第一个标签")
Text("第二个标签")
Text("第三个标签")
}
}
}带动画的布局切换
struct AnimatedLayoutSwitch: View {
@State private var isVertical = false
var body: some View {
let layout = isVertical ?
AnyLayout(VStackLayout(spacing: 10)) :
AnyLayout(HStackLayout(spacing: 10))
VStack {
layout {
ForEach(0..<5) { index in
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue)
.frame(width: 60, height: 60)
.overlay(Text("\(index + 1)"))
}
}
.animation(.spring(), value: isVertical)
Button("切换布局") {
isVertical.toggle()
}
.padding()
}
}
}在自定义布局间切换
struct LayoutSwitcher: View {
@State private var useCustomLayout = false
var body: some View {
let layout = useCustomLayout ?
AnyLayout(FlexibleVStack(alignment: .leading, spacing: 20)) :
AnyLayout(GridLayout())
layout {
Text("内容 1")
Text("内容 2")
Text("内容 3")
}
.animation(.easeInOut, value: useCustomLayout)
}
}内置布局协议实现
SwiftUI 为内置容器提供了符合 Layout 协议的版本,专门用于条件布局。
HStackLayout
水平布局容器,行为与 HStack 相同:
let layout = AnyLayout(HStackLayout(
alignment: .center,
spacing: 10
))VStackLayout
垂直布局容器,行为与 VStack 相同:
let layout = AnyLayout(VStackLayout(
alignment: .leading,
spacing: 15
))ZStackLayout
叠加布局容器,行为与 ZStack 相同:
let layout = AnyLayout(ZStackLayout(
alignment: .topLeading
))GridLayout
网格布局容器,行为与 Grid 相同:
let layout = AnyLayout(GridLayout(
alignment: .center,
horizontalSpacing: 10,
verticalSpacing: 10
))如果不需要动态切换布局,应直接使用 HStack、VStack、ZStack 或 Grid,而不是它们的 Layout 协议版本。
实践示例
瀑布流布局
struct WaterfallLayout: Layout {
var columns: Int = 2
var spacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) -> CGSize {
let width = proposal.replacingUnspecifiedDimensions().width
let columnWidth = (width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
var columnHeights = Array(repeating: CGFloat.zero, count: columns)
for subview in subviews {
let shortestColumn = columnHeights.enumerated().min(by: { $0.1 < $1.1 })!.0
let size = subview.sizeThatFits(
ProposedViewSize(width: columnWidth, height: nil)
)
columnHeights[shortestColumn] += size.height + spacing
}
let maxHeight = columnHeights.max() ?? 0
return CGSize(width: width, height: maxHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Cache
) {
let columnWidth = (bounds.width - spacing * CGFloat(columns - 1)) / CGFloat(columns)
var columnHeights = Array(repeating: bounds.minY, count: columns)
for subview in subviews {
let shortestColumn = columnHeights.enumerated().min(by: { $0.1 < $1.1 })!.0
let x = bounds.minX + CGFloat(shortestColumn) * (columnWidth + spacing)
let y = columnHeights[shortestColumn]
let size = subview.sizeThatFits(
ProposedViewSize(width: columnWidth, height: nil)
)
subview.place(
at: CGPoint(x: x, y: y),
proposal: ProposedViewSize(size)
)
columnHeights[shortestColumn] += size.height + spacing
}
}
}使用瀑布流布局:
ScrollView {
WaterfallLayout(columns: 2, spacing: 10) {
ForEach(0..<20) { index in
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.3))
.frame(height: CGFloat.random(in: 100...300))
.overlay(Text("\(index)"))
}
}
.padding()
}圆形布局
struct CircularLayout: Layout {
var radius: CGFloat = 100
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let diameter = radius * 2
return CGSize(width: diameter, height: diameter)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let angleStep = (2 * .pi) / Double(subviews.count)
for (index, subview) in subviews.enumerated() {
let angle = angleStep * Double(index) - .pi / 2
let x = center.x + radius * cos(angle)
let y = center.y + radius * sin(angle)
subview.place(
at: CGPoint(x: x, y: y),
anchor: .center,
proposal: .unspecified
)
}
}
}使用圆形布局:
CircularLayout(radius: 120) {
ForEach(0..<8) { index in
Circle()
.fill(Color.blue)
.frame(width: 40, height: 40)
.overlay(Text("\(index + 1)").foregroundColor(.white))
}
}
.frame(width: 300, height: 300)总结
- Layout 协议提供了创建自定义布局容器的能力,核心是实现
sizeThatFits和placeSubviews方法 - LayoutSubview 作为子视图代理,提供了获取子视图信息和放置子视图的接口
- ProposedViewSize 是布局系统中尺寸协商的核心,支持灵活的尺寸提议
- LayoutValueKey 允许为子视图定义自定义布局属性,实现更灵活的布局逻辑
- AnyLayout 支持在不同布局类型之间动态切换,并可配合动画实现平滑过渡
- 内置容器的 Layout 版本(HStackLayout、VStackLayout 等)专门用于条件布局场景
- 使用缓存机制可以优化复杂布局的性能
上次更新于