Para editar uma lista filtrada do SwiftUI sem alterar o item errado, mantenha um único array original e vincule cada linha visível a ele por um ID estável. Este exemplo para macOS permite filtrar, renomear, alternar e remover rótulos sintéticos no seu próprio app.
Por que filtrar, por si só, não cria linhas editáveis
O filter(_:) do Swift retorna uma nova coleção com os elementos correspondentes na ordem original. Ele não cria uma relação gravável entre essa coleção e sua origem. No caso de um array de estruturas, alterar um elemento em um array filtrado armazenado separadamente não atualiza o array original. Essas conclusões decorrem do comportamento documentado de filter(_:) e da semântica de valor das estruturas no Swift.
Por exemplo, suponha que o array original contenha Grid, Tile e Ring, mas que um filtro exiba apenas Ring. Sua posição visível é zero; sua posição original é dois. Usar a posição visível para gravar no array original alteraria Grid. Ordenar os resultados exibidos pode causar a mesma incompatibilidade.
Um binding fornece a conexão de leitura e gravação que falta. A Apple documenta Binding como uma conexão a dados pertencentes a outro lugar e fornece init(get:set:) para acesso personalizado. Aqui, ambas as closures localizam o item original pelo ID sempre que são executadas. A coleção filtrada determina quais linhas aparecem; o array original recebe as edições. Consulte Binding e init(get:set:).
Escolha uma identidade estável antes de criar o editor
Cada item precisa de um ID que permaneça inalterado quando seu nome, configuração de inclusão ou posição no array mudar. O protocolo Identifiable da Apple descreve uma identidade estável da entidade, enquanto ForEach usa dados identificáveis ou um ID explícito para distinguir as views que gera. Consulte Identifiable e ForEach.
O exemplo cria cada UUID uma única vez em uma propriedade let id armazenada. Renomear um rótulo mantém seu ID intacto. Evite derivar a identidade do nome editável ou gerar um novo UUID sempre que um ID for lido. Além disso, garanta que itens distintos tenham IDs distintos: a busca encontra deliberadamente o primeiro ID correspondente, então IDs duplicados tornariam ambíguo o destino da gravação.
Use este guia de decisão ao escolher o fluxo de dados:
| Requisito da interface | Abordagem com binding |
|---|---|
| Exibir uma coleção de valores sem alterações | Passe os valores para linhas somente de leitura. |
| Editar diretamente a coleção original | Use o suporte do SwiftUI a bindings de coleções quando apropriado. |
| Editar resultados cujas posições diferem das posições na origem | Localize o item original pelo ID estável. |
| Manter um editor aberto depois que seu item sair dos resultados | Defina uma seleção própria para o editor e uma política de duração. |
O restante implementa o terceiro caso. Também define o que acontece quando uma linha desaparece, para que um item ausente nunca leve a um acesso ao array com índice inválido.
Crie um exemplo completo para macOS
Crie um projeto de app para macOS no Xcode usando SwiftUI e defina o destino de implantação como macOS 13 ou posterior para este exemplo. Substitua as declarações geradas do app e da view de conteúdo pelo arquivo único a seguir. Mantenha exatamente um ponto de entrada @main; a documentação de App da Apple descreve esse requisito e a estrutura WindowGroup usada aqui.
O elemento pai mantém o array original em @State privado; cada filho recebe um @Binding. Isso segue o padrão documentado pela Apple para compartilhar estado com subviews. O exemplo contém apenas rótulos sintéticos locais e mantém os dados na memória.
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 é um exemplo completo de app, não um fragmento para colar dentro de uma view existente. Ele foi conferido com base na documentação da API consultada, mas não foi compilado aqui, pois este ambiente não tem toolchain do Swift nem SDK do macOS.
Como uma edição chega ao array original
A propriedade computada visibleItems recalcula os elementos incluídos com base no array original e nas configurações atuais do filtro. ForEach recebe esses resultados e cria uma linha para cada item correspondente. Filtrar antes de construir as linhas também evita inserir em cada iteração um ramo condicional que gera zero ou uma linha, um padrão discutido nas orientações de desempenho de ForEach da Apple.
Quando uma linha lê seu binding, o getter procura seu ID no array original atual. O snapshot serve apenas como valor alternativo para um item que já foi removido. Enquanto o item existir, o getter retorna o valor atual do array original, em vez do snapshot capturado quando a linha foi criada.
Quando o usuário edita Label name, $item.name projeta o binding do item na propriedade de nome gravável. A Apple documenta essa projeção de propriedade por meio do subscript de membro dinâmico de Binding. Em seguida, o setter encontra o índice atual do item no array original e atualiza seus campos editáveis, preservando o ID armazenado.
A busca usa firstIndex(where:), que retorna um índice opcional e retorna nil quando não há correspondência. O código verifica esse resultado antes de acessar o array pelo índice. Como a busca é repetida a cada gravação, inverter o array original não faz com que o setter use um índice antigo. Consulte a documentação de firstIndex(where:) da Apple.
O que acontece quando uma linha desaparece?
Há dois casos diferentes. Se uma edição fizer com que um item deixe de corresponder ao filtro, ele continuará existindo no array original. A edição é mantida, mas a linha sai dos resultados. Por exemplo, desativar Included com Included only ativado oculta essa linha; desativar o filtro a mostra novamente.
Se o item tiver sido removido do array original, uma gravação posterior por meio de um binding antigo será rejeitada. O exemplo informa Edit skipped: label was removed. Ele não insere o snapshot de volta no array. Retornar um snapshot pelo getter e rejeitar gravações de itens ausentes são políticas explícitas deste exemplo, não garantias fornecidas pelo SwiftUI.
A filtragem de nomes em tempo real também exige uma decisão de interação. A Apple documenta que um TextField vinculado a uma string atualiza seu valor continuamente durante a edição. Consequentemente, uma linha pode desaparecer assim que seu novo nome deixa de corresponder à consulta. Para renomear sem interrupções, limpe primeiro a consulta ou crie um editor separado que aplique um rascunho após uma ação explícita. Consulte TextField.
Verifique o comportamento com um breve teste no desktop
As verificações a seguir descrevem o comportamento esperado com base no exemplo; não são resultados de execução relatados. Inicie uma nova execução para ver os rótulos iniciais e, em seguida, execute as etapas na ordem.
| Ação | Resultado esperado |
|---|---|
Digite Ring em Filter labels. |
Apenas Ring aparece. |
Renomeie-o para Ring Two. |
A leitura do array original passa a ser Grid · Tile · Ring Two. |
Limpe a consulta e clique em Reverse original order. |
A leitura passa a ser Ring Two · Tile · Grid. |
Renomeie Grid para Grid Two. |
Apenas o valor original desse item muda. |
Ative Included only e desative Included para Grid Two. |
Essa linha desaparece, mas seu nome permanece na leitura do array original. |
Desative Included only. |
Grid Two reaparece com a alternância desativada. |
Digite Tile e clique no botão Remove correspondente. |
A linha e a entrada correspondente no array original desaparecem. |
Se o item errado mudar, verifique se um deslocamento exibido está sendo usado como índice do array original. Se as edições desaparecerem ao limpar o filtro, veja se a linha mantém uma cópia separada em @State. Se uma linha renomeada se comportar como um novo item, verifique se o ID depende de conteúdo editável. Essas verificações abordam as três relações que o exemplo mantém explícitas: propriedade da origem, destino da gravação e identidade da linha.
Respeite os limites do macOS e das APIs
Para rótulos curtos e editáveis, o TextField do SwiftUI no exemplo oferece a interface necessária. Um editor de documentos mais completo pode precisar do NSTextView do AppKit, que oferece suporte a texto formatado e se integra a componentes do sistema de texto, como NSTextStorage e NSTextLayoutManager. Isso altera o controle de edição; ainda cabe ao app decidir qual item do modelo original recebe cada edição. Consulte NSTextView.
A ponte compatível para uma view do AppKit em uma hierarquia do SwiftUI é NSViewRepresentable. A Apple documenta como criar e atualizar a view representada e usar um coordenador para encaminhar interações. Em um futuro editor personalizado, mantenha o mesmo destino de gravação baseado em ID estável. Deixe o SwiftUI gerenciar o layout da view representada, sem alterar diretamente o frame ou os limites gerenciados por ele. Consulte NSViewRepresentable.
Uma extensão do AppKit também tem limites relacionados ao TextKit: a Apple documenta que acessar NSTextView.layoutManager pode colocar a view em modo de compatibilidade usando NSLayoutManager. Verifique a disponibilidade e o comportamento das APIs de texto específicas antes de adotá-las. A solução com bindings de coleção não requer personalização do TextKit. Consulte a discussão sobre compatibilidade em NSTextView.
Para coleções maiores, leve em conta o custo das buscas. A Apple documenta complexidade linear para filter(_:) e firstIndex(where:); assim, buscas repetidas por linha podem se tornar significativas à medida que a coleção cresce. Meça a interface antes de introduzir um cache de ID para índice e reconstrua esse cache sempre que mudanças estruturais alterarem os índices. Consulte filter(_:) e firstIndex(where:).
Mantenha no ator principal o acesso à interface por meio desses bindings computados, como faz o exemplo. A documentação da Apple do inicializador de binding explica que o acesso permitido a um binding computado depende do isolamento de suas closures. Passar um binding para outro lugar não elimina essas restrições. Consulte init(get:set:).
Neste editor de rótulos filtrados, mantenha um array original, use IDs estáveis para as linhas e localize o destino da gravação no momento da edição. Em seguida, defina deliberadamente o comportamento quando uma linha desaparecer: um item filtrado mantém sua edição, enquanto um item removido rejeita novas gravações e exibe uma explicação.
Fontes
- 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