Чтобы редактировать отфильтрованный список SwiftUI, не изменяя не тот элемент, храните один исходный массив и связывайте каждую видимую строку с ним по стабильному идентификатору. Этот пример для macOS позволяет фильтровать, переименовывать, переключать и удалять синтетические метки в вашем приложении.
Почему фильтрации недостаточно для создания редактируемых строк
Метод Swift filter(_:) возвращает новую коллекцию, содержащую подходящие элементы в исходном порядке. Он не создаёт записываемую связь между этой коллекцией и её источником. Если массив состоит из структур, изменение элемента в отдельно сохранённом отфильтрованном массиве не обновляет исходный массив. Эти выводы следуют из документированного поведения filter(_:) и семантики значений для структур в Swift.
Например, предположим, что исходный массив содержит Grid, Tile и Ring, а фильтр показывает только Ring. Его видимая позиция равна нулю, а исходная — двум. Если использовать видимую позицию для записи в исходный массив, изменится Grid. Сортировка отображаемых результатов может привести к такому же несоответствию.
Привязка обеспечивает недостающую связь для чтения и записи. Apple описывает Binding как связь с данными, которыми владеет другой объект, и предоставляет init(get:set:) для настройки доступа. Здесь оба замыкания при каждом вызове находят исходный элемент по идентификатору. Отфильтрованная коллекция определяет, какие строки отображаются; изменения записываются в исходный массив. См. Binding и init(get:set:).
Сначала выберите стабильную идентичность
Для каждого элемента нужен идентификатор, который не меняется при изменении имени, настройки включения или позиции в массиве. Протокол Apple Identifiable описывает стабильную идентичность сущности, а ForEach использует идентифицируемые данные или явный идентификатор, чтобы различать создаваемые представления. См. Identifiable и ForEach.
В примере UUID каждого элемента создаётся один раз и хранится в свойстве let id. При переименовании метки её идентификатор сохраняется. Не вычисляйте идентичность по редактируемому имени и не создавайте новый UUID при каждом чтении идентификатора. Также убедитесь, что у разных элементов разные идентификаторы: поиск намеренно находит первый совпавший идентификатор, поэтому дубликаты сделают цель записи неоднозначной.
При выборе потока данных воспользуйтесь этой таблицей:
| Требование интерфейса | Подход к привязкам |
|---|---|
| Отобразить неизменённую коллекцию значений | Передать значения строкам только для чтения. |
| Редактировать исходную коллекцию напрямую | При необходимости использовать поддержку привязок к коллекциям в SwiftUI. |
| Редактировать результаты, позиции которых отличаются от исходных | Находить исходный элемент по стабильному идентификатору. |
| Оставить редактор открытым после того, как его элемент исчез из результатов | Задать редактору собственный выбор и правила жизненного цикла. |
Далее реализуется третий случай. Также задаётся поведение при исчезновении строки, чтобы отсутствие элемента никогда не приводило к неконтролируемому обращению по индексу массива.
Создайте полноценный пример для macOS
Создайте в Xcode проект приложения для macOS с использованием SwiftUI и установите для этого примера целевую версию macOS 13 или новее. Замените сгенерированные объявления приложения и представления содержимого следующим файлом. Оставьте ровно одну точку входа @main; требование и используемая здесь структура WindowGroup описаны в документации Apple по App.
Родительское представление хранит исходный массив в приватном @State; каждый дочерний элемент получает @Binding. Это соответствует документированному Apple подходу к передаче состояния подчинённым представлениям. В примере используются только локальные синтетические метки, а данные хранятся в памяти.
import SwiftUI
import Foundation
private struct LabelItem: Identifiable, Sendable {
let id = UUID()
var name: String
var isIncluded: Bool
}
@main
struct FilteredLabelsApp: App {
var body: some Scene {
WindowGroup {
LabelEditor()
}
}
}
@MainActor
private struct LabelEditor: View {
@State private var items = [
LabelItem(name: "Grid", isIncluded: true),
LabelItem(name: "Tile", isIncluded: false),
LabelItem(name: "Ring", isIncluded: true)
]
@State private var query = ""
@State private var includedOnly = false
@State private var notice = ""
private var visibleItems: [LabelItem] {
items.filter { item in
(!includedOnly || item.isIncluded) &&
(query.isEmpty ||
item.name.localizedCaseInsensitiveContains(query))
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
TextField("Filter labels", text: $query)
Toggle("Included only", isOn: $includedOnly)
Button("Reverse original order") {
items.reverse()
}
List {
ForEach(visibleItems) { snapshot in
LabelRow(item: itemBinding(for: snapshot)) {
items.removeAll { $0.id == snapshot.id }
}
}
}
Text("Original array:")
.font(.headline)
Text(items.map(\.name).joined(separator: " · "))
Text(notice)
}
.padding()
.frame(minWidth: 540, minHeight: 360)
}
private func itemBinding(
for snapshot: LabelItem
) -> Binding<LabelItem> {
let id = snapshot.id
return Binding(
get: {
items.first { $0.id == id } ?? snapshot
},
set: { updated in
guard let index = items.firstIndex(where: {
$0.id == id
}) else {
notice = "Edit skipped: label was removed."
return
}
items[index].name = updated.name
items[index].isIncluded = updated.isIncluded
notice = ""
}
)
}
}
@MainActor
private struct LabelRow: View {
@Binding var item: LabelItem
let remove: () -> Void
var body: some View {
HStack {
TextField("Label name", text: $item.name)
Toggle("Included", isOn: $item.isIncluded)
Button("Remove", action: remove)
}
}
}
Это полноценный пример приложения, а не фрагмент для вставки в существующее представление. Он сверен с полученной документацией API, но здесь не компилировался, поскольку в этой среде нет инструментов Swift и SDK для macOS.
Как изменение попадает в исходный массив
Вычисляемое свойство visibleItems заново определяет состав коллекции по исходному массиву и текущим настройкам фильтра. ForEach получает результаты и создаёт для каждого подходящего элемента строку. Предварительная фильтрация также позволяет не помещать в каждую итерацию условную конструкцию, создающую ноль или одну строку, — этот подход обсуждается в рекомендациях Apple по производительности ForEach.
Когда строка читает привязку, геттер ищет её идентификатор в текущем исходном массиве. Снимок используется лишь как резервное значение для элемента, который уже удалён. Пока элемент существует, геттер возвращает его актуальное значение из исходного массива, а не снимок, сохранённый при создании строки.
Когда пользователь редактирует Label name, выражение $item.name проецирует привязку элемента на доступное для записи свойство имени. Apple описывает эту проекцию свойства через динамический сабскрипт Binding. Затем сеттер находит текущий исходный индекс элемента и обновляет его редактируемые поля, сохраняя сохранённый идентификатор.
Для поиска используется firstIndex(where:): он возвращает необязательный индекс, а при отсутствии совпадений — nil. Перед обращением по индексу код проверяет результат. Поскольку поиск выполняется заново при каждой записи, перестановка исходного массива не приведёт к тому, что сеттер будет использовать устаревший индекс. См. документацию Apple по firstIndex(where:).
Что происходит, когда строка исчезает?
Возможны два разных случая. Если из-за изменения элемент перестаёт соответствовать фильтру, он всё ещё остаётся в исходном массиве. Изменение сохраняется, но строка исчезает из результатов. Например, отключение Included при активном параметре Included only скрывает эту строку; отключение фильтра снова её показывает.
Если элемент удалён из исходного массива, последующая запись через старую привязку отклоняется. В примере выводится сообщение Edit skipped: label was removed. Снимок не добавляется обратно в массив. Возврат снимка геттером и отклонение записей для отсутствующих элементов — явно заданные правила этого примера, а не гарантии SwiftUI.
Для фильтрации имён в реальном времени тоже нужно принять решение о взаимодействии. Apple указывает, что связанное со строкой поле TextField непрерывно обновляет значение во время редактирования. Поэтому строка может исчезнуть, как только новое имя перестанет соответствовать запросу. Чтобы переименовывать без прерываний, сначала очистите запрос или создайте отдельный редактор, который применяет черновик по явному действию. См. TextField.
Проверьте поведение с помощью короткой проверки на компьютере
Ниже описано ожидаемое поведение, выведенное из примера; это не результаты фактического выполнения. Запустите пример заново, чтобы увидеть исходные метки, затем выполните действия по порядку.
| Действие | Ожидаемый результат |
|---|---|
Введите Ring в Filter labels. |
Отображается только Ring. |
Переименуйте её в Ring Two. |
Отображение исходного массива меняется на Grid · Tile · Ring Two. |
Очистите запрос и нажмите Reverse original order. |
Отображение меняется на Ring Two · Tile · Grid. |
Переименуйте Grid в Grid Two. |
Меняется только исходное значение этого элемента. |
Включите Included only, затем отключите Included у Grid Two. |
Строка исчезает, но её имя остаётся в отображении исходного массива. |
Отключите Included only. |
Grid Two снова появляется с выключенным переключателем. |
Введите Tile, затем нажмите кнопку Remove у этой метки. |
Её строка и запись в исходном массиве исчезают. |
Если меняется не тот элемент, проверьте, не используется ли видимое смещение как индекс исходного массива. Если изменения исчезают после очистки фильтра, выясните, не хранит ли строка отдельную копию в @State. Если переименованная строка ведёт себя как новый элемент, проверьте, не зависит ли её идентификатор от редактируемых данных. Эти проверки охватывают три явные связи в примере: владение исходными данными, место записи и идентичность строки.
Учитывайте ограничения macOS и API
Для коротких редактируемых меток в примере используется SwiftUI TextField. Для более сложного редактора документов может понадобиться AppKit NSTextView, который поддерживает форматированный текст и интеграцию с такими компонентами текстовой системы, как NSTextStorage и NSTextLayoutManager. Это меняет элемент управления редактированием; приложению по-прежнему нужно определить, в какой исходный элемент модели записывается каждое изменение. См. NSTextView.
Для встраивания представления AppKit в иерархию SwiftUI используется NSViewRepresentable. Apple описывает создание и обновление представляемого представления, а также использование координатора для передачи взаимодействий. Если в будущем вы создадите собственный редактор, сохраните ту же цель записи, определяемую по стабильному идентификатору. Пусть SwiftUI управляет компоновкой представляемого представления; не меняйте напрямую его рамку или границы, которыми управляет система. См. NSViewRepresentable.
При расширении AppKit нужно также учитывать ограничения TextKit: Apple указывает, что обращение к NSTextView.layoutManager может переключить представление в режим совместимости с использованием NSLayoutManager. Перед применением конкретных текстовых API проверьте их доступность и поведение. Для решения с привязками к коллекции настройка TextKit не нужна. См. обсуждение совместимости в NSTextView.
Для больших коллекций учитывайте затраты на поиск. Apple указывает линейную сложность как для filter(_:), так и для firstIndex(where:); поэтому при росте коллекции многократный поиск для каждой строки может стать существенным. Прежде чем вводить кэш соответствия идентификаторов индексам, измерьте производительность интерфейса и перестраивайте такой кэш при любом структурном изменении, сдвигающем индексы. См. filter(_:) и firstIndex(where:).
Доступ к пользовательскому интерфейсу через эти вычисляемые привязки должен происходить на главном акторе, как и в примере. В документации Apple по инициализатору привязки объясняется, что допустимость доступа через вычисляемую привязку зависит от изоляции её замыканий. Передача привязки в другое место не снимает эти ограничения. См. init(get:set:).
Для этого редактора отфильтрованных меток храните один исходный массив, используйте для строк стабильные идентификаторы и находите цель записи в момент редактирования. Затем намеренно задайте поведение при исчезновении элемента: для скрытого фильтром элемента изменение сохраняется, а удалённый элемент отклоняет дальнейшие записи с видимым объяснением.
Источники
- filter(_:) — Apple
- Structures and Classes — Swift.org
- Binding — Apple
- init(get:set:) — Apple
- Identifiable — Apple
- ForEach — Apple
- App — Apple
- State — Apple
- firstIndex(where:) — Apple
- TextField — Apple
- NSTextView — Apple
- NSViewRepresentable — Apple