WWDC Quick Look 💓 By SwiftGGTeam
Code-along: SwiftUI で強力なドラッグ&ドロップを構築する

Code-along: SwiftUI で強力なドラッグ&ドロップを構築する

元の動画を見る

ハイライト

SwiftUI に reorderablereorderContainerdragContainerdragConfiguration の 4 つの API が追加され、コンテナをまたいだ並べ替え、複数項目のドラッグ、意図の制御に UIKit への橋渡しが不要になります。純粋な SwiftUI だけで、カードゲーム級の複雑なドラッグ&ドロップ操作を実装できます。

主要内容

List.onMove からコンテナ横断の並べ替えへ

以前の SwiftUI でドラッグ&ドロップを扱う場合、リスト内の移動なら List.onMove で十分でした。しかし複数列のカンバン、グリッドレイアウト、カードゲームのようなコンテナをまたぐ複雑な並べ替えになると、draggabledropDestination だけでは力不足でした。開発者は座標計算、状態マシンの管理、場合によっては UIKit への橋渡しまで自前で行う必要がありました。

Apple は今回 reorderContainerreorderable を導入し、コンテナ横断の並べ替えに必要な面倒な処理を引き受けます。

(02:11) 発表者はカードゲームを例に、reorderable の基本的な使い方を示します。ForEach 内の子ビューに .reorderable() を追加し、外側のコンテナに .reorderContainer(for: CardValue.self) を付けると、システムがプレースホルダー、退避アニメーション、位置計算を自動的に処理します。

(03:54) ビューをドラッグすると、そのビューはビュー階層から持ち上げられ、元の位置には空のプレースホルダーが残ります。ドラッグ中、他の要素は自動的に場所を空け、プレースホルダーはドロップ位置を反映してリアルタイムに更新されます。指を離すと、カードは新しい位置へ移動します。

複数の山札: collectionID でコンテナを区別する

(04:38) カードゲームには複数の山札があり、カードを山札間で自由に移動できる必要があります。reorderContainercollectionID 引数で異なる子コンテナを識別できます。

GameViewHStackreorderContainer を宣言し、要素型に CardValue、コンテナ型に Card.Group を指定します。次に PileView 内で、ドラッグ可能なカードに reorderable(collectionID:) を付け、それぞれの山札を一意の collectionID で識別します。

(05:58) ここには実用的なコツがあります。裏向きのカードはドラッグできるべきではありません。やり方は、山札を 2 つの ForEach に分けることです。1 つ目は裏向きカードを走査し、reorderable を付けません。2 つ目は表向きカードを走査し、.reorderable(collectionID:) を付けます。これで裏向きカードは自然にドラッグできなくなり、追加の状態判定は不要です。

複数カードのドラッグ: 一度に複数枚を動かす

(06:57) カードゲームの中核操作のひとつは、1 枚のカードをドラッグしたとき、その上に重なっているカードも一緒に動くことです。これには dragContainer API を使います。

reorderContainer は暗黙的に dragContainerdropDestination の機能を提供しますが、独自の dragContainer を明示的に宣言して動作をカスタマイズすることもできます。クロージャでは、ドラッグされた要素の ID を受け取り、一緒にドラッグする要素の集合を返します。

(07:50) 発表者は GameViewdragContainer(for: CardValue.self) を追加し、クロージャ内で game.cardStack(startingAt: cardID) を呼び出して、指定カードから始まる重なったカード群を取得します。クラブの 4 をドラッグすると、その上にあるダイヤの 3 も一緒に持ち上がります。

ドラッグプレビューの見た目をカスタマイズする

(08:45) 複数枚のカードを一緒にドラッグすると、デフォルトのプレビューはそれらを重ねた山として表示します。dragPreviewsFormation を使うとこの見た目をカスタマイズでき、.stack.pile.list などを選べます。

発表者は .stack を選びました。カードが重なる視覚表現がゲームの直感にもっとも合うからです。さらにドラッグ中のプレビューを一貫させるため、ルートレイアウトに .dropPreviewsFormation(.stack) も追加しています。これにより、カードがターゲット領域上にホバーしても見た目が変わりません。

データ転送の意図を制御する: Move か Copy か

(10:05) SwiftUI のドラッグ&ドロップはデフォルトで Copy として扱われます。カードゲームでは、山札から場にカードをドラッグする操作はコピーではなく移動、つまり Move であるべきです。ここで新しい Drag Configuration API を使います。

(11:40) RemainderView の現在表向きになっているカードに .dragConfiguration(DragConfiguration(allowMove: true)) を付け、転送意図が Move であることを宣言します。

(12:05) 次に GameViewreorderContainer の下に dropDestination を追加し、CardValue 型の新しいカードを受け取ります。dropConfiguration クロージャでは 3 つのことを行います。

  1. ドラッグ位置に対応する山札のインデックスを計算する
  2. 現在の操作に .move が含まれているか確認する
  3. ゲームロジックで今回の移動が合法かどうか検証する

移動が不正な場合、たとえばダイヤの Q をダイヤの 7 の上に置こうとした場合は .forbidden を返し、カードは自動的に元の位置へ戻ります。

詳細

基本的な並べ替え: Preview で素早く検証する

(03:40) ゲームに正式統合する前に、まず Preview で並べ替えの挙動を検証できます。

#Preview {
    @Previewable @State var cards = [
        CardValue(rank: .ace, suit: .clubs),
        CardValue(rank: .ace, suit: .diamonds),
        CardValue(rank: .ace, suit: .hearts),
        CardValue(rank: .ace, suit: .spades)
    ]

    HStack {
        ForEach(cards) { card in
            CardFaceView(card: card)
        }
        .reorderable()
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity)
    .reorderContainer(for: CardValue.self) { difference in
        cards.apply(difference: difference)
    }
    .padding()
    .background(.green.gradient)
}

ポイント:

  • .reorderable()ForEach に付け、この集合内の要素を並べ替え可能としてマークする
  • .reorderContainer(for:) はコンテナに付け、この領域を並べ替え用のサンドボックスとして宣言する
  • クロージャ内の differenceCollectionDifference<CardValue> で、apply(difference:) を呼ぶと配列を直接更新できる
  • CardValueIdentifiable に準拠し、ID はグローバルに一意である必要がある

山札をまたぐ並べ替え: GameView での完全な構成

(04:40) ゲームのメインビューで、すべての山札を同じ並べ替えコンテナに含めます。

struct GameView: View {
    var game: Game

    var body: some View {
        GeometryReader { proxy in
            let spacing: CGFloat = 10
            let cardWidth = (proxy.size.width - 6 * spacing) / 7
            VStack {
                HStack(alignment: .top, spacing: spacing) {
                    Group {
                        RemainderView(game: game)
                        CardBackView()
                            .hidden()
                        ForEach(CardValue.Suit.allCases) { suit in
                            DestinationView(game: game, suit: suit)
                        }
                    }
                    .frame(width: cardWidth)
                }
                .padding(.bottom, 20)
                HStack(alignment: .top, spacing: spacing) {
                    ForEach(0..<7) { index in
                        PileView(game: game, index: index)
                            .frame(width: cardWidth)
                    }
                }
                .frame(maxHeight: .infinity, alignment: .top)
                .reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
                    game.moveCards(difference: difference)
                }
            }
        }
        .padding()
    }
}

ポイント:

  • reorderContainer には 2 つのジェネリック引数があり、要素型 CardValue とコンテナ型 Card.Group を指定する
  • Card.Group.pile(0).pile(1) のように各山札を一意に識別する
  • クロージャ内の difference には山札をまたぐ移動情報が含まれ、game.moveCards(difference:) がデータモデルを更新する

山札ビュー: ドラッグ可能な要素と不可な要素を分ける

(05:58) PileView では、裏向きカードはドラッグ不可、表向きカードはドラッグ可能にします。

struct PileView: View {
    var game: Game
    var index: Int
    @Query var cards: [Card]

    var body: some View {
        ZStack(alignment: .topLeading) {
            CardPlaceholderView()
            PileLayout {
                let index = firstFaceUpIndex
                ForEach(cards[..<index]) { card in
                    CardView(card: card)
                }
                ForEach(cards[index...], id: \.value) { card in
                    CardView(card: card)
                }
                .reorderable(collectionID: Card.Group.pile(index))
            }
        }
    }

    var firstFaceUpIndex: Int {
        cards.firstIndex { !$0.isFaceDown } ?? cards.endIndex
    }
}

ポイント:

  • 2 つの ForEach が裏向きカードと表向きカードを別々に扱う
  • 2 つ目の ForEach だけに .reorderable(collectionID:) を付ける
  • collectionIDCard.Group 型でなければならず、reorderContainer で宣言したコンテナ型と一致する必要がある
  • 裏向きカードには reorderable がないため、自然にドラッグできない

複数カードのドラッグ: dragContainer で重なったカードを追従させる

(07:50) 1 枚のカードをドラッグすると、その上のカードも一緒に動くようにします。

.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
    game.moveCards(difference: difference)
}
.dragContainer(for: CardValue.self) { cardID in
    game.cardStack(startingAt: cardID)
}

ポイント:

  • dragContainerreorderContainer の後に宣言する必要がある
  • 2 つの modifier は同じ要素型 CardValue を使う
  • クロージャはドラッグされたカード ID を受け取り、一緒にドラッグする CardValue の集合を返す
  • game.cardStack(startingAt:) はゲームルールに基づいて重なったカード一覧を計算する

プレビューの見た目をカスタマイズする

(08:45) 複数枚のカードをドラッグするときの視覚表現を制御します。

.dragContainer(for: CardValue.self) { cardID in
    game.cardStack(startingAt: cardID)
}
.dragPreviewsFormation(.stack)

(09:14) ルートレイアウトに dropPreviewsFormation を追加し、ドラッグ全体で見た目を一貫させます。

VStack {
    // ... ゲームレイアウト ...
}
.dropPreviewsFormation(.stack)

ポイント:

  • .dragPreviewsFormation(.stack) はドラッグ中のカードを重ねた形で表示する
  • .dropPreviewsFormation(.stack) はターゲット上にホバーしている間もカードを重ねた見た目に保つ
  • 利用できる値には .stack.pile.list などがある

山札から場へドラッグする: 完全な Move 構成

(11:40) 山札の現在カードに移動意図を宣言します。

struct RemainderView: View {
    @Query var cards: [Card]
    var game: Game

    var body: some View {
        // ...
        ZStack {
            CardPlaceholderView()
            if let currentCard {
                CardFaceView(card: currentCard.value)
                    .draggable(containerItemID: currentCard.value)
                    .opacity(currentCard.value == hiddenCard ? 0 : 1)
            }
        }
        .dragContainer(for: CardValue.self) { cardID in
            [cardID]
        }
        .dragConfiguration(DragConfiguration(allowMove: true))
    }
}

ポイント:

  • .draggable(containerItemID:) はこのビューをドラッグ可能として宣言し、転送するデータを指定する
  • .dragConfiguration(DragConfiguration(allowMove: true)) は転送意図が Move であることを宣言する
  • 山札の dragContainer[cardID]、つまり 1 枚だけを返す。山札は一度に 1 枚だけ出すため

(12:05) GameView で Drop ターゲットを構成し、山札からドラッグされたカードを受け取り、ルールを検証します。

.reorderContainer(for: CardValue.self, in: Card.Group.self) { difference in
    game.moveCards(difference: difference)
}
.dragContainer(for: CardValue.self) { cardID in
    game.cardStack(startingAt: cardID)
}
.dragPreviewsFormation(.stack)
.dragConfiguration(DragConfiguration(allowMove: true))
.dropDestination(for: CardValue.self) { newCards, session in
    if let destination = session.reorderDestination(
        for: CardValue.self, in: Card.Group.self) {
        game.insertCards(newCards, to: destination)
    }
}
.dropConfiguration { session in
    let alignedX = session.location.x - 0.5 * spacing
    let pile = Int(alignedX / (cardWidth + spacing))
    let destination = ReorderDifference<CardValue, Card.Group>
        .Destination(position: .end, collectionID: .pile(pile))
    let allowed = session.suggestedOperations.contains(.move)
        && game.validateMove(session: session, destination: destination)
    let operation: DropOperation = allowed ? .move : .forbidden
    return DropConfiguration(operation: operation, destination: destination)
}

ポイント:

  • .dropDestination(for:) はこの領域が CardValue 型のドラッグデータを受け取れることを宣言する
  • session.reorderDestination(for:in:)reorderContainer からターゲット位置情報を取得する
  • .dropConfiguration は最終判断点で、操作種別とターゲット位置を決める DropConfiguration を返す
  • session.location は現在のドラッグ位置を取得し、どの山札上にあるかを計算するために使う
  • session.suggestedOperations.contains(.move) はドラッグ元が move を提案しているかを確認する
  • game.validateMove(session:destination:) はゲームルールで今回の移動が合法かどうかを検証する
  • 不正な場合は .forbidden を返し、カードは自動的に元の位置へ戻る

重要な示唆

ドラッグ並べ替えに対応したカンバンアプリを作る

reorderContainercollectionID を使えば、Trello 風の複数列カンバンを実装できます。各列が 1 つの collectionID になり、カードを列間でドラッグすると、システムが自動的に CollectionDifference を計算します。入口 API は reorderContainer(for:in:)reorderable(collectionID:) です。

写真をまとめて整理するアルバムアプリを作る

dragContainer を使うと、複数選択した写真をまとめてドラッグできます。ユーザーが複数の写真を選択し、そのうち任意の 1 枚をドラッグすると、選択済みの写真がすべて一緒に動きます。dragPreviewsFormation(.stack) を使えば、プレビューを重ねた形で表示し、「複数枚の写真」という概念を視覚的にわかりやすく表現できます。

ルール検証に対応したカードゲームやボードゲームを作る

dropConfiguration のクロージャ内でゲームルールの検証を実装できます。たとえばチェスで駒を不正なマスへドラッグした場合は .forbidden を返し、駒は自動的に戻ります。手動でアニメーションや状態のロールバックを書くよりはるかに簡単で、SwiftUI が「戻る」アニメーションを処理します。

ファイルマネージャー風のサイドバーを作る

dragConfiguration(DragConfiguration(allowMove: true)) を使って、フォルダ間のファイル移動を実装できます。ドラッグ元は移動意図を宣言し、ターゲット側は dropConfiguration で最終判断を行います。ターゲットフォルダがいっぱい、または権限が足りない場合は .forbidden を返します。

複数リストをまたいで並べ替えできる設定パネルを作る

複数の設定グループを独立した ListVStack として作り、reorderContainer で同じ並べ替えサンドボックスに含めます。ユーザーは設定項目をあるグループから別のグループへドラッグでき、データモデルは CollectionDifference を通じて自動的に同期されます。

関連 Session

  • 272 - SwiftUI + AppKit/UIKit — SwiftUI で AppKit/UIKit のドラッグ&ドロップ機能を橋渡しする方法を学び、この Session の純粋な SwiftUI アプローチを補完します
  • 269 - SwiftUI — SwiftUI 全体の新機能を概観し、ドラッグ&ドロップ API がフレームワーク内でどこに位置づけられるかを理解します
  • 321 - Lazy stacks — ドラッグ&ドロップは大規模リストと組み合わせることが多く、Lazy stacks の性能改善は滑らかなドラッグに重要です
  • 278 - UIKit modernization — UIKit で同様の機能が必要な場合に、UIKit のモダンなドラッグ&ドロップ API を理解します
  • 370 - TextKit — テキスト編集場面でのドラッグ&ドロップ処理。SwiftUI のドラッグ&ドロップ API と組み合わせて使えます

コメント

GitHub Issues · utterances