グループとその子要素をまとめて移動するには、親モデル内に子要素を格納し、親のコレクションを並べ替えます。このガイドでは、各グループが子要素を保持したまま1行として移動する、小さなSwiftUI Macリストを作成します。

このタスクでは、自分のアプリ内でグループを並べ替えます。たとえば、Tile A1とTile A2を含むGroup AをGroup Bの下に移動します。この例では、合成したローカルデータを使い、SwiftUIのリスト移動コールバックに加えて、明示的なMove UpボタンとMove Downボタンを用意します。

何を1つの移動可能な項目として扱うべきか?

移動単位をデータ内の単位と一致させます。このインターフェースでは、外側の配列にグループが入り、各グループは独自の子配列を持ちます。外側の配列を並べ替えると、各グループの子要素とその既存の順序を保ったまま、グループの順序が変わります。

各グループを、見出し、移動ボタン、字下げした子ラベルをVStack内に含む1つの複合リスト行として表します。外側のForEachにonMove(perform:)を付けます。Appleの説明によると、コールバックのオフセットはその動的ビューが基にするコレクションを指します。この場合、そのコレクションはグループ配列です。Appleの移動コールバックのドキュメント。

グループと子要素には安定した識別子を付けます。AppleのForEachでは、Identifiableまたは明示的な識別子キーパスによって要素を識別する必要があります。この設計では識別子をモデルに持たせ、位置が変わっても変更しません。並べ替え可能なコンテンツの識別子として配列の位置を使うのは避けてください。AppleのForEachのドキュメント。

SwiftUIのGroupビューは、以下で使うアプリケーションモデルではありません。コンテナに名前を付けたり、見出しを表示したりするだけでは、どの子レコードを一緒に移動すべきかは決まりません。親に格納されたchildren配列によって、その関係が明確になります。

SwiftUIの完全な例を作成する

Xcodeで、SwiftUIインターフェースを使い、デプロイメントターゲットをmacOS 12以降に設定したmacOSアプリプロジェクトを作成します。生成されたアプリのエントリファイルを以下の完全なプログラムに置き換え、ターゲット内の@mainアプリ宣言は1つだけにします。この例では、移動の動作を確認しやすいように、シンプルなリストとテキストボタンを使います。

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)
    }
}

外側のForEachは、グループごとに1つのVStackを生成します。内側のForEachはその行内に子要素を表示し、子要素用の個別の移動ハンドラーはありません。どちらの移動ボタンも、IDからグループの現在位置を見つけ、onMoveで使うものと同じ変更処理を呼び出します。

アプリを実行し、Group Aの横にあるMove Downをクリックします。順序はGroup B、Group A、Group Cとなり、Tile A1とTile A2は引き続きGroup Aの下に表示されます。そのグループの横にあるMove Upをクリックすると、最初の順序に戻ります。子要素のないグループも移動可能であることをGroup Cで確認できます。

このプログラムは、現在のビューが存続する間だけ順序をメモリに保持し、永続化レイヤーは含みません。参照資料と移動ロジックは確認しましたが、利用可能なシェルにSwiftコンパイラがないため、ここではコンパイルしていません。組み込む前に、Mac上のXcodeでビルドし、インターフェースを確認してください。

下方向への移動では、なぜ異なる移動先を使うのか?

move(fromOffsets:toOffset:)に渡す移動先は、元のコレクション内の挿入位置を示す境界です。Appleの定義では、移動した要素を挿入する直前のオフセットであり、範囲は0...countです。単一要素自身のオフセットや、その次のオフセットを渡しても変化はありません。Appleのコレクション移動のドキュメント。

説明用の開始順序[A, B, C]について、それぞれ独立した以下の操作で違いを確認できます。

意図する結果 元のオフセット moveに渡す移動先 結果
AをBの下に移動する 0 2 [B, A, C]
BをAの上に移動する 1 0 [B, A, C]
Aを末尾に移動する 0 3 [B, C, A]
Aをそのままにする 0 1 [A, B, C]

そのため、下方向ボタンではindex + 2を使います。次の隣接要素のさらに後ろにある要素の直前、または末尾に挿入するためです。上方向ボタンではindex - 1を使います。onMoveから直接渡されたオフセットの場合、このボタン固有の計算を重ねず、そのままコレクションのメソッドに渡してください。

この例の移動先の計算は、隣接する要素を1つ移動する場合に適用されます。移動コールバックに複数の元オフセットが含まれている場合は、完全なIndexSetをコレクションのメソッドに渡してください。このメソッドでは、移動した要素の順序が保たれると説明されています。Appleのコレクション移動のドキュメント。

別の実装が必要になるのはどのようなインターフェースか?

グループの表示方法と、ユーザーが子要素をどう操作する必要があるかに応じて実装を選びます。グループを常に1つのブロックとして移動させるなら、複合行の例が適しています。この例では、子要素を個別のリスト行ではなく、そのブロック内のラベルとして表示します。

インターフェースの要件 実装の出発点 移動を担う処理
グループ見出しと子要素を1つの移動可能なブロックにする グループごとに1つの複合行を持つ外側のForEach 親配列を並べ替える
子孫要素を含む展開可能な階層 OutlineGroup 対象の親コレクションに対するモデルの移動処理を実装する
グループごとに1つのセクションを持つAppKitコレクション NSCollectionView データソースを更新してから、セクション移動をコレクションビューに通知する
項目内のカスタム編集可能なテキスト 項目のエディター内でTextKitを使う グループの並べ替えは周囲のコレクションモデルで行う

展開可能なツリーには明示的な移動ルールが必要

Appleの説明では、OutlineGroupは識別可能なツリー構造データを、分岐を展開・折りたたみする開示コントロールとともに表示するビューです。macOS 11以降で利用できます。ドキュメントに記載された表示動作では、アプリケーションにおける一般的なサブツリー並べ替えの方針までは定まりません。AppleのOutlineGroupのドキュメント。

ツリーでは、移動先を正確に定義します。親を兄弟間で移動する操作と、別の親の中に移動する操作は異なります。兄弟間の並べ替えでは、1つの親配列を変更します。親を付け替える場合は、ノードを元の親から削除して新しい親に挿入し、そのノード自身の子孫内が移動先にならないようにします。これらはツリー構造から導かれる、アプリケーションモデルのルールです。

タスクが引き続きトップレベルのグループ全体の移動であれば、複合行の実装によって追加の移動先ルールを避けられます。子要素の個別選択や並べ替えが必要なら、階層を表示行に平坦化する前に、それらの操作を明確に設計してください。

AppKitでは、含まれる項目とともにセクションを移動できる

インターフェースがグループごとにセクションを持つNSCollectionViewの場合、AppleはmoveSection(_:toSection:)を提供しています。そのドキュメントでは、セクションとその中の項目の再編成が明示的に扱われており、呼び出し前にデータソースを更新する必要があります。このAPIはmacOS 10.11以降で利用できます。Appleのセクション移動のドキュメント。

この方法では、移動元と最終的な移動先のセクションインデックスを特定し、グループデータを並べ替えてから、セクション移動APIを使ってコレクションビューに通知します。SwiftUIの挿入位置の計算と、AppKitが要求する移動先セクションのインデックスは分けて扱ってください。両方のメソッドで移動先の意味が異なります。

SwiftUIにAppKitのコレクションを組み込むには、AppKitビューを作成・更新するためのApple公式のラッパーであるNSViewRepresentableを使います。適切な調整機構を通じてコレクションの操作をSwiftUIモデルに伝え、表現対象のビューの外側のレイアウトはSwiftUIに管理させます。AppleのNSViewRepresentableのドキュメント。

TextKitは項目内のテキストレイアウトを処理する

AppleはNSTextLayoutManagerを、カスタムテキストレイアウトと表示を管理するものと定義しており、macOS 12以降で利用できます。その役割は、テキストコンテナ、要素、レイアウトフラグメントに関するものです。このコレクションのタスクでは、子要素にカスタムTextKitエディターが含まれていても、グループの順序はコレクションモデルで管理します。AppleのNSTextLayoutManagerのドキュメント。

グループが正しく移動しない場合、どう診断するか?

まず、最小限の表示例で1回の操作後にモデルを確認します。以下の確認事項を使うと、順序の誤りと、表示行と基になるデータの不一致を区別できます。

  1. 見出しだけが移動する: 子要素がフラットな配列内の無関係な要素として格納されていないか確認します。子要素を所有する親オブジェクトを移動するか、フラットな表現用のブロック移動を明示的に実装します。
  2. 下に移動しても変化しない: ボタンがindex + 1を渡していないか確認します。単一の元要素の場合、その境界では要素の位置は変わりません。
  3. フィルタリング後に別のグループが移動する: 表示上のオフセットを配列全体に適用していないか確認します。変更前に表示中のグループIDを正規モデル内で解決し、フィルターされた場合に移動先をどこにするか定義します。
  4. 順序がすぐに元に戻る: 描画のたびに表示配列が再ソートされていないか確認します。手動の並べ替えでは、表示に保持される順序が必要です。
  5. 子要素が個別に移動する: 移動ハンドラーがどこに付いているか確認します。この例では、外側のグループコレクションだけに付けます。

組み込む前に、上方向と下方向の移動、リストの両端、グループが1つだけの場合、子要素のないグループを確認します。見た目だけでなく、モデルの不変条件も確認してください。同じグループIDが残っていること、各グループが同じ子IDを保持すること、子要素の順序が変わらないことを確かめます。これらの確認によって、親と子要素が一緒に移動するという要件を直接検証できます。

コンパクトなMacインターフェースでは、移動可能な行ごとに1つの親モデルを用意し、並べ替え関数を共通化するところから始めます。これにより、ユーザーは1回の操作でGroup Aを移動でき、子要素はそのまま付随します。子要素の個別操作やセクション単位のコレクション動作が必要な場合に、階層型またはAppKitの実装を追加します。

参考資料