ハイライト
SwiftUI に
reorderable、reorderContainer、dragContainer、dragConfigurationの 4 つの API が追加され、コンテナをまたいだ並べ替え、複数項目のドラッグ、意図の制御に UIKit への橋渡しが不要になります。純粋な SwiftUI だけで、カードゲーム級の複雑なドラッグ&ドロップ操作を実装できます。
主要内容
List.onMove からコンテナ横断の並べ替えへ
以前の SwiftUI でドラッグ&ドロップを扱う場合、リスト内の移動なら List の .onMove で十分でした。しかし複数列のカンバン、グリッドレイアウト、カードゲームのようなコンテナをまたぐ複雑な並べ替えになると、draggable と dropDestination だけでは力不足でした。開発者は座標計算、状態マシンの管理、場合によっては UIKit への橋渡しまで自前で行う必要がありました。
Apple は今回 reorderContainer と reorderable を導入し、コンテナ横断の並べ替えに必要な面倒な処理を引き受けます。
(02:11) 発表者はカードゲームを例に、reorderable の基本的な使い方を示します。ForEach 内の子ビューに .reorderable() を追加し、外側のコンテナに .reorderContainer(for: CardValue.self) を付けると、システムがプレースホルダー、退避アニメーション、位置計算を自動的に処理します。
(03:54) ビューをドラッグすると、そのビューはビュー階層から持ち上げられ、元の位置には空のプレースホルダーが残ります。ドラッグ中、他の要素は自動的に場所を空け、プレースホルダーはドロップ位置を反映してリアルタイムに更新されます。指を離すと、カードは新しい位置へ移動します。
複数の山札: collectionID でコンテナを区別する
(04:38) カードゲームには複数の山札があり、カードを山札間で自由に移動できる必要があります。reorderContainer は collectionID 引数で異なる子コンテナを識別できます。
GameView の HStack に reorderContainer を宣言し、要素型に CardValue、コンテナ型に Card.Group を指定します。次に PileView 内で、ドラッグ可能なカードに reorderable(collectionID:) を付け、それぞれの山札を一意の collectionID で識別します。
(05:58) ここには実用的なコツがあります。裏向きのカードはドラッグできるべきではありません。やり方は、山札を 2 つの ForEach に分けることです。1 つ目は裏向きカードを走査し、reorderable を付けません。2 つ目は表向きカードを走査し、.reorderable(collectionID:) を付けます。これで裏向きカードは自然にドラッグできなくなり、追加の状態判定は不要です。
複数カードのドラッグ: 一度に複数枚を動かす
(06:57) カードゲームの中核操作のひとつは、1 枚のカードをドラッグしたとき、その上に重なっているカードも一緒に動くことです。これには dragContainer API を使います。
reorderContainer は暗黙的に dragContainer と dropDestination の機能を提供しますが、独自の dragContainer を明示的に宣言して動作をカスタマイズすることもできます。クロージャでは、ドラッグされた要素の ID を受け取り、一緒にドラッグする要素の集合を返します。
(07:50) 発表者は GameView に dragContainer(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) 次に GameView の reorderContainer の下に dropDestination を追加し、CardValue 型の新しいカードを受け取ります。dropConfiguration クロージャでは 3 つのことを行います。
- ドラッグ位置に対応する山札のインデックスを計算する
- 現在の操作に
.moveが含まれているか確認する - ゲームロジックで今回の移動が合法かどうか検証する
移動が不正な場合、たとえばダイヤの 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:)はコンテナに付け、この領域を並べ替え用のサンドボックスとして宣言する- クロージャ内の
differenceはCollectionDifference<CardValue>で、apply(difference:)を呼ぶと配列を直接更新できる CardValueはIdentifiableに準拠し、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:)を付ける collectionIDはCard.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)
}
ポイント:
dragContainerはreorderContainerの後に宣言する必要がある- 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を返し、カードは自動的に元の位置へ戻る
重要な示唆
ドラッグ並べ替えに対応したカンバンアプリを作る
reorderContainer と collectionID を使えば、Trello 風の複数列カンバンを実装できます。各列が 1 つの collectionID になり、カードを列間でドラッグすると、システムが自動的に CollectionDifference を計算します。入口 API は reorderContainer(for:in:) と reorderable(collectionID:) です。
写真をまとめて整理するアルバムアプリを作る
dragContainer を使うと、複数選択した写真をまとめてドラッグできます。ユーザーが複数の写真を選択し、そのうち任意の 1 枚をドラッグすると、選択済みの写真がすべて一緒に動きます。dragPreviewsFormation(.stack) を使えば、プレビューを重ねた形で表示し、「複数枚の写真」という概念を視覚的にわかりやすく表現できます。
ルール検証に対応したカードゲームやボードゲームを作る
dropConfiguration のクロージャ内でゲームルールの検証を実装できます。たとえばチェスで駒を不正なマスへドラッグした場合は .forbidden を返し、駒は自動的に戻ります。手動でアニメーションや状態のロールバックを書くよりはるかに簡単で、SwiftUI が「戻る」アニメーションを処理します。
ファイルマネージャー風のサイドバーを作る
dragConfiguration(DragConfiguration(allowMove: true)) を使って、フォルダ間のファイル移動を実装できます。ドラッグ元は移動意図を宣言し、ターゲット側は dropConfiguration で最終判断を行います。ターゲットフォルダがいっぱい、または権限が足りない場合は .forbidden を返します。
複数リストをまたいで並べ替えできる設定パネルを作る
複数の設定グループを独立した List や VStack として作り、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