Para editar una lista filtrada de SwiftUI sin cambiar el elemento equivocado, conserva un único arreglo original y enlaza cada fila visible con él mediante un ID estable. Este ejemplo para macOS permite filtrar, cambiar el nombre, alternar y eliminar etiquetas sintéticas en tu propia app.
Por qué filtrar por sí solo no crea filas editables
El método filter(_:) de Swift devuelve una colección nueva que contiene los elementos coincidentes en su orden original. No crea una relación de escritura entre esa colección y su origen. Si se trata de un arreglo de estructuras, cambiar un elemento en un arreglo filtrado almacenado por separado no actualiza el arreglo original. Estas conclusiones se desprenden del comportamiento documentado de filter(_:) y de la semántica de valores de las estructuras en Swift.
Por ejemplo, supongamos que el arreglo original contiene Grid, Tile y Ring, pero un filtro muestra solo Ring. Su posición visible es cero; su posición original es dos. Usar la posición visible para escribir en el arreglo original cambiaría Grid. Ordenar los resultados mostrados puede provocar la misma discrepancia.
Un enlace proporciona la conexión de lectura y escritura que falta. Apple describe Binding como una conexión con datos que pertenecen a otro lugar y ofrece init(get:set:) para el acceso personalizado. Aquí, ambos cierres buscan el elemento original por ID cada vez que se ejecutan. La colección filtrada determina qué filas aparecen; el arreglo original recibe las ediciones. Consulta Binding e init(get:set:).
Elige una identidad estable antes de crear el editor
Cada elemento necesita un ID que no cambie cuando cambien su nombre, su configuración de inclusión o su posición en el arreglo. El protocolo Identifiable de Apple describe la identidad estable de una entidad, mientras que ForEach usa datos identificables o un ID explícito para distinguir las vistas que genera. Consulta Identifiable y ForEach.
El ejemplo crea cada UUID una sola vez en una propiedad almacenada let id. Cambiar el nombre de una etiqueta deja intacto su ID. Evita derivar la identidad del nombre editable o generar un UUID nuevo cada vez que se lee un ID. Asegúrate también de que los elementos distintos tengan IDs distintos: la búsqueda encuentra deliberadamente el primer ID coincidente, así que los duplicados harían ambiguo el destino de escritura.
Usa esta guía para elegir el flujo de datos:
| Requisito de la interfaz | Enfoque con enlaces |
|---|---|
| Mostrar una colección de valores sin modificar | Pasa los valores a filas de solo lectura. |
| Editar directamente la colección original | Usa el soporte de SwiftUI para enlaces a colecciones cuando corresponda. |
| Editar resultados cuyas posiciones difieren de las del origen | Busca el elemento original por su ID estable. |
| Mantener abierto un editor después de que su elemento deje de estar entre los resultados | Define una selección y una política de ciclo de vida propias para el editor. |
El resto implementa el tercer caso. También define qué ocurre cuando una fila desaparece, para que un elemento ausente nunca provoque un acceso al arreglo fuera de sus límites.
Crea un ejemplo completo para macOS
Crea en Xcode un proyecto de app para macOS que use SwiftUI y establece como destino de implementación macOS 13 o posterior para este ejemplo. Sustituye las declaraciones generadas para la app y la vista de contenido por el siguiente archivo único. Mantén exactamente un punto de entrada @main; la documentación de App de Apple explica este requisito y la estructura WindowGroup que se usa aquí.
El padre posee el arreglo original mediante @State privado; cada elemento hijo recibe un @Binding. Esto sigue el patrón documentado por Apple para compartir estado con subvistas. El ejemplo solo contiene etiquetas sintéticas locales y mantiene los datos en memoria.
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)
}
}
}
Este es un ejemplo completo de app, no un fragmento para pegar dentro de una vista existente. Se ha revisado contrastándolo con la documentación de API consultada, pero no se compiló aquí porque este entorno no dispone de una cadena de herramientas de Swift ni del SDK de macOS.
Cómo llega una edición al arreglo original
La propiedad calculada visibleItems vuelve a calcular qué elementos coinciden a partir del arreglo original y la configuración actual del filtro. ForEach recibe esos resultados y crea una fila por cada elemento coincidente. Filtrar antes de construir las filas también evita incluir en cada iteración una rama condicional que produzca cero o una fila, un patrón que se analiza en la guía de rendimiento de ForEach de Apple.
Cuando una fila lee su enlace, el getter busca su ID en el arreglo original actual. La instantánea solo sirve como valor de respaldo si el elemento ya se eliminó. Mientras el elemento exista, el getter devuelve el valor original actual, no la instantánea capturada al crear la fila.
Cuando el usuario edita Label name, $item.name proyecta el enlace del elemento sobre su propiedad de nombre editable. Apple documenta esta proyección de propiedades mediante el subíndice de miembro dinámico de Binding. Luego, el setter busca el índice original actual del elemento y actualiza sus campos editables, preservando el ID almacenado.
La búsqueda usa firstIndex(where:), que devuelve un índice opcional y devuelve nil si no encuentra coincidencias. El código comprueba el resultado antes de acceder al arreglo. Como repite la búsqueda en cada escritura, invertir el arreglo original no hace que el setter conserve un índice obsoleto. Consulta la documentación de firstIndex(where:) de Apple.
¿Qué ocurre cuando desaparece una fila?
Hay dos casos distintos. Si una edición hace que un elemento deje de cumplir el filtro, el elemento sigue existiendo en el arreglo original. Se conserva la edición, pero la fila desaparece de los resultados. Por ejemplo, desactivar Included mientras está activado Included only oculta esa fila; al desactivar el filtro, vuelve a aparecer.
Si el elemento se eliminó del arreglo original, se rechaza cualquier escritura posterior mediante un enlace antiguo. El ejemplo muestra Edit skipped: label was removed. No vuelve a insertar la instantánea en el arreglo. Devolver una instantánea desde el getter y rechazar las escrituras de elementos ausentes son políticas explícitas de este ejemplo, no garantías de SwiftUI.
El filtrado en vivo por nombre también requiere decidir cómo interactuar. Apple documenta que un TextField enlazado a una cadena actualiza su valor continuamente mientras se edita. Por eso, una fila puede desaparecer en cuanto su nombre nuevo deja de coincidir con la consulta. Para cambiar nombres sin interrupciones, borra primero la consulta o diseña un editor independiente que confirme un borrador mediante una acción explícita. Consulta TextField.
Verifica el comportamiento con una breve prueba en el escritorio
Las siguientes comprobaciones describen el comportamiento esperado a partir del ejemplo; no son resultados de ejecución. Inicia una ejecución nueva para ver las etiquetas iniciales y luego sigue los pasos en orden.
| Acción | Resultado esperado |
|---|---|
Escribe Ring en Filter labels. |
Solo aparece Ring. |
Cámbiale el nombre a Ring Two. |
La lectura del arreglo original muestra Grid · Tile · Ring Two. |
Borra la consulta y haz clic en Reverse original order. |
La lectura muestra Ring Two · Tile · Grid. |
Cámbiale el nombre a Grid Two. |
Solo cambia el valor original de ese elemento. |
Activa Included only y luego desactiva Included para Grid Two. |
Esa fila desaparece, pero su nombre permanece en la lectura del arreglo original. |
Desactiva Included only. |
Grid Two vuelve a aparecer con su interruptor desactivado. |
Escribe Tile y luego haz clic en su botón Remove. |
Desaparecen tanto su fila como su entrada del arreglo original. |
Si cambia el elemento equivocado, comprueba si se está usando un desplazamiento de la vista como índice del arreglo original. Si las ediciones desaparecen al borrar el filtro, comprueba si la fila tiene una copia @State independiente. Si una fila renombrada se comporta como un elemento nuevo, comprueba si su ID depende de contenido editable. Estas comprobaciones apuntan a las tres relaciones que el ejemplo mantiene explícitas: la propiedad del origen, el destino de escritura y la identidad de la fila.
Mantén la solución dentro de los límites de macOS y de las API
Para etiquetas breves y editables, el TextField de SwiftUI del ejemplo ofrece la interfaz necesaria. Un editor de documentos más completo puede requerir NSTextView de AppKit, que admite texto enriquecido y se integra con componentes del sistema de texto como NSTextStorage y NSTextLayoutManager. Eso cambia el control de edición; la aplicación sigue teniendo que decidir qué elemento del modelo original recibe cada edición. Consulta NSTextView.
El puente admitido para usar una vista de AppKit dentro de una jerarquía de SwiftUI es NSViewRepresentable. Apple documenta cómo crear y actualizar la vista representada y usar un coordinador para reenviar interacciones. Si más adelante creas un editor personalizado, conserva el mismo destino de escritura basado en un ID estable. Deja que SwiftUI gestione el diseño de la vista representada, en lugar de cambiar directamente el marco o los límites que administra. Consulta NSViewRepresentable.
Una extensión de AppKit también está sujeta a los límites de TextKit: Apple documenta que acceder a NSTextView.layoutManager puede cambiar la vista al modo de compatibilidad, que usa NSLayoutManager. Comprueba la disponibilidad y el comportamiento de las API de texto concretas antes de adoptarlas. La solución de enlaces a colecciones no requiere personalizar TextKit. Consulta la explicación del modo de compatibilidad en NSTextView.
Para colecciones más grandes, ten en cuenta el trabajo que requieren las búsquedas. Apple documenta complejidad lineal tanto para filter(_:) como para firstIndex(where:); por tanto, las búsquedas repetidas por fila pueden volverse considerables a medida que crece la colección. Mide tu interfaz antes de añadir una caché de ID a índice y vuelve a crearla cada vez que los cambios estructurales alteren los índices. Consulta filter(_:) y firstIndex(where:).
Mantén en el actor principal el acceso de la interfaz a estos enlaces calculados, como hace el ejemplo. La documentación de Apple del inicializador de enlaces explica que el aislamiento de los cierres determina el acceso permitido a un enlace calculado. Pasar un enlace a otro lugar no elimina esas restricciones. Consulta init(get:set:).
Para este editor de etiquetas filtradas, conserva un único arreglo original, usa IDs estables para las filas y resuelve el destino de escritura en el momento de editar. Luego decide deliberadamente qué ocurre cuando una fila desaparece: un elemento filtrado conserva su edición, mientras que un elemento eliminado rechaza las escrituras posteriores y muestra una explicación.
Fuentes
- 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