Listas no SwiftUI para iOS: List, ForEach, seções e desempenho básico

Capítulo 6

Tempo estimado de leitura: 4 minutos

+ Exercício

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 de VStack, ScrollView ou 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, prefira List.

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.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

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 Identifiable com id está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 body de 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() + clipShape para 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 order no modelo e ordenar por ela, atualizando order ao mover.
  • Desabilitar o modo de edição quando query não estiver vazia.

5) Checklist do mini-projeto (para validar)

RequisitoOnde aparece
List + ForEach com IdentifiableTaskItem: Identifiable e ForEach(tasks)
Seções com header e footerSection { ... } header/footer
Swipe actions.swipeActions leading/trailing
DeleteBotã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

Agora responda o exercício sobre o conteúdo:

Em uma tela típica de lista no iOS, com rolagem e recursos nativos como seleção, edição e swipe actions, qual abordagem é mais indicada no SwiftUI?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Para uma tela de lista iOS padrão, List é a escolha recomendada porque já entrega comportamento nativo (rolagem, seleção, separadores, acessibilidade, edição e swipe actions) e reciclagem de células, essencial em listas longas.

Próximo capitúlo

Formulários no SwiftUI para iOS: Form, validação e experiência do usuário

Arrow Right Icon
Capa do Ebook gratuito iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store
43%

iOS para Iniciantes com SwiftUI: do zero ao primeiro app na App Store

Novo curso

14 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.