Per riordinare un elenco nella tua app per Mac, passa direttamente le posizioni di origine e destinazione di onMove di SwiftUI a move(fromOffsets:toOffset:). La destinazione indica un confine nella raccolta originale, quindi sottrarre manualmente uno durante gli spostamenti verso il basso produce un risultato errato.

Questa guida realizza un piccolo elenco macOS nativo con etichette sintetiche, spiega il calcolo della destinazione e fornisce una matrice di test per diagnosticare le righe posizionate in modo errato. L’ambito è il riordino degli elementi nell’elenco della propria app.

Che cosa indica la destinazione di onMove?

Apple definisce i due argomenti di onMove(perform:) come offset relativi alla raccolta sottostante della vista dinamica. Il primo argomento è un IndexSet di offset di origine; il secondo è l’offset di destinazione. Queste posizioni appartengono alla raccolta usata da quella specifica ForEach. La documentazione di onMove(perform:) di Apple.

Per move(fromOffsets:toOffset:), Apple definisce la destinazione come l’offset prima del quale inserire gli elementi spostati; è inoltre consentito usare count per indicare la fine. Pensala come un confine tra gli elementi della raccolta originale. La documentazione di move(fromOffsets:toOffset:) di Apple.

Considera l’ordine originale A, B, C, D, E. Gli offset degli elementi sono 0, 1, 2, 3, 4, mentre i confini di inserimento vanno da 0 a 5. Il confine 4 si trova immediatamente prima di E; il confine 5 si trova dopo l’ultimo elemento.

Spostare B dall’offset di origine 1 alla destinazione 4 produce quindi A, C, D, B, E. L’elemento spostato termina all’indice 3, anche se l’argomento di destinazione era 4. Entrambi i valori usano posizioni a base zero, ma descrivono fasi diverse dell’operazione.

Questa distinzione spiega il comune errore negli spostamenti verso il basso. Il codice che modifica la destinazione da 4 a 3 prima di chiamare il metodo di spostamento della raccolta richiede un confine diverso e posiziona B prima di D. Quando usi il metodo di spostamento di Apple, inoltra i valori della callback senza modificarli.

Crea un elenco macOS riordinabile completo

In Xcode usa un progetto di app SwiftUI nativa per macOS. L’esempio completo seguente richiede macOS 11 o versioni successive perché usa il ciclo di vita App di SwiftUI; Apple indica che onMove è disponibile già da macOS 10.15. Sostituisci le dichiarazioni generate per l’app e la vista del contenuto con questo codice, lasciando un solo punto di ingresso @main nel target. La documentazione di App di Apple, la documentazione di onMove(perform:) di Apple.

import SwiftUI
import Foundation

struct DemoItem: Identifiable {
    let id: Int
    let label: String

    static let samples: [DemoItem] = [
        DemoItem(id: 0, label: "A"),
        DemoItem(id: 1, label: "B"),
        DemoItem(id: 2, label: "C"),
        DemoItem(id: 3, label: "D"),
        DemoItem(id: 4, label: "E")
    ]
}

@main
struct ReorderDemoApp: App {
    var body: some Scene {
        WindowGroup {
            ReorderView()
        }
    }
}

struct ReorderView: View {
    @State private var items = DemoItem.samples
    @State private var selection = Set<Int>()
    @State private var lastMove = "No move yet"

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text("Drag rows to change their order")

            List(selection: $selection) {
                ForEach(items) { item in
                    Text(item.label)
                        .tag(item.id)
                }
                .onMove(perform: moveItems)
            }

            Text("Order: " + items.map(\.label).joined(separator: ", "))
            Text(lastMove)

            Button("Reset") {
                items = DemoItem.samples
                selection.removeAll()
                lastMove = "No move yet"
            }
        }
        .padding()
        .frame(minWidth: 420, minHeight: 300)
    }

    private func moveItems(from source: IndexSet, to destination: Int) {
        lastMove = "Source: \(Array(source)); destination: \(destination)"
        items.move(fromOffsets: source, toOffset: destination)
    }
}

ForEach visualizza lo stesso array che il gestore modifica. Ogni elemento ha un identificatore fisso, indipendente dalla posizione corrente. Apple richiede elementi identificabili o una chiave di identità esplicita per ForEach; mantenendo ogni identificatore durante lo spostamento, la vista continua a rappresentare lo stesso elemento. La documentazione di ForEach di Apple.

L’array è uno stato locale della vista. Le sue modifiche fanno sì che SwiftUI aggiorni le viste dipendenti, compreso l’elenco e l’ordine visualizzato. Questo esempio mantiene l’ordine in memoria e non implementa la persistenza. La documentazione di State di Apple.

Avvia l’app, trascina B fino al confine immediatamente prima di E e controlla sia l’ordine sia la visualizzazione della callback. Usa Reset prima di provare un altro scenario. Il binding esplicito della selezione supporta anche la selezione di più righe; Apple documenta gli insiemi di selezione e la selezione su Mac senza entrare in modalità di modifica. La documentazione di List di Apple.

Non aggiungere EditButton() a questo esempio macOS nativo. Le informazioni aggiornate sulla disponibilità pubblicate da Apple includono iOS e Mac Catalyst, ma escludono macOS nativo. Prima di copiare in un target Mac i controlli di modifica di un tutorial per dispositivi mobili, verifica che siano disponibili. La documentazione di EditButton di Apple.

Come calcolare l’indice di inserimento finale?

Questo calcolo serve solo se implementi direttamente la rimozione e l’inserimento o se traduci lo spostamento in un’altra operazione sul modello. È un modello esplicativo derivato dalla semantica di inserimento documentata da Apple, non una correzione aggiuntiva da applicare prima di chiamare il metodo di spostamento integrato.

Il calcolo è il seguente:

indice di inserimento dopo la rimozione = destinazione originale
                                        - numero di offset di origine inferiori alla destinazione

Per l’origine {1} e la destinazione 4, un elemento rimosso si trova inizialmente prima del confine. L’array ridotto è A, C, D, E e l’indice di inserimento è 4 - 1 = 3. Inserendo B in quella posizione si ottiene A, C, D, B, E.

Con più origini, una singola correzione verso il basso non basta. Con le origini {1, 3} e la destinazione 5, gli elementi spostati sono B, D; l’array rimanente è A, C, E. Due offset di origine precedono la destinazione 5, quindi l’indice di inserimento è 3. Il risultato è A, C, E, B, D.

Un’implementazione personalizzata deve prima acquisire gli elementi spostati nel loro ordine originale, poi rimuovere gli originali senza permettere che le rimozioni precedenti invalidino le posizioni di origine successive. Può quindi calcolare la posizione di inserimento corretta e inserire il blocco acquisito in un’unica operazione. Per questo elenco basato su un array, il metodo già disponibile di Apple evita di dover implementare questi passaggi.

Quali casi limite dovresti testare?

I casi seguenti sono calcoli esemplificativi per l’ordine originale A, B, C, D, E. Ripristina quest’ordine prima di ciascun caso. L’ultima colonna riporta il risultato atteso secondo il modello, indipendentemente dall’aspetto dell’indicatore di rilascio.

Offset di origine Destinazione originale Indice di inserimento dopo la rimozione Ordine atteso
{1} 4 3 A, C, D, B, E
{3} 1 1 A, D, B, C, E
{1} 5 4 A, C, D, E, B
{1} 1 1 A, B, C, D, E
{1} 2 1 A, B, C, D, E
{1, 3} 5 3 A, C, E, B, D
{1, 3} 3 2 A, C, B, D, E

I due casi senza effetto per il singolo elemento sono importanti: inserire un elemento al proprio confine o a quello immediatamente successivo non ne cambia l’ordine. Apple documenta esplicitamente questo comportamento. Anche il confine finale è valido, sebbene non possa essere usato per accedere a un elemento tramite indice. La documentazione di move(fromOffsets:toOffset:) di Apple.

Il modello aritmetico alla base di questi sette casi è stato verificato con un breve calcolo deterministico in Python. Il programma SwiftUI è stato esaminato, ma non è stato compilato né eseguito perché qui non erano disponibili una toolchain Swift e un runtime macOS. Verifica l’effettivo trascinamento sulla versione di macOS di destinazione.

Perché le righe possono ancora spostarsi in modo errato?

Parti dal confine della raccolta, poi controlla l’identità e la modifica. Questa sequenza permette di distinguere i problemi di calcolo della destinazione dalle discrepanze tra le righe visualizzate e i dati memorizzati.

  1. Controlla la raccolta passata a ForEach. Se mostra un array filtrato, i suoi offset descrivono quell’array filtrato. Per esempio, le righe visibili B, D hanno gli offset 0, 1; applicare l’origine 0 all’intero array sposterebbe A. La conseguenza deriva dal contratto documentato da Apple, secondo cui la callback è relativa alla raccolta. La documentazione di onMove(perform:) di Apple.
  2. Controlla se il rendering ordina di nuovo i dati. Se ogni ridisegno ordina per etichetta, modificare l’ordine dell’array sottostante non può stabilire un ordine manuale persistente. Definisci una politica per l’ordinamento manuale prima di abilitare il riordino in quella vista.
  3. Controlla gli identificatori. Mantieni l’identificatore di ogni elemento mentre lo sposti. Non rigenerare gli identificatori durante il rendering e non usare posizioni variabili come identità degli elementi spostabili. La documentazione di ForEach di Apple.
  4. Controlla che l’operazione non venga eseguita due volte. Rimuovi la sottrazione manuale della destinazione, quindi verifica che il gestore applichi una sola volta lo spostamento alla raccolta autorevole. Un secondo spostamento agisce su un ordine già modificato.

Per interfacce filtrate o suddivise in sezioni, definisci il comportamento previsto prima di tradurre gli indici. Decidi se lo spostamento riordina solo gli elementi visibili, modifica l’intera raccolta o trasferisce un elemento tra sezioni. Mappa gli elementi visibili alle identità del modello e determina la destinazione in base a quella politica. Un semplice offset numerico non può rappresentare tutte queste scelte.

Mantieni gli offset della callback legati all’istantanea della raccolta che descrivono. Se l’app rimanda l’operazione mentre altre modifiche cambiano l’array, determina di nuovo gli elementi e la destinazione desiderati invece di riutilizzare posizioni non più attuali. L’esempio evita questa complicazione modificando subito l’array locale.

AppKit o TextKit cambiano le regole della destinazione?

Per un’app che usa già NSTableView, AppKit dispone di un proprio contratto per il rilascio. NSTableView.DropOperation.above indica un inserimento sopra una riga, mentre .on indica il rilascio sulla riga. Apple documenta un rilascio sotto l’ultima riga come riga n con operazione .above, dove n è il numero di righe. Prima di tradurre un rilascio di AppKit in uno spostamento del modello, interpreta sia la riga sia l’operazione. La documentazione di NSTableView.DropOperation di Apple.

TextKit gestisce la memorizzazione, il layout e la modifica del testo tramite componenti come NSTextView e i gestori del layout del testo. Non fornisce il contratto di riordino degli array usato in questo esempio. Se una riga contiene anche un editor, mantieni la sua implementazione di modifica del testo separata dall’operazione sulla raccolta che cambia l’ordine delle righe. La documentazione di TextKit di Apple.

Per un semplice elenco Mac, mantieni una raccolta con ordinamento manuale, conserva gli identificatori degli elementi e passa direttamente gli argomenti di onMove al metodo di spostamento della raccolta. Usa il calcolo dei confini e la matrice di test quando devi diagnosticare il risultato o tradurre l’operazione in un modello diverso.

Fonti