Para mover um grupo e seus elementos filhos juntos, armazene os filhos dentro do modelo pai e reordene a coleção de pais. Este guia cria uma pequena lista SwiftUI para Mac na qual cada grupo se move como uma única linha, mantendo seus filhos vinculados.

A tarefa consiste em reorganizar grupos dentro do seu próprio app: por exemplo, mover Group A, que contém Tile A1 e Tile A2, para depois de Group B. O exemplo usa dados locais sintéticos e oferece botões explícitos Move Up e Move Down junto com o callback de movimentação da lista do SwiftUI.

O que deve contar como um único item movível?

Faça com que a unidade de movimentação corresponda à unidade dos seus dados. Para esta interface, o array externo contém grupos, e cada grupo contém seu próprio array de filhos. Reordenar esse array externo altera a ordem dos grupos, mantendo os filhos de cada grupo e a ordem atual deles.

Represente cada grupo como uma única linha composta da lista: um título, botões de movimentação e rótulos dos filhos recuados dentro de um VStack. Anexe onMove(perform:) ao ForEach externo. A Apple documenta que os offsets do callback se referem à coleção subjacente daquela view dinâmica; neste caso, essa coleção é o array de grupos. Documentação da Apple sobre o callback de movimentação.

Atribua identificadores estáveis aos grupos e aos filhos. O ForEach da Apple exige que os elementos sejam identificados, seja por meio de Identifiable, seja por uma chave de identificação explícita. Nesse design, os identificadores pertencem aos modelos e permanecem inalterados quando as posições mudam. Evite usar posições do array como identidade de conteúdo que pode ser reordenado. Documentação da Apple sobre ForEach.

Uma view Group do SwiftUI não é o modelo da aplicação usado abaixo. Dar nome a um contêiner ou exibir um título não estabelece quais registros filhos devem se mover com ele. O array children armazenado no pai deixa essa relação explícita.

Crie um exemplo completo em SwiftUI

Crie um projeto de app para macOS no Xcode, usando a interface SwiftUI e um destino de implantação macOS 12 ou posterior. Substitua o arquivo de entrada do app gerado pelo programa completo abaixo, mantendo apenas uma declaração @main no destino. O exemplo usa deliberadamente uma lista simples e botões de texto para facilitar a inspeção da movimentação.

import Foundation
import SwiftUI

private struct DemoItem: Identifiable {
    let id: String
    let title: String
}

private struct DemoGroup: Identifiable {
    let id: String
    let title: String
    let children: [DemoItem]
}

@main
struct GroupMoveDemoApp: App {
    var body: some Scene {
        WindowGroup {
            GroupCollectionView()
        }
    }
}

private struct GroupCollectionView: View {
    @State private var groups: [DemoGroup] = [
        DemoGroup(
            id: "group-a", title: "Group A",
            children: [
                DemoItem(id: "a1", title: "Tile A1"),
                DemoItem(id: "a2", title: "Tile A2")
            ]
        ),
        DemoGroup(
            id: "group-b", title: "Group B",
            children: [
                DemoItem(id: "b1", title: "Tile B1")
            ]
        ),
        DemoGroup(
            id: "group-c", title: "Group C",
            children: []
        )
    ]

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Arrange Groups")
                .font(.title2)
                .padding(.horizontal)

            List {
                ForEach(groups) { group in
                    VStack(alignment: .leading, spacing: 8) {
                        HStack {
                            Text(group.title).font(.headline)
                            Spacer()
                            Button("Move Up") {
                                moveOne(id: group.id, down: false)
                            }
                            .disabled(group.id == groups.first?.id)

                            Button("Move Down") {
                                moveOne(id: group.id, down: true)
                            }
                            .disabled(group.id == groups.last?.id)
                        }
                        .buttonStyle(BorderlessButtonStyle())

                        ForEach(group.children) { child in
                            Text(child.title)
                                .padding(.leading, 16)
                        }
                    }
                    .padding(.vertical, 8)
                }
                .onMove(perform: moveGroups)
            }
        }
        .padding(.vertical)
        .frame(minWidth: 520, minHeight: 320)
    }

    private func moveGroups(from source: IndexSet, to destination: Int) {
        withAnimation {
            groups.move(fromOffsets: source, toOffset: destination)
        }
    }

    private func moveOne(id: String, down: Bool) {
        guard let index = groups.firstIndex(where: { $0.id == id }) else {
            return
        }
        let neighbor = down ? index + 1 : index - 1
        guard groups.indices.contains(neighbor) else {
            return
        }
        let destination = down ? index + 2 : index - 1
        moveGroups(from: IndexSet(integer: index), to: destination)
    }
}

O ForEach externo produz um VStack por grupo. O ForEach aninhado exibe os filhos dentro dessa linha; não há um manipulador separado para movimentar os filhos. Os dois botões de movimentação encontram a posição atual do grupo pelo ID e chamam a mesma mutação usada por onMove.

Execute o app e clique em Move Down ao lado de Group A. A ordem esperada passa a ser Group B, Group A, Group C, com Tile A1 e Tile A2 ainda exibidos abaixo de Group A. Clique em Move Up ao lado desse grupo para restaurar a ordem inicial. Group C também demonstra que um grupo sem filhos continua podendo ser movido.

Este programa mantém a ordem na memória durante o ciclo de vida da view atual; ele não contém uma camada de persistência. As referências e a lógica de movimentação foram inspecionadas, mas o programa não foi compilado aqui porque o shell disponível não tem um compilador Swift. Compile e confira a interface no Xcode em um Mac antes de integrá-lo.

Por que mover para baixo usa um destino diferente?

O destino passado a move(fromOffsets:toOffset:) é um limite de inserção na coleção original. A Apple o define como o offset antes do qual inserir os elementos movidos, dentro do intervalo 0...count. Passar o próprio offset de um único elemento ou o offset seguinte não produz alterações. Documentação da Apple sobre movimentação em coleções.

Para a ordem inicial ilustrativa [A, B, C], estas operações independentes mostram a diferença:

Resultado pretendido Offset de origem Destino passado a move Resultado
Mover A para depois de B 0 2 [B, A, C]
Mover B para antes de A 1 0 [B, A, C]
Mover A para o final 0 3 [B, C, A]
Manter A no lugar 0 1 [A, B, C]

Por isso, o botão de movimentação para baixo usa index + 2: ele insere antes do elemento seguinte ao próximo vizinho ou no final. O botão para cima usa index - 1. Para os offsets fornecidos diretamente por onMove, passe-os ao método da coleção sem aplicar novamente esse cálculo específico dos botões.

O cálculo do destino no exemplo se aplica a uma única movimentação para a posição adjacente. Para um callback de movimentação que contenha vários offsets de origem, use o IndexSet completo com o método da coleção, que documenta a preservação da ordem dos elementos movidos. Documentação da Apple sobre movimentação em coleções.

Qual interface precisa de outra implementação?

Escolha a implementação de acordo com a forma como o grupo aparece e com a maneira como os usuários precisam interagir com seus filhos. O exemplo de linha composta é adequado quando um grupo deve sempre se mover como um bloco. Ele exibe deliberadamente os filhos como rótulos dentro desse bloco, em vez de linhas independentes da lista.

Requisito da interface Ponto de partida adequado Responsabilidade pela movimentação
Título do grupo e filhos formam um bloco movível ForEach externo com uma linha composta por grupo Reordenar o array pai
Hierarquia expansível com descendentes aninhados OutlineGroup Implementar a movimentação do modelo para a coleção pai pretendida
Coleção AppKit com uma seção por grupo NSCollectionView Atualizar a fonte de dados e, em seguida, notificar a view da movimentação da seção
Texto editável personalizado dentro de um item TextKit dentro do editor do item Manter a reordenação dos grupos no modelo da coleção ao redor

Árvores expansíveis precisam de regras explícitas de movimentação

A Apple descreve OutlineGroup como uma view de dados identificados e organizados em árvore, com controles de divulgação para expandir e recolher ramificações. Ela está disponível no macOS 11 e posterior. O comportamento de apresentação documentado não estabelece uma política geral de reordenação de subárvores para sua aplicação. Documentação da Apple sobre OutlineGroup.

Em uma árvore, defina o destino com precisão: mover um pai entre seus irmãos e movê-lo para dentro de outro pai são operações diferentes. Reordenar entre irmãos altera um array pai. Mudar o pai exige remover o nó do pai antigo, inseri-lo no novo e rejeitar um destino dentro dos próprios descendentes desse nó. Essas são regras do modelo da aplicação derivadas da estrutura da árvore.

Se a tarefa continuar sendo mover grupos inteiros de nível superior, a implementação com linha composta evita essa política adicional de destino. Se os filhos precisarem de seleção ou reordenação independente, defina explicitamente essas interações antes de achatar a hierarquia em linhas visíveis.

O AppKit permite mover uma seção com os itens que ela contém

Quando sua interface é um NSCollectionView com cada grupo representado por uma seção, a Apple fornece moveSection(_:toSection:). A documentação cobre explicitamente a reorganização de seções e dos itens que elas contêm, e exige a atualização da fonte de dados antes de chamá-lo. A API está disponível no macOS 10.11 e posterior. Documentação da Apple sobre movimentação de seções.

Nesse fluxo, determine os índices da seção de origem e da seção de destino final, reordene os dados dos grupos e notifique a view da coleção usando a API de movimentação de seções. Mantenha separado o cálculo do limite de inserção do SwiftUI do índice da seção de destino esperado pelo AppKit. Os dois métodos descrevem seus destinos de formas diferentes.

Para incorporar uma coleção AppKit no SwiftUI, use NSViewRepresentable, o wrapper documentado pela Apple para criar e atualizar uma view AppKit. Encaminhe as interações com a coleção de volta ao modelo SwiftUI pelo mecanismo de coordenação adequado e deixe o SwiftUI controlar o layout externo da view representada. Documentação da Apple sobre NSViewRepresentable.

O TextKit cuida do layout do texto dentro de um item

A Apple define NSTextLayoutManager como o gerenciador de layout e apresentação de texto personalizados, disponível no macOS 12 e posterior. Suas responsabilidades dizem respeito a contêineres de texto, elementos e fragmentos de layout. Para esta tarefa de coleção, a consequência arquitetural é manter a ordenação dos grupos no modelo da coleção, mesmo que um filho contenha um editor TextKit personalizado. Documentação da Apple sobre NSTextLayoutManager.

Como diagnosticar um grupo que se move incorretamente?

Comece com o menor exemplo visível e inspecione o modelo após uma operação. As verificações abaixo ajudam a distinguir um erro de ordenação de uma incompatibilidade entre as linhas exibidas e os dados subjacentes.

  1. Só o título se move: verifique se os filhos estão armazenados como entradas sem relação em um array plano. Mova o objeto pai que os contém ou implemente explicitamente uma movimentação de bloco para a representação plana.
  2. Mover para baixo não faz nada: verifique se o botão passa index + 1. Para um único item de origem, esse limite mantém o item no lugar.
  3. O grupo errado se move após a filtragem: verifique se os offsets visíveis estão sendo aplicados ao array completo. Resolva os IDs dos grupos visíveis no modelo canônico antes da mutação e defina onde deve ficar um destino filtrado.
  4. A ordem reverte imediatamente: verifique se o array exibido é ordenado novamente a cada renderização. A ordenação manual precisa estar em um estado que a interface realmente mantenha.
  5. Um filho se move de forma independente: verifique onde o manipulador de movimentação está conectado. Neste exemplo, ele pertence apenas à coleção externa de grupos.

Antes da integração, verifique as movimentações para cima e para baixo, as duas extremidades da lista, um único grupo restante e um grupo vazio. Confira as invariantes do modelo além da aparência: os mesmos IDs de grupo continuam presentes, cada grupo mantém os mesmos IDs de filhos e a ordem dos filhos permanece inalterada. Essas verificações testam diretamente o requisito de que o pai e seus filhos se movam juntos.

Para uma interface compacta para Mac, comece com um modelo pai por linha movível e uma única função compartilhada para reordenar. Assim, o usuário pode mover Group A com uma única ação, mantendo seus filhos vinculados. Adicione uma implementação hierárquica ou AppKit quando a interface exigir interação independente com os filhos ou comportamento de coleção baseado em seções.

Fontes