使用自定义 SwiftUI Layout,让首个单元格占据两个列槽位,然后将其余单元格依次填入后续各行。这个 macOS 示例会在窗口宽度变化时重新计算列数,并根据内容测量每行的高度。

任务是制作一个小型桌面调色板:一个介绍卡片,后面跟着十一个编号按钮。只要至少能容纳两列,首个卡片就会更宽。在较窄的宽度下,它会变为单列卡片,让内容可以继续向下排列。示例中的所有标签和选项均为虚构内容。

哪种 SwiftUI 容器支持这种布局?

Apple 的 Grid 支持通过 GridRow 内的 gridCellColumns(_:) 让单元格跨越多列。不过,行需要由你自行提供。直接放在 Grid 内、但位于 GridRow 外的视图会跨越整个网格。这两种方式都不会根据宽度自动将平铺的集合排列成多行。请参阅 Apple 的 Grid 文档和 gridCellColumns(_:) 文档。

LazyVGrid 可以自动填充行:它会将子视图依次放入各列,并继续排列到后续行。其文档所述的列配置并未提供 GridRow 那种让单个单元格跨列的机制。单纯将第一个子视图在视觉上加宽,并不能形成有文档支持的双列分配。请参阅 Apple 的 LazyVGrid 文档。

根据所需行为选择容器:

需求 适用方案
显式指定行,并让单元格跨越两列 Grid、GridRow 和 gridCellColumns(2)
自动填充普通单元格的行 LazyVGrid
自动填充行,同时让首个单元格占据两个槽位 实现该排列规则的自定义 Layout

对于最后一种情况,Apple 支持的扩展点是 Layout 协议。它的两个核心方法分别用于报告容器尺寸和放置子视图。下面的实现是在这些有文档说明的方法基础上编写的自定义排列算法。Layout — Apple

编写布局前先确定尺寸规则

这个示例将普通列的最小宽度设为 96 点,列间距设为 8 点。列会等宽扩展以利用可用宽度。首个子视图占据两个槽位,包括两列之间的间距;其余子视图各占一个槽位。子视图按现有顺序填充各行,每行高度取该行最高子视图的高度。

可用宽度为 W、最小列宽为 m、间距为 g 时,列数为 max(1, floor((W + g) / (m + g)))。普通列的实际宽度为 (W - (columns - 1) × g) / columns。因此,双槽位卡片的宽度为 2 × columnWidth + g 点。

例如,宽度为 408 点的内容区域可以容纳四列,每列宽 96 点,列间有三个 8 点间距。介绍卡片宽 200 点,它旁边可以放两个普通按钮,后续按钮则每行填满四个槽位。这些只是根据选定常量进行的示例计算,并非运行中应用的测量结果。

SwiftUI 在评估布局的伸缩性时,可能会提出未指定、零或无限尺寸的建议。需要针对这些建议做出明确响应。这个实现的理想宽度最多使用四列,最小宽度使用一列;如果要求最大宽度,则会使用足够多的列,让所有子视图排在同一行。它始终返回有限宽度。Apple 在 ProposedViewSize 中说明了这些建议的含义。

在 macOS 应用中实现布局

在 Xcode 中创建 SwiftUI macOS 应用,并将部署目标设为 macOS 13 或更高版本,该版本开始支持 Layout。添加名为 WideFirstGrid.swift 的 Swift 文件,并写入以下完整布局类型。Layout — Apple

import SwiftUI

struct WideFirstGrid: Layout {
    let minimumColumnWidth: CGFloat = 96
    let gap: CGFloat = 8

    struct Plan {
        var size: CGSize
        var frames: [CGRect]
    }

    func makePlan(width proposedWidth: CGFloat?, subviews: Subviews) -> Plan {
        guard !subviews.isEmpty else {
            return Plan(size: .zero, frames: [])
        }

        let totalSlots = subviews.count + (subviews.count > 1 ? 1 : 0)
        let idealColumns = min(4, totalSlots)
        let idealWidth = CGFloat(idealColumns) * minimumColumnWidth
            + CGFloat(idealColumns - 1) * gap
        let maximumWidth = CGFloat(totalSlots) * minimumColumnWidth
            + CGFloat(totalSlots - 1) * gap

        let width: CGFloat
        if let proposedWidth {
            if proposedWidth.isFinite {
                width = max(minimumColumnWidth, proposedWidth)
            } else {
                width = maximumWidth
            }
        } else {
            width = idealWidth
        }

        let columns = max(1, Int(floor(
            (width + gap) / (minimumColumnWidth + gap)
        )))
        let columnWidth = (width - CGFloat(columns - 1) * gap)
            / CGFloat(columns)

        var frames: [CGRect] = []
        var column = 0
        var y: CGFloat = 0
        var rowHeight: CGFloat = 0

        for (index, subview) in subviews.enumerated() {
            let span = index == 0 ? min(2, columns) : 1
            if column + span > columns {
                y += rowHeight + gap
                column = 0
                rowHeight = 0
            }

            let cellWidth = CGFloat(span) * columnWidth
                + CGFloat(span - 1) * gap
            let measured = subview.sizeThatFits(
                ProposedViewSize(width: cellWidth, height: nil)
            )
            frames.append(CGRect(
                x: CGFloat(column) * (columnWidth + gap),
                y: y,
                width: cellWidth,
                height: measured.height
            ))
            rowHeight = max(rowHeight, measured.height)
            column += span
        }

        return Plan(
            size: CGSize(width: width, height: y + rowHeight),
            frames: frames
        )
    }

    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
        makePlan(width: proposal.width, subviews: subviews).size
    }

    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
        let plan = makePlan(width: bounds.width, subviews: subviews)
        for (subview, frame) in zip(subviews, plan.frames) {
            subview.place(
                at: CGPoint(
                    x: bounds.minX + frame.minX,
                    y: bounds.minY + frame.minY
                ),
                anchor: .topLeading,
                proposal: ProposedViewSize(width: frame.width, height: nil)
            )
        }
    }
}

同一个规划函数同时用于测量和放置。每个子视图都以分配给它的宽度和未指定的高度进行测量,然后使用相同的子视图尺寸建议进行放置。这样可以确保文本换行和行高计算保持一致。Apple 明确要求尺寸计算与放置计算彼此兼容。placeSubviews(in:proposal:subviews:cache:) — Apple

放置时会将 bounds.minX 和 bounds.minY 加到每个计算出的位置上。父视图分配的边界不一定从零开始。每个子视图都会收到一次放置调用,并且这些调用只会发生在 placeSubviews 内,符合 LayoutSubview 的放置约定。

添加虚构卡片和按钮操作

将生成的应用入口文件替换为以下代码,并确保项目中恰好只有一个 @main 声明。与 WideFirstGrid.swift 一起,这就是完整示例。介绍文字是布局中的第一个子视图;每次迭代都会提供一个按钮。

import SwiftUI

@main
struct WideFirstGridDemoApp: App {
    var body: some Scene {
        WindowGroup {
            PaletteView()
                .frame(minWidth: 240, minHeight: 260)
        }
    }
}

struct PaletteView: View {
    @State private var selectedNumber: Int? = nil

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Tile palette")
                .font(.title2)

            Text(selectedNumber.map { "Selected tile: \($0)" }
                 ?? "Choose a numbered tile")

            ScrollView {
                WideFirstGrid {
                    Text("Choose a tile from this sample collection")
                        .font(.headline)
                        .padding(12)
                        .frame(
                            maxWidth: .infinity,
                            minHeight: 64,
                            alignment: .leading
                        )
                        .background(
                            Color.blue.opacity(0.12),
                            in: RoundedRectangle(cornerRadius: 10)
                        )

                    ForEach(1...11, id: \.self) { number in
                        Button {
                            selectedNumber = number
                        } label: {
                            Text("Tile \(number)")
                                .padding(12)
                                .frame(maxWidth: .infinity, minHeight: 64)
                                .background(
                                    Color.gray.opacity(0.12),
                                    in: RoundedRectangle(cornerRadius: 10)
                                )
                        }
                        .buttonStyle(.plain)
                    }
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .padding(16)
    }
}

固定的数字集合通过 id: \.self 为每个按钮提供唯一且稳定的标识符。布局通过子视图代理计算几何信息;选中状态保存在视图状态中,且只会在按钮操作中改变。Apple 文档指出,ForEach 要求元素可识别,或提供显式标识符。ForEach — Apple

ScrollView 可以容纳更多行。该布局会计算内容的自然高度,而不会压缩各行以适应建议高度。较高的文字标签会增加所在行的高度,后续行也会相应下移。较矮的子视图会在该行顶部对齐。

检查窗口缩放、窄宽度和布局生命周期

构建并运行 macOS 目标,然后水平调整窗口大小。以下预期结果使用网格的可用内容宽度,也就是外围内边距之后的宽度。假设内容包含介绍卡片和十一个按钮,共十二个子视图。

网格内容宽度 列数 首个单元格宽度 总行数
96 点 1 96 点 12
200 点 2 200 点 7
304 点 3 200 点 5
408 点 4 200 点 4

由于窗口设有最小宽度,测试 96 点的情况需要使用单独的窄容器,或暂时调低窗口的最小宽度。还要测试空布局、仅包含介绍卡片的布局,以及更长的介绍标签。空内容会返回零尺寸;只有一个首个卡片时,它可以占据可用的两个槽位,无需另一个子视图。

已使用确定性的 Python 计算检查上面四种宽度下的排列算术,并检查了槽位重叠和列溢出情况。研究环境中没有 Swift 编译器,因此这里只检查了 SwiftUI 应用,未进行编译或运行。改编此示例前,请先在 Xcode 中确认视觉效果。

SwiftUI 可能会针对不同建议反复调用 sizeThatFits,然后才确定尺寸。测量过程应避免更改选中状态、放置子视图或执行其他应用操作。这个示例使用默认的空缓存并重新计算布局方案,因此先前的宽度不会留下过期的行位置。Apple 在 sizeThatFits(proposal:subviews:cache:) 中介绍了这种反复测量行为。

扩展前先了解适用范围

96 点的最小宽度是一项有意设定的布局规则。如果父视图提供的宽度更小,布局仍会报告 96 点,因此可能超出分配的宽度。这里提供的窗口最小宽度可以避免这种情况。如需支持更窄的容器,请降低最小列宽,并检查相应宽度下的标签。

尺寸建议是一种请求:子视图可以选择不同的尺寸。示例中的卡片经过设计,会接受分配给它们的宽度。若替换为坚持使用更大固定宽度的内容,就可能发生重叠,因此在引入此类子视图前应调整尺寸规则。place(at:anchor:proposal:) — Apple

这个实现还采用从左到右的物理方向放置,并测量所有子视图。它没有实现随语言方向变化的排序,也没有实现延迟创建。对于大得多的集合,应先分析性能,再选择其他容器。Apple 建议在性能分析表明确有显著提升时,才从常规网格改用懒加载网格;但这种改动仍需重新考虑跨列需求。Grid — Apple

对于小型 Mac 调色板,共用规划函数让行为易于调整:修改列最小宽度或间距,保持测量和放置一致,并保留单列回退方案。这样,只要空间允许,第一个卡片就会保持较宽,其余按钮则会流入下一个可用槽位。

来源