Объявите содержимое, предоставляемое вызывающей стороной, как (Input) -> Content, ограничив Content протоколом View. Это позволяет компоненту macOS передавать типизированное значение, а вызывающей стороне — выбирать способ его отображения. Контракт требует принять это значение, но не требует его использовать.

В этом руководстве создаётся простое настольное приложение для предварительного просмотра цветового образца. Переиспользуемый компонент содержит элемент управления размером и передаёт вызывающей стороне синтетическую конфигурацию для замыкания отрисовки. Вызывающая сторона рисует образец, не управляя внутренним состоянием компонента.

Какой интерфейс выражает это требование?

Используйте именованный параметр обобщённого типа для возвращаемого представления и конкретный тип для входных данных компонента. В примере ниже основная сигнатура — @ViewBuilder content: @escaping (SwatchInput) -> Content в объявлении с ограничением Content: View.

У входных и выходных данных разные задачи. SwatchInput определяет значения, которые передаёт компонент. Content представляет конкретный тип представления, создаваемого замыканием каждой вызывающей стороны. Именованные параметры обобщённых типов позволяют объявлению выражать эти типовые связи и повторно использовать их, как описано в главе Swift о generics.

Apple использует этот шаблон интерфейса в GeometryReader: его инициализатор принимает замыкание типа (GeometryProxy) -> Content. Контейнер передаёт прокси-объект, а вызывающая сторона создаёт на его основе содержимое. Это полезный пример для пользовательского компонента, передающего собственную конфигурацию. См. документацию Apple по GeometryReader и init(content:).

Выбирайте контракт в соответствии с реальной задачей:

Требование Подходящий интерфейс
Вызывающая сторона предоставляет содержимое без входных данных от компонента () -> Content
Компонент предоставляет определённую конфигурацию (SwatchInput) -> Content
Контейнер получает значение, тип которого меняется в разных случаях использования Именованный параметр обобщённого типа Input с соответствующим замыканием (Input) -> Content
Компилятор должен гарантировать, что вызывающая сторона использует конфигурацию Сигнатура замыкания не выражает это требование

Эти варианты следуют из типов функций Swift: типы параметров и возвращаемого значения описывают, что можно передать и вернуть. Они не описывают, как реализация должна использовать аргументы. См. раздел Function Type в справочнике по языку Swift.

Создание готового предварительного просмотра образца для macOS

Используйте целевой объект приложения macOS с SwiftUI. В примере используется принятая запись @ViewBuilder; он рассчитан на Swift 5.7 или новее и целевую версию macOS 12 или новее. В нём не используются макросы предварительного просмотра и более новые имена построителей содержимого. В исследовательской среде нет Swift и SDK Apple, поэтому код был изучен, но здесь не компилировался и не запускался.

В проекте приложения SwiftUI в Xcode замените объявление приложения, созданное автоматически, следующей полной программой. Удалите созданное автоматически объявление ContentView, если оно дублирует это. Оставьте ровно одну точку входа приложения, помеченную @main. В справке Apple по Xcode описано, как начать с проекта SwiftUI или добавить файл SwiftUI в существующий проект.

import SwiftUI

struct SwatchInput {
    let title: String
    let side: CGFloat
    let color: Color
}

@MainActor
struct SwatchPanel<Content: View>: View {
    @State private var isLarge = false
    private let content: (SwatchInput) -> Content

    init(
        @ViewBuilder content: @escaping (SwatchInput) -> Content
    ) {
        self.content = content
    }

    private var input: SwatchInput {
        SwatchInput(
            title: "Sample A",
            side: isLarge ? 120 : 64,
            color: .blue
        )
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            Toggle("Large sample", isOn: $isLarge)
            content(input)
        }
        .padding(24)
    }
}

@MainActor
struct ContentView: View {
    var body: some View {
        SwatchPanel { input in
            Text(input.title)
                .font(.headline)

            RoundedRectangle(cornerRadius: 12)
                .fill(input.color)
                .frame(width: input.side, height: input.side)

            if input.side == 120 {
                Text("Large preview")
                    .font(.caption)
            }
        }
        .frame(width: 300, height: 280, alignment: .topLeading)
    }
}

@main
@MainActor
struct TypedContentDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

В исходном состоянии на экране отображаются переключатель Large sample, заголовок Sample A и синий скруглённый квадрат со стороной 64 пункта. Включение переключателя меняет передаваемую длину стороны на 120 пунктов и добавляет надпись Large preview. Эти размеры и подписи — иллюстративные локальные значения, а не результаты измерений.

Компонент владеет isLarge. Вычисляемое свойство input упаковывает текущее состояние выбора, а content(input) передаёт этот пакет вызывающей стороне. SwiftUI управляет хранением состояния и обновляет те части иерархии представлений, которые зависят от изменившегося значения; это описано в документации о State.

За отображение отвечает вызывающая сторона: она задаёт заголовок, фигуру и необязательную подпись. Она может заменить композицию, сохранив тот же типизированный ввод. И панель, и её вызывающая сторона реализуют body для композиции своих интерфейсов, следуя описанной Apple модели View.

Зачем нужны аннотации построителя и escaping?

@ViewBuilder позволяет вызывающей стороне записать несколько выражений представлений и поддерживаемые условные ветви в одном замыкании. В этом примере вызывающая сторона предоставляет два безусловных представления и одну условную подпись. Построитель объединяет это содержимое в результат, удовлетворяющий ограничению на возвращаемый тип компонента. Несколько дочерних представлений и условное построение описаны Apple в документации по ViewBuilder.

Построитель не передаёт SwatchInput. Входные данные объявляются обычным параметром замыкания, а content(input) передаёт фактический аргумент. Если бы инициализатор принимал () -> Content, добавление аннотации построителя не превратило бы его в замыкание, принимающее конфигурацию.

@escaping необходима, потому что инициализатор сохраняет замыкание в свойстве, чтобы позже вызвать его из body. В главе Swift о closures объясняется, что замыкание считается escaping, если его можно вызвать после возврата принимающей функции.

Если конфигурация компонента окончательно задаётся при инициализации, можно выбрать другой вариант: немедленно вызвать неescaping-построитель и сохранить полученное значение Content. Это меняет момент применения конфигурации. Здесь замыкание сохраняется, чтобы явно показать предполагаемый поток данных: вывести текущие входные данные из состояния компонента, а затем построить содержимое с их использованием.

Соблюдение границы версий построителей в Xcode

В актуальной документации Apple различаются Xcode 26 и более ранние версии, с одной стороны, и Xcode 27 и новее — с другой. Для ранних версий инструментов в ней указано, что ViewBuilder строит представления. Для Xcode 27 и новее описано построение без привязки к типу и рекомендуется псевдоним ContentBuilder, указывающий на такое расширенное поведение. См. ViewBuilder.

При сопровождении кодовой базы для Xcode 27 или новее инициализатор может использовать @ContentBuilder. Apple описывает его как псевдоним типа для ViewBuilder; соответствие обеспечивается типами создаваемого содержимого. Для этого компонента сохраните ограничение Content: View: результат по-прежнему предназначен для иерархии представлений. Не предполагайте, что новая запись доступна в старых SDK только потому, что в актуальной документации указаны старые целевые версии развёртывания. См. ContentBuilder.

Почему some View не решает все части этого контракта?

some View подходит для свойств body в этом примере. Оно скрывает конкретный тип результата, сохраняя обещание, что результат является представлением. В сохранённом замыкании вместо этого используется именованный параметр Content, чтобы компонент мог ссылаться на один и тот же тип результата в своём свойстве и инициализаторе.

В Swift 5.7 благодаря SE-0341 появились объявления непрозрачных параметров. Прямой параметр, например _ value: some View, вводит безымянный параметр обобщённого типа, конкретный тип которого определяется вызывающей стороной. Это не означает, что реализация функции может выбрать произвольное представление и передать его туда, где ожидается этот безымянный тип. См. Opaque Parameter Declarations.

В частности, параметр обратного вызова, записанный как (some View) -> Content, находится вне поддерживаемых позиций непрозрачных параметров, описанных в SE-0341. В предложении явно запрещены непрозрачные типы во входной позиции типа функции обратного вызова. Возможность такого синтаксиса обсуждается в разделе будущих направлений, а не как реализованное поведение. В актуальном справочнике Types это ограничение сохраняется.

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

Какие гарантии действительно даёт компилятор?

Сигнатура гарантирует форму вызываемого объекта: один аргумент SwatchInput и результат, удовлетворяющий Content: View. Она также позволяет вывести тип параметра замыкания, поэтому вызывающая сторона может обращаться к input.title, input.side и input.color, не указывая тип повторно. Вывод типа из контекста замыкания описан в документации Swift о closures.

Компилятор не может гарантировать, что эти свойства повлияют на отображение. Например, это допустимое выражение для замены внутри ContentView.body; оно намеренно принимает конфигурацию, но игнорирует её:

SwatchPanel { _ in
    Text("Fixed preview")
}

Это различие важно при формулировании требований к компоненту. «Содержимое вызывающей стороны принимает SwatchInput» — это контракт типов. «Содержимое вызывающей стороны отображает переданный размер» — поведенческое требование. Если отображение этого размера необходимо для работы компонента, включите его отрисовку в сам компонент и предоставьте меньшую область для настройки.

Используйте следующие проверки для диагностики проблем интеграции. Это шаги проверки, выведенные из интерфейса, а не заявленные результаты тестирования компилятора:

Симптом Проверка
При вызове content не хватает аргумента Передайте конфигурацию: content(input).
Диагностика об escaping-замыкании в инициализаторе Не удаляйте @escaping, если сохраняете обратный вызов.
При вызове не удаётся использовать несколько соседних представлений Сохраните соответствующую аннотацию построителя параметра инициализатора.
Явно типизированный обратный вызов отвергается Убедитесь, что его входной тип — SwatchInput, а не посторонний тип, например String.
В предварительном просмотре переключатель размера ничего не меняет Проверьте, читает ли отрисовка input.side.
Компонент продолжает показывать исходную конфигурацию Проверьте, не было ли содержимое построено один раз и сохранено вместо вызова обратного вызова с текущими входными данными.

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

Источники