Quando usar List vs ForEach
No SwiftUI, você pode renderizar coleções de dados de duas formas comuns:
ForEach: repete views para cada item. É ótimo quando você quer controlar o container (por exemplo, dentro deVStack,ScrollViewou grids).List: é um componente especializado para listas no iOS, com comportamento nativo (rolagem, seleção, separadores, acessibilidade, edição, swipe actions, seções, etc.). Em geral, para telas “de lista” típicas, prefiraList.
Uma regra prática: se você quer “uma tela de lista iOS padrão”, comece por List. Se você quer um layout mais customizado e não precisa do comportamento nativo de lista, use ForEach dentro de um container apropriado.
Itens identificáveis: Identifiable, id e estabilidade
Para que o SwiftUI atualize a lista com eficiência, ele precisa identificar cada item de forma estável. Isso evita recarregar células desnecessariamente e garante animações corretas ao inserir/remover/mover itens.
Opção 1: conformar ao Identifiable
Crie um modelo com id único e estável:
import Foundation
struct TaskItem: Identifiable {
let id: UUID
var title: String
var isDone: Bool
var category: Category
}
enum Category: String, CaseIterable, Identifiable {
case work = "Trabalho"
case personal = "Pessoal"
case shopping = "Compras"
var id: String { rawValue }
}Use UUID() ao criar o item e mantenha esse id o mesmo durante toda a vida do item. Evite usar índice do array como id, porque ele muda quando você reordena ou remove itens.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
Opção 2: usar id: em ForEach/List
Se seu tipo não é Identifiable, você pode indicar uma chave única:
ForEach(users, id: \ .email) { user in
Text(user.name)
}Escolha uma propriedade realmente única e estável (e-mail, identificador do servidor, etc.).
Construindo uma lista básica com List
Uma lista simples pode ser feita com List + ForEach. Abaixo, um exemplo com uma linha customizada.
1) Criar uma view de linha (Row)
import SwiftUI
struct TaskRow: View {
let item: TaskItem
var body: some View {
HStack(spacing: 12) {
Image(systemName: item.isDone ? "checkmark.circle.fill" : "circle")
.foregroundStyle(item.isDone ? .green : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.strikethrough(item.isDone)
Text(item.category.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.contentShape(Rectangle())
}
}.contentShape(Rectangle()) ajuda a tornar a linha inteira “clicável” quando você adicionar gestos ou menus.
2) Renderizar com List
struct TasksListScreen: View {
@State private var items: [TaskItem] = [
.init(id: UUID(), title: "Enviar relatório", isDone: false, category: .work),
.init(id: UUID(), title: "Comprar café", isDone: true, category: .shopping),
.init(id: UUID(), title: "Treino", isDone: false, category: .personal)
]
var body: some View {
List {
ForEach(items) { item in
TaskRow(item: item)
}
}
}
}O List já cuida de rolagem e reciclagem de células. Em listas longas, isso é essencial para desempenho.
Seções: cabeçalhos, rodapés e agrupamento
Seções ajudam a organizar conteúdo e são muito comuns em apps iOS. Você pode criar seções manualmente ou agrupando dados por categoria.
Seções manuais
List {
Section {
Text("Item A")
Text("Item B")
} header: {
Text("Hoje")
} footer: {
Text("2 itens")
}
Section("Amanhã") {
Text("Item C")
}
}Agrupando por categoria (dicionário + ordenação)
Um padrão simples é agrupar em um dicionário e depois ordenar as chaves para renderizar em seções.
extension Array where Element == TaskItem {
func groupedByCategory() -> [(Category, [TaskItem])] {
let dict = Dictionary(grouping: self, by: \ .category)
return Category.allCases.compactMap { cat in
guard let items = dict[cat] else { return nil }
return (cat, items)
}
}
}Agora, use isso na lista:
struct TasksByCategoryScreen: View {
@State private var items: [TaskItem] = []
var grouped: [(Category, [TaskItem])] {
items.groupedByCategory()
}
var body: some View {
List {
ForEach(grouped, id: \ .0) { category, tasks in
Section {
ForEach(tasks) { item in
TaskRow(item: item)
}
} header: {
Text(category.rawValue)
} footer: {
Text("\(tasks.count) item(ns)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}Note que o id da seção é a própria categoria (estável), e o idUUID de cada tarefa.
Estilos e aparência do List
O List tem estilos que mudam a aparência (especialmente em iOS). Você pode experimentar:
List { ... }
.listStyle(.insetGrouped)Outras opções comuns: .plain, .grouped, .inset. O melhor estilo depende do tipo de tela (configurações, lista de conteúdo, etc.).
Separadores, insets e background (ajustes comuns)
Alguns ajustes úteis por linha:
TaskRow(item: item)
.listRowSeparator(.visible)
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))Você também pode customizar o background por linha:
TaskRow(item: item)
.listRowBackground(Color(.secondarySystemBackground))Interações: swipe actions, delete e move
Listas no iOS geralmente oferecem ações rápidas. O SwiftUI facilita isso com modificadores.
Swipe actions (deslizar para ações)
Você pode adicionar ações ao deslizar a linha para a esquerda ou direita:
ForEach(items) { item in
TaskRow(item: item)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(item)
} label: {
Label("Apagar", systemImage: "trash")
}
}
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
toggleDone(item)
} label: {
Label(item.isDone ? "Desfazer" : "Concluir", systemImage: "checkmark")
}
.tint(.green)
}
}Implemente as funções buscando pelo id (evita problemas quando a lista muda):
private func toggleDone(_ item: TaskItem) {
guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return }
items[idx].isDone.toggle()
}
private func delete(_ item: TaskItem) {
items.removeAll { $0.id == item.id }
}Delete padrão (onDelete)
Para habilitar o gesto padrão de apagar (e também o modo de edição), use onDelete:
List {
ForEach(items) { item in
TaskRow(item: item)
}
.onDelete(perform: delete)
}private func delete(at offsets: IndexSet) {
items.remove(atOffsets: offsets)
}IndexSet representa os índices selecionados para remoção. Esse método é o mais comum para listas simples.
Move (reordenar) com onMove
Para permitir reordenação:
List {
ForEach(items) { item in
TaskRow(item: item)
}
.onMove(perform: move)
}private func move(from source: IndexSet, to destination: Int) {
items.move(fromOffsets: source, toOffset: destination)
}Para o usuário conseguir reordenar, normalmente você expõe um botão de edição na barra de navegação. Se você já estiver usando uma tela com barra, pode adicionar:
.toolbar {
EditButton()
}Context menus (menu de contexto por toque longo)
O menu de contexto é útil para ações secundárias sem poluir a UI.
TaskRow(item: item)
.contextMenu {
Button {
toggleDone(item)
} label: {
Label(item.isDone ? "Marcar como pendente" : "Marcar como concluída", systemImage: "checkmark")
}
Button(role: .destructive) {
delete(item)
} label: {
Label("Apagar", systemImage: "trash")
}
}Boas práticas: mantenha o menu curto e com ações claras; ações destrutivas devem usar role: .destructive.
List e ForEach: armadilhas comuns
1) Usar índice como id
Evite:
ForEach(items.indices, id: \ .self) { i in
Text(items[i].title)
}Isso quebra facilmente ao remover/mover itens e pode causar animações erradas. Prefira Identifiable e itere diretamente nos itens.
2) IDs instáveis (gerar UUID no body)
Evite criar UUID() dentro do body ou em propriedades computadas que rodam toda renderização. O id precisa ser persistente no dado.
3) Mutação de item dentro do ForEach
Se você precisa editar itens diretamente na lista, uma abordagem comum é trabalhar com índices de forma segura, ou buscar pelo id e atualizar o array (como nos exemplos). Em listas simples, atualizar pelo id é claro e reduz bugs.
Desempenho básico em listas: o que realmente importa
Em listas simples, o SwiftUI costuma performar bem, mas alguns cuidados evitam engasgos e recarregamentos desnecessários.
Dados estáveis e diffs eficientes
- Use
Identifiablecomidestável. - Evite recriar o array inteiro sem necessidade (por exemplo, recomputar e reatribuir em loop). Prefira mutações pontuais (toggle, remove, move).
- Evite computações pesadas dentro do
bodyde cada linha. Se precisar, pré-calcule fora ou use propriedades já prontas no modelo.
Imagens em listas: cache e carregamento assíncrono básico
Se cada item tem uma imagem remota, use AsyncImage (básico) e forneça placeholder para evitar “pulos” visuais.
struct TaskRowWithIcon: View {
let item: TaskItem
let iconURL: URL?
var body: some View {
HStack(spacing: 12) {
AsyncImage(url: iconURL) { phase in
switch phase {
case .empty:
ProgressView()
.frame(width: 32, height: 32)
case .success(let image):
image
.resizable()
.scaledToFill()
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 8))
case .failure:
Image(systemName: "photo")
.frame(width: 32, height: 32)
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
Text(item.title)
Spacer()
}
}
}Boas práticas rápidas:
- Mantenha thumbnails pequenos (ex.: 24–48pt) e use
.scaledToFill()+clipShapepara evitar re-layout. - Evite aplicar muitos efeitos pesados por linha (sombras grandes, blur) em listas longas.
- Se a imagem vier de assets locais, prefira
Image("nome")direto (é eficiente).
Carregamento incremental simples (quando aplicável)
Para listas que crescem com paginação, você pode disparar carregamento quando o último item aparece:
ForEach(items) { item in
TaskRow(item: item)
.onAppear {
if item.id == items.last?.id {
loadMoreIfNeeded()
}
}
}Em um app real, proteja contra múltiplas chamadas simultâneas (flag isLoading) e dedupe de resultados.
Mini-projeto: Lista de tarefas com categorias e busca simples
Neste mini-projeto, você vai montar uma tela de tarefas com:
- Seções por categoria
- Busca simples por texto
- Ações: concluir, apagar, mover
- Menu de contexto
1) Modelo e dados iniciais
import SwiftUI
import Foundation
struct TaskItem: Identifiable {
let id: UUID
var title: String
var isDone: Bool
var category: Category
}
enum Category: String, CaseIterable, Identifiable {
case work = "Trabalho"
case personal = "Pessoal"
case shopping = "Compras"
var id: String { rawValue }
}extension Array where Element == TaskItem {
func groupedByCategory() -> [(Category, [TaskItem])] {
let dict = Dictionary(grouping: self, by: \ .category)
return Category.allCases.compactMap { cat in
guard let items = dict[cat] else { return nil }
return (cat, items)
}
}
}2) Row reutilizável
struct TaskRow: View {
let item: TaskItem
var body: some View {
HStack(spacing: 12) {
Image(systemName: item.isDone ? "checkmark.circle.fill" : "circle")
.foregroundStyle(item.isDone ? .green : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.strikethrough(item.isDone)
Text(item.category.rawValue)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.contentShape(Rectangle())
}
}3) Tela principal com busca, seções e ações
Esta tela usa .searchable para filtrar por título. A busca é “simples”: filtra por substring, ignorando maiúsculas/minúsculas.
struct TasksMiniProjectScreen: View {
@State private var items: [TaskItem] = [
.init(id: UUID(), title: "Enviar relatório", isDone: false, category: .work),
.init(id: UUID(), title: "Reunião com time", isDone: false, category: .work),
.init(id: UUID(), title: "Comprar café", isDone: true, category: .shopping),
.init(id: UUID(), title: "Comprar frutas", isDone: false, category: .shopping),
.init(id: UUID(), title: "Treino", isDone: false, category: .personal)
]
@State private var query: String = ""
private var filteredItems: [TaskItem] {
let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !q.isEmpty else { return items }
return items.filter { $0.title.localizedCaseInsensitiveContains(q) }
}
private var grouped: [(Category, [TaskItem])] {
filteredItems.groupedByCategory()
}
var body: some View {
List {
ForEach(grouped, id: \ .0) { category, tasks in
Section {
ForEach(tasks) { item in
TaskRow(item: item)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(item)
} label: {
Label("Apagar", systemImage: "trash")
}
}
.swipeActions(edge: .leading, allowsFullSwipe: false) {
Button {
toggleDone(item)
} label: {
Label(item.isDone ? "Desfazer" : "Concluir", systemImage: "checkmark")
}
.tint(.green)
}
.contextMenu {
Button {
toggleDone(item)
} label: {
Label(item.isDone ? "Marcar como pendente" : "Marcar como concluída", systemImage: "checkmark")
}
Button(role: .destructive) {
delete(item)
} label: {
Label("Apagar", systemImage: "trash")
}
}
}
.onMove(perform: moveWithinFiltered)
} header: {
Text(category.rawValue)
} footer: {
Text("\(tasks.count) item(ns)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.listStyle(.insetGrouped)
.searchable(text: $query, prompt: "Buscar tarefas")
.toolbar {
EditButton()
}
}
private func toggleDone(_ item: TaskItem) {
guard let idx = items.firstIndex(where: { $0.id == item.id }) else { return }
items[idx].isDone.toggle()
}
private func delete(_ item: TaskItem) {
items.removeAll { $0.id == item.id }
}
private func moveWithinFiltered(from source: IndexSet, to destination: Int) {
// Move funciona naturalmente quando a lista mostra o array completo.
// Como aqui existe filtro e agrupamento, precisamos mapear o movimento para o array original.
// Estratégia simples: permitir mover apenas quando não há busca ativa.
let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard q.isEmpty else { return }
items.move(fromOffsets: source, toOffset: destination)
}
}4) Ajuste importante: mover com busca e seções
Reordenar itens enquanto a lista está filtrada e agrupada por seções exige uma regra clara. No exemplo acima, o método moveWithinFiltered bloqueia o move quando há busca ativa, porque os índices do ForEach não correspondem diretamente ao array original.
Alternativas comuns:
- Permitir reordenação apenas dentro da categoria (e sem busca), movendo itens dentro de um array por categoria.
- Manter uma propriedade
orderno modelo e ordenar por ela, atualizandoorderao mover. - Desabilitar o modo de edição quando
querynão estiver vazia.
5) Checklist do mini-projeto (para validar)
| Requisito | Onde aparece |
|---|---|
| List + ForEach com Identifiable | TaskItem: Identifiable e ForEach(tasks) |
| Seções com header e footer | Section { ... } header/footer |
| Swipe actions | .swipeActions leading/trailing |
| Delete | Botão destrutivo no swipe/menu (e função delete) |
| Move | .onMove + EditButton (com regra para busca) |
| Context menu | .contextMenu na linha |
| Busca simples | .searchable + filteredItems |