Mac アプリでリストを並べ替えるには、SwiftUI の onMove のソースと宛先を直接 move(fromOffsets:toOffset:) に渡します。宛先は元のコレクション内の境界を示すため、下方向の移動で手動で 1 を引くと間違った結果になります。

このチュートリアルでは、合成ラベルを使用して小さなネイティブ macOS リストを構築し、宛先の計算を説明し、誤配置された行を診断するためのテストマトリックスを提供します。スコープは、独自のアプリのリスト内でのアイテムの並べ替えです。

onMove の宛先とは何を意味するか?

Apple は、2 つの onMove(perform:) 引数を、動的ビューの基盤となるコレクションに対する相対的なオフセットとして定義しています。最初の引数はソースオフセットの IndexSet、2 番目は宛先オフセットです。これらの位置は、その特定の ForEach で使用されるコレクションに属します。Apple の onMove(perform:) ドキュメント。

move(fromOffsets:toOffset:) の場合、Apple は宛先を、移動する要素が挿入される前のオフセットとして定義し、count も末尾を意味するとして許可しています。これは元のコレクション内の要素間の境界と考えることができます。Apple の move(fromOffsets:toOffset:) ドキュメント。

元の順序 A, B, C, D, E を考えます。その要素オフセットは 0, 1, 2, 3, 4 であり、挿入境界は 0 から 5 までです。境界 4 は E の直前、境界 5 は最後のアイテムの後です。

ソースオフセット 1 から宛先 4 へ B を移動すると、したがって A, C, D, B, E になります。移動したアイテムはインデックス 3 に配置されますが、宛先引数は 4 でした。両方の値はゼロベースの位置を使用しますが、操作の異なる段階を記述しています。

この違いが、一般的な下方向移動エラーを説明しています。コレクションの move メソッドを呼び出す前に宛先 4 を 3 に変更するコードは、異なる境界を要求し、B を D の前に配置します。Apple の move メソッドを使用する場合は、コールバックの値を変更せずにそのまま渡してください。

完全な並べ替え可能な macOS リストを構築する

Xcode でネイティブ macOS SwiftUI アプリプロジェクトを使用します。以下の完全な例は、SwiftUI App ライフサイクルを使用するため、macOS 11 以降を対象としています。Apple は onMove 自体を macOS 10.15 から利用可能としています。生成されたアプリとコンテンツビューの宣言をこのコードで置き換え、ターゲットに @main エントリポイントが 1 つだけ残るようにしてください。Apple の App ドキュメント、Apple の onMove(perform:) ドキュメント。

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 は、ハンドラが変更する同じ配列をレンダリングします。各アイテムは、現在の位置とは独立した固定の識別子を持ちます。Apple は ForEach に識別可能な要素または明示的な身分キーを要求します。移動中に各識別子を保持することで、ビューが同じアイテムを表し続けることができます。Apple の ForEach ドキュメント。

配列はローカルビュー状態です。これを変更すると、SwiftUI はリストと表示順序を含む依存ビューを更新します。この例では順序をメモリ内に保持し、永続化の実装は含みません。Apple の State ドキュメント。

アプリを実行し、B を E の直前の境界にドラッグし、順序とコールバック表示の両方を確認してください。別のシナリオを試す前に Reset を使用してください。明示的な選択バインディングも複数行の選択をサポートします。Apple は選択セットと、編集モードに入らずに Mac で選択することを文書化しています。Apple の List ドキュメント。

このネイティブ macOS の例に EditButton() を追加しないでください。Apple の現在の可用性情報には iOS と Mac Catalyst が含まれますが、ネイティブ macOS は除外されています。チュートリアルのモバイル編集コントロールを、可用性を確認せずに Mac ターゲットにコピーすべきではありません。Apple の EditButton ドキュメント。

最終的な挿入インデックスをどう計算するか?

この計算が必要なのは、削除と挿入を自分で実装する場合、または移動を別のモデル操作に変換する場合のみです。これは Apple の文書化された挿入セマンティクスから導かれる説明モデルであり、組み込みの move メソッドを呼び出す前に適用する追加の調整ではありません。

計算は以下の通りです:

削除後の挿入インデックス = 元の宛先
                             - 宛先より下のソースオフセットの数

ソース {1} と宛先 4 の場合、1 つの削除された要素が元々境界の前にあります。削減後の配列は A, C, D, E で、挿入インデックスは 4 - 1 = 3 です。そこに B を挿入すると A, C, D, B, E になります。

複数のソースがある場合、単一の下方向補正では不十分です。ソース {1, 3} と宛先 5 の場合、移動するアイテムは B, D、残りの配列は A, C, E です。2 つのソースオフセットが宛先 5 より前にあるため、挿入インデックスは 3 です。結果は A, C, E, B, D です。

カスタム実装では、まず元の順序で移動要素を取得し、元の要素を削除します(より早い削除が後のソース位置を無効にしないように)。次に調整された挿入位置を計算し、取得したブロックを 1 回で挿入します。この配列ベースのリストでは、Apple の既存メソッドがそれらのステップを自分で実装することを防ぎます。

どのエッジケースをテストすべきか?

以下のケースは、元の順序 A, B, C, D, E に対する説明的な計算です。各ケースの前にその順序にリセットしてください。最後の列は、ドロップインジケータの外観に関係なく、期待されるモデルの結果です。

ソースオフセット 元の宛先 削除後の挿入インデックス 期待される順序
{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

2 つの単一アイテムの no-op ケースが重要です:アイテムを自分の境界または次の境界に挿入すると、順序は変わりません。Apple はこの動作を明示的に文書化しています。末尾の境界も、要素のサブスクリプトには使用できませんが有効です。Apple の move(fromOffsets:toOffset:) ドキュメント。

これら 7 ケースの背後にある算術モデルは、小さな決定論的な Python 計算で検証されました。SwiftUI プログラムは検査しましたが、Swift ツールチェーンと macOS ランタイムが利用できなかったため、ここでコンパイルまたは実行はしていません。実際のドラッグ操作を対象の macOS バージョンで確認してください。

なぜ行が依然として正しく移動しないのか?

コレクションの境界から始め、次に識別子と変更を確認してください。この順序で、宛先の計算問題と、表示行と格納データの不一致を区別できます。

  1. ForEach に渡されるコレクションを確認。 フィルタ済み配列を表示する場合、そのオフセットはそのフィルタ済み配列を表します。例えば、表示行 B, D のオフセットは 0, 1 です。ソース 0 を完全な配列に適用すると A が移動します。この結果は、Apple の文書化されたコレクション相対的なコールバック契約から導かれます。Apple の onMove(perform:) ドキュメント。
  2. レンダリングがデータを再ソートするか確認。 毎回の再描画でラベルでソートする場合、バッキング配列の順序を変更しても、永続的な手動表示順序を確立できません。そのビューで並べ替えを有効にする前に、手動並べ替えポリシーを選択してください。
  3. 識別子を確認。 アイテムを移動する際、各アイテムの識別子を保持してください。レンダリング中に識別子を再生成したり、変更する位置を移動可能なアイテムの身分として使用したりしないでください。Apple の ForEach ドキュメント。
  4. 重複作業を確認。 手動の宛先減算を削除し、ハンドラが権威あるコレクションに移動を 1 回だけ適用することを確認してください。2 回目の移動は、すでに変更された順序に作用します。

フィルタ済みまたはセクション分けされたインターフェースの場合、インデックスを変換する前に意図した動作を定義してください。移動が表示アイテムのみを並べ替えるのか、完全なコレクションを変更するのか、またはアイテムをセクション間で転送するのかを決定してください。表示アイテムをモデルの身分にマッピングし、そのポリシーに従って宛先を解決してください。数値オフセットだけでは、それらすべての選択を表現できません。

コールバックオフセットを、それらが記述するコレクションのスナップショットに結び付けておいてください。アプリが操作を延期し、その間に他の編集で配列が変更された場合、古い位置を再生するのではなく、意図したアイテムと宛先を再度解決してください。サンプルはローカル配列を即座に変更することで、その複雑さを回避しています。

AppKit や TextKit は宛先ルールを変更するか?

すでに NSTableView を使用しているアプリの場合、AppKit には独自のドロップ契約があります。NSTableView.DropOperation.above は行の上への挿入を意味し、.on はその行へのドロップを意味します。Apple は、最後の行の下へのドロップを行 n と操作 .above で文書化しています。ここで n は行数です。モデル移動に変換する前に、AppKit のドロップの行と操作の両方を解釈してください。Apple の NSTableView.DropOperation ドキュメント。

TextKit は、NSTextView やテキストレイアウトマネージャなどのコンポーネントを通じて、テキストストレージ、レイアウト、編集を扱います。この例で使用される配列並べ替え契約は提供しません。行にエディタも含まれる場合、そのテキスト編集実装を行順序を変更するコレクション操作から分離しておいてください。Apple の TextKit ドキュメント。

シンプルな Mac リストの場合、手動で並べ替えた 1 つのコレクションを維持し、アイテム識別子を保持し、onMove 引数を直接コレクションの move メソッドに渡してください。結果を診断したり、操作を別のモデルに変換したりする必要がある場合は、境界計算とテストマトリックスを使用してください。

出典