Для переиспользуемой панели SwiftUI в приложении для Mac укажите основной билдер содержимого первым, присвойте остальным билдерам метки и используйте специализированные инициализаторы для пропущенных областей. Так вызовы остаются читаемыми, а отсутствующие заголовки и нижние колонтитулы получают явные типы.
В этом руководстве мы создадим небольшой компонент с областями содержимого, заголовка и нижнего колонтитула. Нужно поддержать четыре полезных варианта вызова: только содержимое, содержимое с любой из дополнительных областей и содержимое со всеми областями — без зависимости от конкурирующих замыканий со значениями по умолчанию. Все отображаемые значения — синтетический локальный текст.
Что делает инициализатор с несколькими билдерами однозначным?
Синтаксис Swift с несколькими замыкающими в конце вызова опускает метку аргумента первого такого замыкания и требует метки для последующих замыканий. Поэтому назначение первого билдера компонента должно быть понятно из имени компонента, а последующие билдеры должны иметь разные метки. Этот синтаксис появился в Swift 5.3. SE-0279: Несколько замыканий в конце вызова
Для этой панели полный инициализатор — init(content:header:footer:). В месте вызова первый блок задаёт содержимое, а метки header: и footer: обозначают остальные блоки. Порядок их объявления определяет порядок аргументов, а body компонента — порядок отображения. Поэтому содержимое может быть первым аргументом, хотя заголовок отображается над ним.
В текущем интерфейсе Apple структура Section использует такую же схему с содержимым на первом месте. Её полный инициализатор принимает три замыкания-билдера, а отдельные инициализаторы поддерживают варианты с отсутствующими областями. Это полезный пример проектирования интерфейса; у панели ниже собственные компоновка и поведение. Apple: Section, Apple: init(content:header:footer:)
Атрибут билдера и метка параметра решают разные задачи. Атрибут билдера результата преобразует тело предоставленного замыкания, а метки аргументов определяют, как вызывающий код обозначает параметры. Добавление @ViewBuilder не возвращает метку первого аргумента, опущенную синтаксисом замыкания в конце вызова. SE-0289: Билдеры результата
Из этого различия следует практическое правило проектирования: избегайте однотипных инициализаторов, единственное полезное отличие которых исчезает, когда метка первого замыкания в конце вызова опускается. Например, два во всём остальном эквивалентных инициализатора init(content:header:) и init(body:header:) выглядели бы как одно и то же первое замыкание без метки, за которым следует header:. Это следствие правил для замыканий в конце вызова, а не повод добавлять больше аннотаций билдера.
Какие ограничения компилятора и SwiftUI важно учитывать?
Учитывайте три детали: компилятор, выбранный режим языка Swift и SDK SwiftUI. Несколько замыканий в конце вызова появились в Swift 5.3, а формальная возможность построения результата была реализована в Swift 5.4. Эти версии обозначают поддержку языка, но не гарантируют доступность каждого инициализатора фреймворка. SE-0279, SE-0289
Текущая документация Apple также задаёт границу для версии инструментов: в Xcode 26 и более ранних версиях используется установленное поведение построения представлений, а в Xcode 27 и более поздних эти билдеры создают типонезависимое содержимое. Для нового поведения Apple рекомендует запись @ContentBuilder. ContentBuilder — это псевдоним типа для ViewBuilder; допустимое содержимое по-прежнему определяется ограничениями внешнего API. Apple: ViewBuilder, Apple: ContentBuilder
Полный пример ниже использует запись @ViewBuilder для совместимости с Xcode 26 и рассчитан на macOS 11 или новее. При переходе на стиль интерфейса, задокументированный для Xcode 27, замените аннотации параметров инициализатора на @ContentBuilder. Сохраните три ограничения обобщённого типа View: этот компонент размещает все три области внутри компоновки представления.
Не указывайте тип кортежа, создаваемого билдером, в контракте компонента. Apple описывает несовместимости исходного кода, связанные с явными ограничениями TupleView при переходе на новую реализацию билдера. Обобщённые типы областей позволяют этой панели обойтись без такой зависимости. Apple: TN3211, устранение несовместимостей исходного кода SwiftUI для State и ContentBuilder
Создайте панель с четырьмя явными вариантами инициализатора
Используйте отдельный обобщённый тип для каждой области. Повторное использование одного параметра обобщённого типа потребовало бы, чтобы области имели одинаковый конкретный тип. Это излишнее ограничение, если одна область содержит одно текстовое представление, а другая — несколько.
Для отсутствующей области специализируйте её параметр обобщённого типа на EmptyView. Apple точно так и описывает назначение EmptyView: представлять отсутствующий дочерний элемент в обобщённом типе представления. Приведённые ниже инициализаторы с ограничениями явно задают этот тип вместо того, чтобы заставлять вызывающий код выводить неопределённый тип дополнительной области. Apple: EmptyView
Два блока кода Swift в этом разделе составляют один полный файл приложения. В целевом приложении SwiftUI для macOS замените ими сгенерированные объявления приложения и представления содержимого, оставив ровно одну декларацию @main. Первый блок определяет компонент и все четыре варианта инициализатора.
import SwiftUI
struct BuilderPanel<Content: View, Header: View, Footer: View>: View {
private let content: Content
private let header: Header
private let footer: Footer
init(
@ViewBuilder content: () -> Content,
@ViewBuilder header: () -> Header,
@ViewBuilder footer: () -> Footer
) {
self.content = content()
self.header = header()
self.footer = footer()
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
header
content
footer
}
.padding()
}
}
extension BuilderPanel where Footer == EmptyView {
init(
@ViewBuilder content: () -> Content,
@ViewBuilder header: () -> Header
) {
self.init(
content: content,
header: header,
footer: { EmptyView() }
)
}
}
extension BuilderPanel where Header == EmptyView {
init(
@ViewBuilder content: () -> Content,
@ViewBuilder footer: () -> Footer
) {
self.init(
content: content,
header: { EmptyView() },
footer: footer
)
}
}
extension BuilderPanel where Header == EmptyView, Footer == EmptyView {
init(@ViewBuilder content: () -> Content) {
self.init(
content: content,
header: { EmptyView() },
footer: { EmptyView() }
)
}
}
Полный инициализатор вызывает каждый билдер один раз при инициализации значения панели и сохраняет возвращённые значения. Вспомогательные инициализаторы делегируют вызов ему. Это особенность данной реализации: она не гарантирует, что билдер будет вызван только один раз за всё время существования интерфейса, поскольку создание нового значения панели снова вызывает его инициализатор.
Добавьте второй блок в тот же файл. В нём проверяются все поддерживаемые варианты, в том числе несколько инструкций в одном билдере.
struct PanelDemo: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
BuilderPanel {
Text("Canvas A")
}
BuilderPanel {
Text("Canvas B")
Text("Grid: 12 columns")
} header: {
Text("Layout")
}
BuilderPanel {
Text("Canvas C")
} footer: {
Text("Units: points")
}
BuilderPanel {
Text("Canvas D")
} header: {
Text("Preview")
} footer: {
Text("Scale: 100 percent")
}
}
.padding()
}
.frame(minWidth: 360, minHeight: 440)
}
}
@main
struct BuilderPanelDemoApp: App {
var body: some Scene {
WindowGroup {
PanelDemo()
}
}
}
Соберите и запустите приложение в Xcode, чтобы проверить контракт с выбранным SDK. Пример был проверен визуально, но не компилировался и не запускался: в исследовательской среде не было ни компилятора Swift, ни SDK SwiftUI для macOS.
Сопоставьте каждый вызов с конкретным контрактом типов
Полезный ориентир для выбора — таблица форм вызова. В ней указано, какой инициализатор должен выбираться и какой конкретный тип получают отсутствующие области. Это ожидания, выведенные из объявлений в примере; их можно проверить в своём проекте.
| Переданные области | Метки инициализатора | Ограничения для отсутствующих областей |
|---|---|---|
| Только содержимое | content: |
Header == EmptyView, Footer == EmptyView |
| Содержимое и заголовок | content:header: |
Footer == EmptyView |
| Содержимое и нижний колонтитул | content:footer: |
Header == EmptyView |
| Все три области | content:header:footer: |
Нет |
Для каждого более короткого инициализатора обязательны все объявленные в нём замыкания. Благодаря отсутствию аргументов со значениями по умолчанию трёхбилдерный инициализатор не конкурирует за вызов только с содержимым. Варианты только с заголовком и только с нижним колонтитулом различаются метками второго аргумента, даже если оба замыкания возвращают Text.
Используйте эту таблицу при изменении компонента: если новый инициализатор принимает уже существующую форму вызова, проверьте, останется ли различие заметным после опускания первой метки замыкания в конце вызова. Так вы обнаружите пересечение интерфейсов до того, как оно приведёт к трудной для понимания диагностике в более крупном представлении.
Зачем избегать нескольких билдера-замыканий со значениями по умолчанию?
Аргументы по умолчанию полезны, но несколько параметров-замыканий со значениями по умолчанию добавляют правила сопоставления, зависящие от режима языка Swift. В SE-0286 описано сопоставление с просмотром вперёд — эвристика совместимости для пропуска параметров со значениями по умолчанию, — а также запасной вариант сопоставления с просмотром назад в старых режимах. Там же описан флаг будущей возможности ForwardTrailingClosures, реализованный в Swift 5.8. SE-0286: Сопоставление замыканий в конце вызова с просмотром вперёд
Практическая рекомендация здесь уже этих правил: небольшой переиспользуемой панели можно не зависеть от них. Сделайте основной билдер обязательным, а поддерживаемые варианты без дополнительных областей выразите через специализированные инициализаторы. Так поддерживаемые комбинации будут видны в объявлениях, и предполагаемый смысл компонента не будет зависеть от поведения сопоставления, чувствительного к версии компилятора.
Отсутствующая дополнительная область и пустое замыкание-билдер для неё — разные варианты интерфейса. Инициализатор только для содержимого явно задаёт EmptyView для обеих дополнительных областей. Пустой билдер передаёт определение результата замыкания реализации билдера в SDK. В этом компоненте пропускайте неиспользуемые области и позволяйте вспомогательному инициализатору задавать документированный тип отсутствия.
У этого подхода есть предел масштабирования. Три области дают четыре полезные комбинации, если содержимое всегда обязательно. Для компонента со многими независимо необязательными областями потребовалось бы гораздо больше перегруженных вариантов. Прежде чем добавлять их, решите, какие комбинации действительно нужны читателям: меньшим числом значимых вариантов компонента может быть проще пользоваться и их проще поддерживать.
Как диагностировать неоднозначный вызов?
Начните с самого простого вызова панели, на котором воспроизводится проблема. Затем выполните следующие шаги: так вы отделите ошибки сопоставления аргументов от ошибок внутри билдера.
- Укажите все аргументы-замыкания в скобках. Явно напишите
content:,header:иfooter:в порядке объявления. Если вызов заработает, проверьте, какое различие исчезло из-за синтаксиса замыканий в конце вызова. - Временно замените содержимое каждой области одним выражением
Text. Если после этого вызов работает, возвращайте исходные выражения по одному, чтобы найти проблемный блок билдера. - Сравните формы инициализаторов. Ищите пересекающиеся значения по умолчанию, обратный порядок замыканий и перегруженные варианты, различающиеся только меткой первого билдера.
- Проверьте ограничения для отсутствующих областей. Для каждой опущенной обобщённой области в выбранном инициализаторе должен быть предусмотрен конкретный тип.
- Проверьте границу версии инструментов. После перехода на Xcode 27 проверьте явные ограничения на результаты билдеров и совпадающие имена типов, прежде чем переделывать набор инициализаторов.
Метки в скобках сохраняют имена аргументов, которые опускаются у первого замыкания в конце вызова. Swift поддерживает обе формы, поэтому полностью размеченная запись полезна для диагностики; её также уместно оставить в итоговом коде, если она делает конкретный вызов понятнее. Язык программирования Swift: Замыкания
Для диагностики в новых SDK Apple рекомендует указывать модуль для типов, имена которых совпадают в разных модулях, и обновлять ограничения, зависящие от устаревших типов выходных кортежей. Эти проблемы могут возникнуть внутри вызова хорошо спроектированного инициализатора; добавление новых перегруженных вариантов не устранит их первопричину. Apple: TN3211
Понятный контракт инициализатора начинается с вызовов, которые должен поддерживать компонент. Сначала укажите основное содержимое, присвойте дополнительным билдерам осмысленные метки и задайте явные типы отсутствующим областям. Затем соберите все поддерживаемые формы вызова с выбранными компилятором, режимом языка и SDK, прежде чем расширять интерфейс.
Источники
- Несколько замыканий в конце вызова — SE-0279 — Swift Project.
- Сопоставление замыканий в конце вызова с просмотром вперёд — SE-0286 — Swift Project.
- Билдеры результата — SE-0289 — Swift Project.
- Замыкания — Язык программирования Swift — Swift Project.
- Section — Apple.
- init(content:header:footer:) — Apple.
- ViewBuilder — Apple.
- ContentBuilder — Apple.
- EmptyView — Apple.
- TN3211: устранение несовместимостей исходного кода SwiftUI для State и ContentBuilder — Apple.