SwiftUI

自定义布局

深入理解 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.priority

LayoutSubviews

LayoutSubviewsLayoutSubview 的集合,支持标准的集合操作:

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
))

如果不需要动态切换布局,应直接使用 HStackVStackZStackGrid,而不是它们的 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 协议提供了创建自定义布局容器的能力,核心是实现 sizeThatFitsplaceSubviews 方法
  • LayoutSubview 作为子视图代理,提供了获取子视图信息和放置子视图的接口
  • ProposedViewSize 是布局系统中尺寸协商的核心,支持灵活的尺寸提议
  • LayoutValueKey 允许为子视图定义自定义布局属性,实现更灵活的布局逻辑
  • AnyLayout 支持在不同布局类型之间动态切换,并可配合动画实现平滑过渡
  • 内置容器的 Layout 版本(HStackLayout、VStackLayout 等)专门用于条件布局场景
  • 使用缓存机制可以优化复杂布局的性能
在 GitHub 上编辑

上次更新于