ottijp blog

SwiftUI.Listで編集モードの時だけ削除・並べ替えを許可する実装方法(iOS16以上でも)

  • 2023-11-08

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つ目のセクションを見ると,非編集モードから編集モードに切り替える時にeditModeisEditingForViewSwitchの更新タイミングが調整されている(isEditingForViewSwitchの更新が0.5秒遅れる)ことがわかります.

これで,iOS14〜iOS17のすべてで意図した通りの動作をするようになりました.

ちなみに編集モードの時に編集ボタンをすばやく2回タップすると,アニメーションがスキップされてiOS15以前の(通常の)UIと若干挙動が変わりますが,レアなケースですし,ビューを入れ替える都合上アニメーションをスムーズにするのは難しいため,まぁよしとしました.

疑問

2つのSectionを用意して切り替える方法を採りましたが,切り替え前後でスクロールポジションがうまく保持されていました. これは(Reactのように?)構造に変化があった部分(.deleteDisabled.moveDisabled)のみがうまく更新されるからなんでしょうか. SwiftUIの内部機構をよく理解していないのでわかりませんでした.


ottijp
都内でアプリケーションエンジニアをしています
© 2024, ottijp