Чтобы хранить разные фигуры SwiftUI в одном множестве, обеспечивая общий интерфейс, используйте конкретную обёртку Hashable с инициализатором, ограниченным протоколом. В этом руководстве создаётся небольшая палитра фигур для Mac с явными правилами обработки дубликатов и вызываемыми операциями над фигурами.
Задача конкретна: собрать синтетические компоненты диска и плитки, отклонять несвязанные значения на этапе компиляции, удалять дубликаты и показывать оставшиеся компоненты в окне SwiftUI. Коллекция принимает несколько типов компонентов через общий протокол, уточняющий протокол Shape из SwiftUI.
Почему множество значений протокола не соответствует этому контракту?
Для элементов Set в Swift требуется соответствие типа Hashable. Объявление о том, что каждое значение внутри экзистенциального контейнера соответствует Hashable, не означает, что сам экзистенциальный тип получает такое соответствие. Поэтому Set<any PaletteShape> не становится допустимым лишь потому, что PaletteShape наследует Hashable. Apple описывает ограничение для элементов коллекции, а Swift Evolution объясняет различие между экзистенциальным контейнером и содержащимся в нём значением, соответствующим протоколу. Set, Implicitly Opened Existentials.
Экзистенциальный тип, например any PaletteShape, может хранить значения разных соответствующих типов. Проблема возникает при сравнении: унаследованное требование Equatable сравнивает два значения одного и того же типа Self, тогда как два экзистенциальных контейнера могут содержать значения разных конкретных типов. Расширенная поддержка экзистенциальных значений в Swift не предоставляет универсальную операцию сравнения для таких контейнеров. Unlock existentials for all protocols.
Эти варианты решают разные части задачи:
| Представление | Разные конкретные типы? | Обеспечивается общий интерфейс? | Подходит в качестве элемента множества? |
|---|---|---|---|
[any PaletteShape] |
Да | Да | Массив не обеспечивает уникальность элементов множества |
Set<S> с S: PaletteShape |
Один выбранный S |
Да | Да, для одного конкретного типа |
Set<AnyHashable> |
Да | Ограничение на тип фигуры отсутствует | Да |
Set<AnyPaletteShape> ниже |
Да, внутри обёрток | Да, при создании | Да |
AnyHashable предоставляет стёртое равенство и хеширование, но его инициализатор принимает любые значения, поддерживающие хеширование. Поэтому одно такое множество может содержать строку или целое число. Приведённая ниже пользовательская обёртка ограничивает добавление значениями, соответствующими протоколу фигур приложения, и сохраняет интерфейс фигуры вместе со стёртым хешируемым значением. AnyHashable.
Что считать дубликатом фигуры?
Определите равенство до реализации обёртки. В этой палитре два компонента считаются дубликатами, если они имеют один и тот же конкретный тип и равные сохранённые значения. Диск с меткой Sample и плитка с меткой Sample остаются разными элементами. Два диска с одинаковой меткой объединяются в один.
Метка — единственное сохранённое свойство каждого тестового компонента, поэтому синтезированные операции равенства и хеширования учитывают её. Это выбор модели для примера: визуальное сходство само по себе не определяет равенство. Apple описывает автоматический синтез Hashable для структур, чьи сохранённые свойства поддерживают хеширование, и требует, чтобы равные значения передавали хешерам одни и те же компоненты. Hashable.
Обёртка также включает идентификатор типа и в равенство, и в хеширование. Это явно закрепляет правило одинакового типа, вместо того чтобы полагаться на предположения о сравнениях стёртых значений. AnyHashable может сравнивать некоторые значения разных базовых типов с помощью канонических представлений; Apple не определяет его как безусловный разделитель конкретных типов. ObjectIdentifier поддерживает метатипы, что позволяет этому примеру идентифицировать входной обобщённый тип. AnyHashable, ObjectIdentifier.
Создайте полный пример палитры для Mac
Используйте в Xcode проект приложения SwiftUI для macOS с компилятором Swift 5.7 или новее. Для воспроизводимой основы выберите режим языка Swift 5 и целевую версию macOS 13.0 или новее. Целевая версия выбрана с запасом для этого руководства; это не означает, что каждому приведённому ниже API требуется macOS 13.
Минимальная версия компилятора важна, поскольку в примере хранится any PaletteShape, унаследованные протоколы которого содержат требования с ассоциированными типами и Self. В SE-0309 эта расширенная поддержка экзистенциальных типов указана как реализованная в Swift 5.7. Для примера не требуется более новая возможность обобщённых соответствий экзистенциальных типов. Unlock existentials for all protocols.
Замените сгенерированные объявление приложения и представление содержимого следующим полным файлом исходного кода. В целевом приложении оставьте только одно объявление @main. В примере есть определения компонентов, обёртка, тестовые значения, проверки, представление и точка входа приложения. В исследовательской среде Swift недоступен, поэтому этот код был проверен визуально, но здесь его не компилировали и не запускали.
import SwiftUI
protocol PaletteShape: Shape, Hashable {
var label: String { get }
}
struct Disk: PaletteShape {
let label: String
func path(in rect: CGRect) -> Path {
Path(ellipseIn: rect)
}
}
struct Tile: PaletteShape {
let label: String
func path(in rect: CGRect) -> Path {
var result = Path()
result.addRect(rect)
return result
}
}
struct AnyPaletteShape: Hashable {
private let base: any PaletteShape
private let typeID: ObjectIdentifier
private let key: AnyHashable
init<S: PaletteShape>(_ value: S) {
base = value
typeID = ObjectIdentifier(S.self)
key = AnyHashable(value)
}
var label: String { base.label }
func path(in rect: CGRect) -> Path {
base.path(in: rect)
}
static func == (
lhs: AnyPaletteShape,
rhs: AnyPaletteShape
) -> Bool {
lhs.typeID == rhs.typeID && lhs.key == rhs.key
}
func hash(into hasher: inout Hasher) {
hasher.combine(typeID)
hasher.combine(key)
}
}
func makePalette() -> Set<AnyPaletteShape> {
var result: Set<AnyPaletteShape> = [
AnyPaletteShape(Disk(label: "Disk")),
AnyPaletteShape(Tile(label: "Tile"))
]
let duplicate = result.insert(
AnyPaletteShape(Disk(label: "Disk"))
)
precondition(!duplicate.inserted)
precondition(result.count == 2)
precondition(result.contains(
AnyPaletteShape(Tile(label: "Tile"))
))
let sameLabel: Set<AnyPaletteShape> = [
AnyPaletteShape(Disk(label: "Sample")),
AnyPaletteShape(Tile(label: "Sample"))
]
precondition(sameLabel.count == 2)
return result
}
struct ContentView: View {
private let shapes = makePalette()
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Shape Palette")
.font(.title)
ForEach(
shapes.sorted { $0.label < $1.label },
id: \.self
) { item in
HStack(spacing: 16) {
GeometryReader { geometry in
item.path(in: CGRect(
origin: .zero,
size: geometry.size
))
.fill(Color.blue)
}
.frame(width: 56, height: 56)
Text(item.label)
}
}
}
.padding(24)
.frame(minWidth: 300, minHeight: 220)
}
}
@main
struct ShapePaletteApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
PaletteShape наследует настоящий протокол Shape из SwiftUI. Диск и плитка реализуют path(in:) — требование к отрисовке, описанное Apple. Для создания эллипса и прямоугольника в реализациях используются документированные операции Path. Квадратная область отображения делает тестовый эллипс круглым. Shape, Path.
Ограниченный инициализатор задаёт правило добавления: init<S: PaletteShape> принимает только типы, соответствующие общему протоколу. Сохранённые свойства закрыты, поэтому вызывающий код не может независимо подменить сохранённую фигуру или ключ равенства. AnyPaletteShape("Disk") должно вызвать ошибку соответствия на этапе компиляции, поскольку String не соответствует PaletteShape; намеренно отклоняемый пример не следует включать в исполняемый файл.
Хотя у множества один конкретный тип элементов, каждая обёртка может хранить фигуру другого конкретного типа. Её члены label и path(in:) перенаправляют операции, нужные этой палитре. Сама обёртка объявляет соответствие Hashable; протоколу Shape она не соответствует.
Как проверить добавление, наличие и порядок отображения?
Запустите приложение в отладочной сборке. Включённые проверки задают три ожидаемых результата: при добавлении второго равного диска inserted возвращает false, поиск находит равную плитку, а компоненты разных типов сохраняются, даже если их метки совпадают. Apple указывает, что insert(_:) возвращает флаг добавления и получившийся элемент. Set.
В ожидаемом окне будут две строки: Disk, затем Tile, обе с синей графикой. Сортировка выполняется отдельно при отображении, поскольку у множества нет определённого порядка элементов. Полученный массив также удовлетворяет требованию ForEach к коллекции с произвольным доступом; id: \.self использует хешируемое значение обёртки в качестве идентификатора строки. Set, ForEach.
Для приведённых значений этого порядка достаточно, поскольку их метки различаются. Если будущие компоненты будут иметь одинаковые метки, добавьте детерминированное дополнительное поле сортировки. Если строки можно редактировать и при изменении меток они должны сохранять идентичность, введите стабильный идентификатор и отдельно решите, должен ли он участвовать в равенстве элементов множества.
Не рассчитывайте, что добавление в множество заменит данные уже существующего равного компонента. Для замены Apple предоставляет update(with:). Это различие важно, если более поздняя модель не учитывает в равенстве свойства представления: при добавлении равного значения может сохраниться прежний элемент вместо обновления его внешнего вида. Set.
Каковы ограничения обёртки?
Хранение с поддержкой Hashable не сохраняет все возможности SwiftUI
Обёртка перенаправляет создание пути для статической палитры. Она не перенаправляет анимируемые данные базовой фигуры, операции вставки внутрь или все члены, унаследованные от View. Поэтому отрисовка возвращённого пути не означает, что стёртая фигура сохраняет анимацию. Протокол Shape в SwiftUI также наследует Animatable и View; реализация одного перенаправляемого метода отрисовки не воспроизводит весь этот контракт. Shape.
Если другому API нужна конкретная стёртая фигура, SwiftUI предоставляет AnyShape, доступный в macOS 13 и новее. Согласно документации, он стирает конкретный тип фигуры; среди перечисленных соответствий у него нет Hashable. Он может использоваться на границе отрисовки, но не определяет политику равенства и не заменяет ограниченный протоколом тип элемента множества, созданный здесь. AnyShape.
Поддержка новых компиляторов не отменяет ограничение на тип элемента множества
SE-0352, реализованный в Swift 5.7, позволяет раскрывать базовый тип экзистенциального значения для подходящих обобщённых вызовов. В текущем тексте также описано поведение в режиме языка Swift 6. Раскрытие значения для вызова не делает any PaletteShape типом Hashable, подходящим для параметра типа элемента множества. Используйте конкретную обёртку в этом подходе для разных поколений компиляторов. Implicitly Opened Existentials.
Расширение модели требует решения о равенстве
В примере используются неизменяемые структуры. Добавляя размеры или другие параметры, хранящиеся в структуре, решите, создаёт ли их изменение новый элемент множества. При синтезированном хешировании будут учитываться дополнительные сохранённые свойства, поддерживающие хеширование. При пользовательской реализации равенства хешируйте в том же порядке ровно те же существенные компоненты; совпадение хешей само по себе никогда не означает равенства. Hashable.
Если набор вариантов компонентов в приложении фиксирован навсегда, можно использовать и перечисление с хешируемыми ассоциированными значениями: так поддерживаемые случаи будут собраны в одном месте. Обёртка удобна, когда новые соответствующие типы компонентов должны добавляться через тот же инициализатор без расширения централизованного списка вариантов. Этот выбор зависит от потребностей приложения в расширении.
В этой палитре для Mac каждому требованию отведено конкретное место: протокол определяет допустимые фигуры, инициализатор контролирует добавление, равенство определяет дубликаты, а перенаправление построения пути обеспечивает отрисовку. Добавляйте новые неизменяемые компоненты, соответствующие протоколу, оборачивая их при добавлении, и расширяйте проверки при каждом изменении смысла дубликата.
Источники
- Set — Apple
- AnyHashable — Apple
- Hashable — Apple
- ObjectIdentifier — Apple
- Shape — Apple
- Path — Apple
- ForEach — Apple
- AnyShape — Apple
- Unlock existentials for all protocols — Swift Evolution
- Implicitly Opened Existentials — Swift Evolution