Чтобы один делегат мог обрабатывать несколько типов компонентов SwiftUI, сделайте метод обратного вызова обобщённым и не связывайте компонент с конкретным типом делегата. В этом руководстве создаётся небольшой macOS-переключатель плиток, который сохраняет тип содержимого отправителя без рекурсивного требования к Self.
Сначала выберите контракт обратного вызова, затем синтаксис
Задача читателя конкретна: создать переиспользуемый компонент SwiftUI с изменяемым содержимым, при этом один делегат получает уведомления о выборе от всех поддерживаемых специализаций содержимого. В примере используются две условные плитки — текст и круг — и отображается идентификатор выбранной плитки.
В Swift делегирование определяется как основанный на протоколах способ передать классу или структуре ответственность другому экземпляру. Делегат может получить действие вместе с его отправителем, как показано в примере делегирования в руководстве по языку. Протоколы
Для этого компонента нужен такой контракт: «Для каждого типа содержимого, соответствующего View, делегат может получить плитку с этим содержимым». Поместите параметр типа в метод обратного вызова. Обобщённые функции Swift выбирают конкретные аргументы типа при каждом вызове, тогда как ассоциированный тип задаётся соответствием протоколу. Это разные обязательства. Обобщения
| Требование | Подходящая форма интерфейса | Следствие |
|---|---|---|
| Один делегат обрабатывает все поддерживаемые типы содержимого | Обобщённый метод обратного вызова | У каждого обратного вызова может быть свой тип содержимого. |
| Каждый делегат обрабатывает один фиксированный тип содержимого | Ассоциированный тип | Каждое соответствие задаёт один тип содержимого. |
| Обратному вызову нужен только идентификатор плитки | Обратный вызов, принимающий идентификатор | Обратный вызов не зависит от типа отображения. |
Эта таблица помогает выбрать проектное решение на основе описанных правил языка. Выберите первую строку, если сохранение типизированного отправителя — часть API. Выберите третью, если получателю достаточно идентификатора отправителя: передача всего компонента тогда добавила бы ненужную зависимость.
Устраните зависимость от собственного типа делегата
В тесно связанном проекте компонент может быть обобщён по типу делегата, а затем в протоколе делегата на него ссылаются как на Component<Self>. Здесь Self означает окончательный тип, соответствующий протоколу. Ассоциированные типы также относятся к этому соответствующему типу. Объявления
При такой структуре проверка обратного вызова зависит от специализации компонента, которая сама зависит от делегата. Чтобы определить, принимается ли конкретное рекурсивное объявление, нужно рассмотреть все его ограничения и сообщения компилятора. Было бы неверно утверждать, что Swift отвергает любые рекурсивные отношения протоколов: SE-0157 добавил рекурсивные ограничения ассоциированных типов в Swift 4.1. Поддержка рекурсивных ограничений для ассоциированных типов
Для выбранной задачи конкретный тип делегата не должен входить в тип компонента. В приведённом ниже проекте у Tile один обобщённый параметр — Content. Делегат хранится как any TileDelegate, а требование протокола объявляет собственный параметр Content на уровне метода. Обратный вызов сохраняет тип содержимого отправителя, не выражая его через Self делегата.
Написание any явно обозначает экзистенциальное значение: хранимое значение может содержать экземпляр типа, соответствующего протоколу. Такое написание появилось в Swift 5.6 благодаря SE-0335. Использование экзистенциального типа для делегата не требует стирать тип содержимого плитки до AnyView. Введение existential any
Создайте полноценный переключатель плиток для macOS
Используйте целевой объект приложения SwiftUI для macOS с компилятором Swift 6, например с компилятором из Xcode 16 или новее. Установите минимальную версию macOS 11.0 или новее, чтобы использовать хранилище @StateObject в этом примере. Согласно документации Apple, StateObject доступен начиная с macOS 11.0. StateObject
Замените сгенерированное объявление приложения и начальное представление этим единственным файлом исходного кода. Убедитесь, что целевой объект содержит только одно объявление приложения @main: в Swift допускается только один способ задать точку входа программы. Объявления
import SwiftUI
import Combine
@MainActor
protocol TileDelegate: AnyObject {
func didChoose<Content: View>(_ tile: Tile<Content>)
}
@MainActor
struct Tile<Content: View>: View {
let id: String
let delegate: any TileDelegate
let content: Content
init(
id: String,
delegate: any TileDelegate,
@ViewBuilder content: () -> Content
) {
self.id = id
self.delegate = delegate
self.content = content()
}
var body: some View {
Button(action: {
delegate.didChoose(self)
}) {
VStack(spacing: 8) {
content
.frame(width: 100, height: 50)
Text("Choose \(id)")
}
.padding(8)
}
}
}
@MainActor
final class SelectionModel: ObservableObject, TileDelegate {
@Published private(set) var selection = "No tile selected"
func didChoose<Content: View>(_ tile: Tile<Content>) {
selection = "Selected: \(tile.id)"
}
}
@MainActor
struct ChooserView: View {
@StateObject private var model = SelectionModel()
var body: some View {
VStack(spacing: 16) {
HStack(spacing: 16) {
Tile(id: "text", delegate: model) {
Text("Sample")
}
Tile(id: "circle", delegate: model) {
Circle()
}
}
Text(model.selection)
}
.padding(24)
.frame(minWidth: 340, minHeight: 180)
}
}
@main
@MainActor
struct DelegateDemoApp: App {
var body: some Scene {
WindowGroup {
ChooserView()
}
}
}
Это полноценный пример приложения, включающий импорты, делегат, компонент, модель и точку входа. Код был изучен, но не компилировался и не запускался, поскольку в доступной среде нет компилятора Swift и SDK для macOS.
API Button от Apple принимает действие и метку, создающую представление. В этом примере щелчок по плитке вызывает делегат из действия кнопки; вычисление body создаёт интерфейс, не отправляя событие выбора. Button
Соберите и запустите приложение на Mac. Изначально в статусе должно отображаться No tile selected. После нажатия Choose text должно появиться Selected: text, а после нажатия Choose circle — Selected: circle. Это ожидаемые результаты примера, а не результаты фактического запуска.
Разберитесь, что сохраняет пример
Первая плитка имеет тип Tile<Text>, а вторая — Tile<Circle>. Обе используют один экземпляр SelectionModel. Его обобщённый метод обратного вызова удовлетворяет требованию протокола для любого из этих типов содержимого и при этом читает общее свойство id. В этом и состоит практическое преимущество размещения обобщённого параметра в методе.
Получатель также может обратиться к tile.content как к Content. Однако внутри обобщённой реализации объявленное ограничение гарантирует только возможности View. Для кода, которому нужна операция, специфичная для текста, потребуется другой контракт или дополнительные сведения: нельзя предполагать, что каждое значение содержимого — это Text. Это следует из модели обобщений Swift, основанной на ограничениях. Обобщения
Компонент реализует body с помощью some View, следуя задокументированному Apple шаблону пользовательского представления. Текущее объявление View также изолировано главным актором. В примере протокол и типы, работающие с интерфейсом, явно помечены @MainActor, чтобы обратный вызов и обновление модели использовали одну область изоляции. View
@StateObject обеспечивает стабильное хранилище модели на протяжении всего времени существования идентичности содержащего представления. Изменения опубликованного выбора в модели обновляют зависимое представление статуса. Перенос создания модели в обычное вычисляемое выражение уже не выражал бы это владение через StateObject. StateObject
В примере хранится сильная ссылка на обязательный делегат. Его модель хранит только строку выбора, поэтому в показанном графе объектов нет ссылки обратно на плитку. Если позднее модель начнёт хранить отправителей, пересмотрите владение. В руководстве по языку Apple демонстрируется делегат, ограниченный классом и хранимый слабо, когда этого требует связь между объектами. Протоколы
Когда лучше использовать контракт с ассоциированным типом
Предположим, что получатель должен принимать только текстовые плитки. Альтернативный протокол может объявить associatedtype Content: View и требовать func didChoose(_ tile: Tile<Content>). Это задаёт один тип содержимого для каждого соответствия. Объявление Content основным ассоциированным типом упрощает синтаксис ограничений, но не превращает требование в метод, принимающий любой тип содержимого. SE-0346 добавил основные ассоциированные типы в Swift 5.7. Упрощённые требования равенства типов для основных ассоциированных типов
Это был бы другой вариант API. Также пришлось бы изменить свойство делегата и параметр инициализатора компонента, указав подходящий ограниченный экзистенциальный тип, например any FixedTileDelegate<Content>, а каждый получатель должен был бы задать свой фиксированный ассоциированный тип и соответствующий необобщённый метод обратного вызова. Соответствие только для текста больше не могло бы обслуживать плитку-круг в рамках того же соответствия.
SE-0353 добавил ограниченные экзистенциальные типы в Swift 5.7, что позволяет привязать основные ассоциированные типы экзистенциального типа к заданным типам. Привязка одного ассоциированного типа всё равно описывает фиксированное отношение. Она не предоставляет набор реализаций обратного вызова для произвольных типов содержимого. Ограниченные экзистенциальные типы
Разделяйте поддержку компилятора и поддержку целевой версии ОС. В SE-0353 явно указаны изменения среды выполнения, несовместимые с обратным развёртыванием на существующих версиях ОС. Прежде чем использовать альтернативу при старой минимальной версии ОС, проверьте диагностику доступности в фактическом целевом объекте. В полном примере выше используется обычный экзистенциальный тип делегата, и для хранения не требуются ограниченные экзистенциальные типы. Ограниченные экзистенциальные типы
Разбирайте ошибки адаптации в полезном порядке
- Проверьте предполагаемую область действия обратного вызова. Если один делегат должен обрабатывать текстовые плитки и плитки-круги, его реализация протокола должна сохранять сигнатуру обобщённого метода. Метод, принимающий только
Tile<Text>, этому требованию не соответствует. - Проверьте параметры обобщения компонента. В этом проекте
Tileзависит отContent; его тип не включает конкретный тип делегата. - Проверьте изоляцию акторов. Выполняйте обратные вызовы интерфейса и показанную наблюдаемую модель на главном акторе. Вызовы из другого контекста изоляции должны учитывать эту границу.
- Проверьте место отправки события. Отправляйте событие выбора из действия кнопки, а не при вычислении
bodyили инициализации представления. - Проверьте владение моделью. Сохраняйте
@StateObjectв содержащем представлении и не создавайте для обратного вызова второй экземпляр модели.
Если адаптация по-прежнему не работает, сведите пример к одной плитке и одной реализации делегата, затем добавьте второй тип содержимого. Так можно отличить проблему соответствия протоколу от проблемы композиции представлений. Два разных типа содержимого в примере позволяют проверить контракт: оба должны попадать в один и тот же обобщённый метод обратного вызова без приведений типов и стирания типа содержимого.
Главное проектное решение — определить, где должны находиться вариации типов. Если делегат обрабатывает каждую специализацию компонента, разместите эту вариацию в методе обратного вызова и оставьте тип компонента независимым от конкретного типа делегата. Такой API сохраняет типизированного отправителя и оставляет поток выбора в macOS достаточно простым для анализа и расширения.
Источники
- Обобщения — Язык программирования Swift
- Протоколы — Язык программирования Swift
- Объявления — Язык программирования Swift
- Поддержка рекурсивных ограничений для ассоциированных типов — Swift Evolution
- Введение existential any — Swift Evolution
- Упрощённые требования равенства типов для основных ассоциированных типов — Swift Evolution
- Ограниченные экзистенциальные типы — Swift Evolution
- View — Документация Apple для разработчиков
- StateObject — Документация Apple для разработчиков
- Button — Документация Apple для разработчиков