Объявите содержимое, предоставляемое вызывающей стороной, как (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. |
| Компонент продолжает показывать исходную конфигурацию | Проверьте, не было ли содержимое построено один раз и сохранено вместо вызова обратного вызова с текущими входными данными. |
Делайте контракт настолько простым, насколько позволяет задача. Конкретная конфигурация и обобщённый тип результата представления задают чёткую границу для этой панели образца: компонент передаёт текущие значения, вызывающая сторона получает их с полной информацией о типах, а выбранное отображение легко заменить.
Источники
- Generics — Swift
- Types — Swift
- Closures — Swift
- Opaque Parameter Declarations — Swift Evolution
- GeometryReader — Apple
- init(content:) — Apple
- ViewBuilder — Apple
- ContentBuilder — Apple
- View — Apple
- State — Apple
- Show a preview of your user interface — Apple