開発者向け
iOS 27 SwiftUI のリスト並べ替え:reorderable() とスワイプ
iOS 27 の reorderable() と reorderContainer で SwiftUI のリストやグリッドをドラッグで並べ替える方法と、List 外のスワイプアクションを iPhone と iPhone Duo 向けに解説。
AppleDuo 編集部公開 14分で読めます
このページの内容
「SwiftUI のリストをユーザーが並べ替えられるようにするには?」という問いへの答えは、長年同じでした。List 内の ForEach に .onMove を付け、EditButton を追加し、インデックスのオフセットをモデルに変換し直す、というものです。これは List でしか動かず、編集モードも必要でした。このガイドでは、どのコンテナでも動き、そのどちらも必要としない iOS 27 の SwiftUI リスト並べ替え API を解説します。
iOS 27 では、List 専用だったもう 1 つの制限、スワイプアクションも解消されます。どちらの変更も WWDC26 で発表され、Apple の SwiftUI リファレンスに記載されています。この記事は、そのドキュメント、Apple のセッション「Code-along: Build powerful drag and drop in SwiftUI」、iOS デベロッパ @sarunw の投稿に基づいています。これらの API は iOS 27.0 で新たに追加されたもので、リリース間で詳細が変わる可能性があります。
iOS 27 で SwiftUI のリストを並べ替えるには?
必要なモディファイアは 2 つです。@sarunw のまとめによると、iOS 27 では ForEach 自体がドラッグによる並べ替えに対応します。.reorderable() を付け、List に .reorderContainer を付けるだけです。「.onMove も編集モードも不要」です。
reorderable()は、ユーザーが移動できるビューを持つForEach(任意のDynamicViewContent)に付けます。reorderContainer(for:isEnabled:move:)はコンテナに付け、ドラッグの範囲を定義します。アイテムの型と、変更を受け取るmoveクロージャを渡します。
struct TaskListView: View {
@State private var tasks: [TaskItem] = TaskItem.samples
var body: some View {
List {
ForEach(tasks) { task in
TaskRow(task: task)
}
.reorderable()
}
.reorderContainer(for: TaskItem.self) { difference in
tasks.apply(difference)
}
}
}
TaskItem は Identifiable で、Apple のガイドでは ID の型が Hashable かつ Sendable であることが求められています。apply(_:) の呼び出しは私たち独自のヘルパーで、次のセクションで紹介します。SwiftUI は配列用のヘルパーを提供していません。
操作の流れは Apple が説明しています。ユーザーがドラッグジェスチャでビューを持ち上げると、システムがドロップできる位置にプレースホルダを置き、プレースホルダがドラッグに追従し、ドロップするとシステムが move を呼び出します。ジェスチャやアニメーションのコードを書く必要はありません。
コンテナは List でなくてもかまいません。Apple のドキュメントによると、リスト、スタック、グリッド、カスタムレイアウトのいずれでもよく、Apple 自身のサンプルでは通常の VStack に .reorderContainer を適用し、WWDC26 のセッションではトランプのカードを並べた HStack を使っています。
並べ替えのクロージャでデータを更新するには?
move クロージャは ReorderDifference を受け取ります。これは変更をインデックスではなく ID で表します。
| プロパティ | 型 | 意味 |
|---|---|---|
sources | [ItemID] | 移動したアイテムの ID(ユーザーが選択した順) |
destination.position | .before(_:) または .end | その ID のアイテムの前に挿入、または末尾に追加 |
destination.collectionID | 独自のコレクション ID 型 | アイテムを受け取るセクション(複数コレクションのコンテナ) |
ID で扱うことには実用的な利点があります。Apple のガイドによると、ID は配列が変わっても正しい要素を指し続けるため、「クラウド同期など、配列へのほかの変更が同時に起きても」アイテムを移動できます。.onMove の IndexSet ではそうはいきません。
単一コレクションの差分を配列に適用する方法の一例を示します。移動したアイテムを取り除き、選択順に移動先へ挿入し直します。
@available(iOS 27.0, *)
extension Array where Element: Identifiable, Element.ID: Sendable {
mutating func apply(
_ difference: ReorderDifference<Element.ID, ReorderableSingleCollectionIdentifier>
) {
let moved = difference.sources.compactMap { id in
first { $0.id == id }
}
removeAll { difference.sources.contains($0.id) }
switch difference.destination.position {
case .before(let id):
let index = firstIndex { $0.id == id } ?? endIndex
insert(contentsOf: moved, at: index)
case .end:
append(contentsOf: moved)
}
}
}
ジェネリック型は単一コレクション用のオーバーロードに合わせています。そのクロージャは ReorderDifference<Item.ID, ReorderableSingleCollectionIdentifier> を受け取ります。このヘルパーはあくまで出発点として扱い、自分のモデルでテストしてください。
セクションをまたいで並べ替える
セクション分けしたデータでは、reorderable(collectionID:) で各 ForEach に識別子を与え、セクションを識別する型を指定して reorderContainer(for:in:isEnabled:move:) を使います。これでセクション内でもセクション間でもアイテムを移動でき、どのセクションが受け取ったかは destination.collectionID でわかります。
List {
ForEach(model.sections) { section in
Section(section.name) {
ForEach(section.reminders) { reminder in
ReminderView(reminder)
}
.reorderable(collectionID: section.id)
}
}
}
.reorderContainer(for: Reminder.self, in: ReminderModel.Section.ID.self) { difference in
model.apply(difference: difference)
}
これは Apple のドキュメントのサンプルに沿ったもので、model.apply(difference:) は自分のモデル側のコードです。ForEach が id 以外の ID を使う場合は、itemID: を取るオーバーロードに \.databaseID のようなキーパスを渡します。
reorderable() は onMove や EditButton と何が違う?
従来のパターンも引き続き動きます。移行する前に、何が変わるのかを正確に把握しておきましょう。
.onMove + EditButton | .reorderable() + .reorderContainer | |
|---|---|---|
| 導入 | iOS 13 | iOS 27.0 |
| コンテナ | List | リスト、スタック、グリッド、カスタムレイアウト |
| 開始方法 | ドラッグハンドル(通常は編集モードで表示) | システムのドラッグジェスチャ |
| クロージャの入力 | (IndexSet, Int) のオフセット | アイテム ID を持つ ReorderDifference |
| セクション間の移動 | ForEach ごとに個別の onMove | reorderable(collectionID:) と 1 つのクロージャ |
| 複数アイテム | ドキュメントに記載なし | sources に選択順で並ぶ |
| 無効化 | onMove に nil を渡す | isEnabled: false |
| ほかのアプリからのドラッグ | 別途 onInsert または dropDestination | dropDestination と reorderDestination を組み合わせる |
Hacking with Swift の定番ガイドでは、従来の構成として、ForEach に onMove(perform:) を付け、ドラッグハンドルを表示するためにツールバーに EditButton を置く方法が紹介されています。Apple の onMove のドキュメントでは、クロージャの引数を元のコレクション内のオフセットと説明しており、従来のコードが move(fromOffsets:toOffset:) で終わることが多いのはそのためです。
isEnabled パラメータは実際のアプリで便利です。Apple のガイドでは、同期中は並べ替えを無効にする例として isEnabled: !model.isSaving が示されています。
セッションでは、ソリティアの例からデザイン上のヒントも 1 つ紹介されています。一部のアイテムを固定したい場合は、それらを .reorderable() を付けない別の ForEach に分けます。持ち上げられるのは指定したコンテンツだけです。同じ ForEach に .onMove と .reorderable() を組み合わせることについて Apple のドキュメントには記載がないので、どちらか一方を選んでください。
iOS 27 では List の外でもスワイプアクションを使える?
使えます。@sarunw は端的に「スワイプアクションにはこれまで List の行が必要だったが、iOS 27 ではどのビューにも付けられる」と述べています。それを可能にしているのが swipeActionsContainer() です。
各行には、List のときとまったく同じように .swipeActions を書きます。そのうえで、行を含む ScrollView、LazyVStack などのコンテナに .swipeActionsContainer() を適用します。
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item)
.swipeActions {
Button("Delete", role: .destructive) {
delete(item)
}
}
}
}
}
.swipeActionsContainer()
Apple のドキュメントには、このコンテナが調整する内容が挙げられています。
- スワイプアクションが表示されるのは一度に 1 行だけです。
- コンテナをスクロールすると、開いているアクションが閉じます。
- アクティブな行の外側をタップすると、そのアクションが閉じます。
List はこれらをすべてすでに行っているため、Apple は List にこのモディファイアを適用しても何も起きないとしています。Majid Jabrayilov の記事も同じ結論で、List 以外ではコンテナのモディファイアが必要です。
iOS 27 では swipeActions(edge:allowsFullSwipe:content:onPresentationChanged:) も追加されています。このクロージャは、行のアクションが表示されると true、閉じられると false を受け取ります。Apple は、アクションの表示中に行を暗くしたり周囲の UI を更新したりする用途を提案しています。
確認しておきたい既知の問題
Apple のフォーラムには、自分のビルドで確認しておく価値のあるデベロッパの報告が 2 つあります。1 つは、右から左に書くレイアウトではアクションは反転した側に表示されるのに、ジェスチャは左から右の方向のままだというものです。もう 1 つは、List の外では状態が変わってもスワイプアクションの内容が再評価されず、行が作り直されるまで「Pin」のタイトルが「Unpin」に切り替わらないことがあるというものです。執筆時点では、どちらのスレッドにも Apple からの回答はありませんでした。
これらの API は以前の iOS にバックデプロイされる?
されません。reorderable()、すべての reorderContainer オーバーロード、swipeActionsContainer()、onPresentationChanged のオーバーロードは、iOS 27.0、iPadOS 27.0、Mac Catalyst 27.0、macOS 27.0、visionOS 27.0、watchOS 27.0 から利用可能とドキュメントに記載されています。WWDC26 のセッションでは、並べ替えの API は「ドラッグ&ドロップをサポートするすべての Apple プラットフォームで新たに利用可能」と説明されています。
iOS 26 以前をサポートする場合は、#available で分岐し、従来の方法を残します。
@ViewBuilder
private var taskList: some View {
if #available(iOS 27.0, *) {
List {
ForEach(tasks) { TaskRow(task: $0) }
.reorderable()
}
.reorderContainer(for: TaskItem.self) { tasks.apply($0) }
} else {
List {
ForEach(tasks) { TaskRow(task: $0) }
.onMove { tasks.move(fromOffsets: $0, toOffset: $1) }
}
.toolbar { EditButton() }
}
}
以前のバージョンでは、List 外のスワイプアクションにフォールバックはありません。Nil Coalescing によると、iOS 27 より前は LazyVStack やグリッドの行に付けても効果がありませんでした。そうしたバージョンでは行を List に残すか、同じアクションをコンテキストメニューで提供してください。
iPhone Duo ではどう役立つ?
iPhone Duo では、内側ディスプレイが iPad と同じく regular の水平サイズクラスを返し、外側ディスプレイは compact のままです(iPhone Duo のサイズクラスのガイドを参照)。多くのアプリは、この広い幅に対して 1 列の List ではなく、グリッドやカスタムレイアウトで応えます。
iOS 27 までは、その選択には機能面の代償がありました。カードを並べた LazyVGrid には組み込みの並べ替えもスワイプアクションもなかったため、内側ディスプレイでグリッドに切り替えるには、その両方を自前で作り直す必要がありました。今では、サイズクラスの切り替えで外側ディスプレイに List、内側ディスプレイにグリッドを表示する場合でも、同じ .reorderable() と .swipeActions のコードがそのまま使えます。
let columns = [GridItem(.adaptive(minimum: 220))]
ScrollView {
LazyVGrid(columns: columns) {
ForEach(boards) { board in
BoardCard(board: board)
}
.reorderable()
}
}
.reorderContainer(for: Board.self) { boards.apply($0) }
Apple のガイドによると、並べ替えが扱うのはコンテナ内の移動だけです。アイテムをアプリのほかの場所へドラッグしたり、ほかのアプリから受け入れたりするのは別のステップで、dragContainer(for:in:_:) と dropDestination(for:isEnabled:action:) を使います。内側ディスプレイで ArrangementView を使って 2 つのペインを並べる場合、分割そのものについては ArrangementView のガイドで解説しています。
まとめ
- iOS 27 では、
ForEachに.reorderable()、そのコンテナに.reorderContainer(for:)を付けるだけで、編集モードも.onMoveもなしにドラッグで並べ替えられます。 - コンテナは
List、スタック、グリッド、カスタムレイアウトのいずれでもかまいません。 moveクロージャはReorderDifferenceを受け取ります。移動した ID はsourcesに、移動先は.before(_:)または.endのdestinationに入っています。配列への適用は自分で行います。- セクション間でアイテムを移動するには、
reorderable(collectionID:)とreorderContainer(for:in:)を使います。 - スワイプアクションは
Listの外でも使えるようになりました。ScrollViewやレイアウトに.swipeActionsContainer()を付けます。 - ここで紹介した API はすべて iOS 27.0 以降なので、
#availableで分岐し、.onMoveをフォールバックとして残しましょう。
リストは洗練された UI の半分にすぎず、行の中のテキストも重要です。次は iOS 26 の SwiftUI の行の高さのガイドを読み、iPhone Duo を最大限に活かしているアプリやゲームを Built for the fold でご覧ください。
よくある質問
iOS 27 で編集モードなしに SwiftUI の List を並べ替えるには?
List 内の ForEach に .reorderable() を、List 自体に .reorderContainer(for:) を適用し、アイテムの型を渡します。これで行を直接ドラッグできるようになり、move クロージャに渡される ReorderDifference をデータに適用します。EditButton や onMove は使いません。
reorderable() は LazyVStack や LazyVGrid でも動きますか?
動きます。Apple のドキュメントによると、並べ替え可能なコンテナはリスト、スタック、グリッド、カスタムレイアウトのいずれでもかまいません。そのコンテナに .reorderContainer を、その中の ForEach に .reorderable() を付けます。
SwiftUI の ReorderDifference とは?
ドラッグの終了時に move クロージャが受け取る値です。sources プロパティには移動したアイテムの ID がユーザーが選択した順に並び、destination は移動先(特定のアイテムの前、またはコレクションの末尾)を示します。
iOS 27 では List の外でもスワイプアクションを使えますか?
使えます。これまでどおり各行に .swipeActions を付け、それを囲む ScrollView やレイアウトに .swipeActionsContainer() を適用します。このコンテナにより、一度に開く行は 1 つだけになり、スクロールしたり別の場所をタップしたりすると閉じます。
reorderable() と swipeActionsContainer() は iOS 26 にバックデプロイされますか?
されません。どちらも iOS 27.0 から利用可能とドキュメントに記載されているため、それより前のバージョンをサポートするアプリでは、if #available で分岐し、List の onMove などのフォールバックを用意する必要があります。
出典
- reorderable() · Apple Developer Documentation
- reorderContainer(for:isEnabled:move:) · Apple Developer Documentation
- reorderContainer(for:in:isEnabled:move:) · Apple Developer Documentation
- Reordering items in lists, stacks, grids, and custom layouts · Apple Developer Documentation
- ReorderDifference · Apple Developer Documentation
- onMove(perform:) · Apple Developer Documentation
- swipeActionsContainer() · Apple Developer Documentation
- swipeActions(edge:allowsFullSwipe:content:onPresentationChanged:) · Apple Developer Documentation
- Code-along: Build powerful drag and drop in SwiftUI (WWDC26) · Apple Developer
- swipeActionsContainer() does not mirror the swipe gesture in right-to-left layouts · Apple Developer Forums
- iOS 27: swipeActions outside List never re-evaluates its content closure · Apple Developer Forums
- iOS 27 gives ForEach its own drag to reorder: .reorderable() and .reorderContainer · @sarunw on X
- No .onMove. No edit mode. · @sarunw on X
- Swipe actions used to need a List row; in iOS 27 any view can have them · @sarunw on X
- New SwiftUI APIs for reordering and drag and drop on iOS 27 · Nil Coalescing
- Custom scroll layouts with swipe actions in SwiftUI on iOS 27 · Nil Coalescing
- Swipe actions outside of List in SwiftUI · Swift with Majid
- How to let users move rows in a list · Hacking with Swift
折りたたみを、アプリの一部に。
AppleDuo は iPhone Duo を本当に活かしたアプリとゲームを紹介しています。App Store へのリンクを添えて送ってください。ほかの開発者の作品も見られます。