Используйте собственный Layout в SwiftUI, чтобы выделить первой ячейке два столбца, а затем размещать остальные ячейки в следующих строках. В этом примере для macOS число столбцов пересчитывается при изменении ширины окна, а высота каждой строки определяется её содержимым.

Задача — создать небольшую палитру для настольного приложения: одну вводную плитку, за которой следуют одиннадцать пронумерованных кнопок. Первая плитка шире, если помещаются хотя бы два столбца. При узкой ширине она занимает один столбец, чтобы содержимое могло располагаться вертикально. Все подписи и варианты выбора в примере условные.

Какой контейнер SwiftUI поддерживает такое расположение?

Grid в Apple поддерживает ячейку, занимающую несколько столбцов, с помощью gridCellColumns(_:) внутри GridRow. Однако строки нужно задавать самостоятельно. Представление, помещённое непосредственно в 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. Добавьте файл Swift с именем WideFirstGrid.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 для четырёх указанных выше значений ширины; также проверялось, что места не перекрываются и число столбцов не превышается. Приложение SwiftUI было изучено, но не собрано и не запущено в исследовательской среде, где не было компилятора Swift. Прежде чем адаптировать пример, проверьте визуальное поведение в Xcode.

SwiftUI может многократно вызывать sizeThatFits с разными предложениями размеров, прежде чем выбрать размер. Не изменяйте состояние выбора, не размещайте дочерние элементы и не выполняйте другие действия приложения во время измерения. В этом примере используется пустой кеш по умолчанию, а план пересчитывается каждый раз, поэтому ширина, заданная ранее, не может оставить устаревшие координаты строк. Подробнее о повторных измерениях Apple пишет в документации sizeThatFits(proposal:subviews:cache:).

Учтите ограничения, прежде чем расширять пример

Минимальная ширина 96 пунктов — это заданное правило разметки. Если родитель предлагает меньшую ширину, разметка всё равно сообщает ширину 96 пунктов и может выходить за выделенные границы. Минимальная ширина окна в примере предотвращает такую ситуацию. Чтобы поддержать более узкие контейнеры, уменьшите минимальную ширину столбца и проверьте подписи на этой ширине.

Предложенный размер — это запрос: дочерний элемент может выбрать другой размер. Приведённые плитки рассчитаны на то, чтобы принять выделенную им ширину. Если заменить их содержимым, которое требует большей фиксированной ширины, элементы могут перекрыться, поэтому перед добавлением таких дочерних элементов пересмотрите правила расчёта размеров. place(at:anchor:proposal:) — Apple

Кроме того, эта реализация размещает элементы слева направо и измеряет каждый дочерний элемент. В ней не реализованы порядок, зависящий от направления письма, и ленивое создание элементов. Для значительно больших коллекций сначала оцените производительность. Apple рекомендует переходить от обычной сетки к ленивой, когда профилирование показывает заметный выигрыш в производительности; при таком переходе всё равно нужно заново обдумать требование к объединению столбцов. Grid — Apple

Для небольшой палитры Mac общая функция планирования упрощает адаптацию поведения: измените минимальную ширину столбца или промежуток, обеспечьте согласованность измерения и размещения и сохраните вариант с одним столбцом. Тогда первая плитка останется широкой, когда позволит место, а остальные кнопки будут переходить в следующие доступные ячейки.

Источники