SwiftUI.Listで編集モードの時だけ削除・並べ替えを許可する実装方法(iOS16以上でも)
SwiftUIのList
で実装したリストビューにおいて,編集モードの時だけ削除・並べ替えを許可する(非編集モードの時のスワイプ削除や長押し並べ替えをさせない)実装をしたかったのですが,素直な方法ではiOS16以上の場合にうまく動かなかったので,実装方法を考えました.
環境
- Xcode: 15.0
素直な実装
.deleteDisabled()
やmoveDisabled()
モディファイアを使うことで編集操作の可否を制御できるので,これをEditMode
型の変数と連携させます.
EditMode
型の変数を@State
として持っているのは,編集モードのスコープを明示的にこのビューにするためです.詳しくは以下を参照してください.
cf. 【SwiftUI】編集モードの取得に関する問題 | カピ通信
また,ここでの実装はログを出すだけで,実際に削除・並べ替えする実装は行っていません.
import SwiftUI
struct ContentViewNotWork: View {
@State private var items = (0...20).map{ "hoge \($0)" }
@State private var editMode: EditMode = .inactive
var body: some View {
NavigationView {
List {
Section {
ForEach(items, id: \.self) { item in
Text(item)
}
.onMove(perform: onMove)
.onDelete(perform: onDelete)
.deleteDisabled(!editMode.isEditing)
.moveDisabled(!editMode.isEditing)
}
}
.listStyle(.insetGrouped)
.navigationBarTitle("Items")
.toolbar {
EditButton()
}
.environment(\.editMode, $editMode)
}
}
private func onMove(from source: IndexSet, to destination: Int) {
print("moved", source, destination)
}
private func onDelete(from source: IndexSet) {
print("deleted", source)
}
}
#Preview {
ContentViewNotWork()
}
問題
普通なら素直な実装でOKだと思うところのですが,実はこのコードはiOS15までとiOS16以降で挙動が異なりました.
iOS | 動作 |
---|---|
iOS14.5 | 意図した通りになる. |
iOS15.5 | 意図した通りになる. |
iOS16.4 | 意図した通りにならない(編集モード・非編集モード両方で削除・並べかえができない). |
iOS17.0 | 意図した通りにならない(編集モード・非編集モード両方で削除・並べかえができない). |
色々試してみたところ,どうも.deleteDisabled()
や.moveDisabled()
に対して初回にセットされた値をList
(かSection
)が記憶してしまい,
その後に値が変わっても動的に変化しないような挙動でした.値の変更に追随させるには,Sectionを再作成しないとだめでした.
SwiftUIのバグとしか思えません・・・.
回避策
そこでiOS16以上の場合には,編集可能なSection
と編集不可能なSection
を用意し,編集モードの状態によってこれらのビューを切り替えるようにしました.
単純にビューを切り替えるだけだと,編集モードの切り替えで画面のちらつきが発生してしまったので,以下のようにすることで対処しています.
- 編集モードから非編集モードに遷移する時は,アニメーションした後でビューを切り替える.
- 非編集モードから編集モードに遷移する時は,アニメーション前にビューを切り替えてから遷移する.
そのために以下のような実装にしています.
- 編集モードとは別に,ビュー切り替え用の変数(
isEditingForViewSwitch
)を用意する. EditButton
を使わず自前のボタンで編集モードを切り替える.- 編集モードの切り替えアニメーションとビュー切り替えのタイミングを,自前のボタンのアクションで制御する.
import SwiftUI
struct ContentView: View {
@State private var items = (0...20).map{ "hoge \($0)" }
@State private var editMode: EditMode = .inactive
// ビューを入れ替える都合上,アニメーションのタイミングを調整する必要があり,ビューの表示切り替え用変数を用意する
@State private var isEditingForViewSwitch = false
var body: some View {
NavigationView {
List {
if #available(iOS 16, *) {
if isEditingForViewSwitch {
MySection(items: items, onMove: onMove, onDelete: onDelete, editable: true)
}
else {
MySection(items: items, onMove: onMove, onDelete: onDelete, editable: false)
}
}
else {
MySection(items: items, onMove: onMove, onDelete: onDelete, editable: editMode.isEditing)
}
Section {
Text("editMode: \(editMode.isEditing ? "editing" : "not editing")")
Text("isEditingForViewSwitch: \(isEditingForViewSwitch.description)")
}
}
.listStyle(.insetGrouped)
.navigationBarTitle("Items")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if #available(iOS 16, *) {
Button(action: {
// 編集モードから非編集モードに遷移する場合は,モード遷移アニメーションが終わってからビューを切り替える
// アニメーション終了のタイミングを合わせるためにdurationを指定しているが,iOS17以降はアニメーションのcompletionハンドラがあるようなので,iOS17+ターゲットならそれを使ってもよいかも
if editMode.isEditing {
withAnimation(.easeInOut(duration: 0.5)) {
editMode = .inactive
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// 連続して操作された場合を考慮してトグルする
isEditingForViewSwitch.toggle()
}
}
// 非編集モードから編集モードに遷移する場合は,ビューを切り替えてからモード遷移する
else {
// 連続して操作された場合を考慮してトグルする
isEditingForViewSwitch.toggle()
withAnimation {
editMode = .active
}
}
}) {
Text(editMode.isEditing ? "Done" : "Edit")
}
}
else {
EditButton()
}
}
}
.environment(\.editMode, $editMode)
}
}
private func onMove(from source: IndexSet, to destination: Int) {
print("moved", source, destination)
}
private func onDelete(from source: IndexSet) {
print("deleted", source)
}
}
struct MySection: View {
var items: [String]
var onMove: ((IndexSet, Int) -> Void)
var onDelete: ((IndexSet) -> Void)
var editable: Bool
var body: some View {
Section {
ForEach(items, id: \.self) { item in
Text(item)
}
.onMove(perform: onMove)
.onDelete(perform: onDelete)
.deleteDisabled(!editable)
.moveDisabled(!editable)
}
}
}
#Preview {
ContentView()
}
デバッグ用に表示している2つ目のセクションを見ると,非編集モードから編集モードに切り替える時にeditMode
とisEditingForViewSwitch
の更新タイミングが調整されている(isEditingForViewSwitch
の更新が0.5秒遅れる)ことがわかります.
これで,iOS14〜iOS17のすべてで意図した通りの動作をするようになりました.
ちなみに編集モードの時に編集ボタンをすばやく2回タップすると,アニメーションがスキップされてiOS15以前の(通常の)UIと若干挙動が変わりますが,レアなケースですし,ビューを入れ替える都合上アニメーションをスムーズにするのは難しいため,まぁよしとしました.
疑問
2つのSectionを用意して切り替える方法を採りましたが,切り替え前後でスクロールポジションがうまく保持されていました.
これは(Reactのように?)構造に変化があった部分(.deleteDisabled
と.moveDisabled
)のみがうまく更新されるからなんでしょうか.
SwiftUIの内部機構をよく理解していないのでわかりませんでした.