SwiftUI: NavigationStackとEditModeが鬼門
SwiftUIでList
を編集(行の入れ替えや削除)可能にするには、環境変数のeditMode
を.active
にしてやる必要があります。
editMode
を切り替えるには、EditButton
を画面に追加して押してもらうか、editMode
を何かの処理で書き換えるかになります。
一方、List
のアイテムをクリックしたら別のページに飛ぶ、というような場合はNavigationStack
を使って右に(?)遷移するパターンと、.sheet
で現在の画面の上にかぶせてモーダル画面を表示するパターンとがあります。
今回はNavigationStack
を使う場合の話になります。
もともとList
とeditMode
が紐付いていないので、どのList
をどのEditButton
で編集可能にするか管理できません。
List
内の要素用のカスタムView
を用意して、複数の画面の異なるList
で同じ要素を表示する、ということをやろうとしています。
画面によってNavigationStack
とList
との間のView
の階層構造などが異なります。
結果、発生している不具合がいくつかあります。
- ある画面では
List
が編集可能になるけど別の画面では編集可能にならない- 一瞬だけ編集可能になって戻るケース
- 編集可能にまったくならないケース
- 編集可能になるけど、遷移しないケース
再現用の小さいコードを書こうとしていますが、それがまたなかなか再現しないというか安定しないというか、そういう状況です。
NavigationStack
をどこかの階層に入れたり抜いたり階層を変えたりするごとに挙動が変わるので、これが影響しているのは確かなのですがよく分かりません。
再現コードが書けてはないのですが、なんとなく以下のような感じにすべきかも知れないと思いつつあります。
-
editMode
を変更する機能の部分に、editMode
を外部に伝えるための@Binding
変数(例えばlocalEditMode
)を用意する -
NavigationStack
の内部のList
などに、.envirionment(\.editMode, $localEditMode)
のようにモディファイアをつけて、伝わってきたeditMode
を設定する。
EditButton
などで変更したeditMode
は、伝播する範囲というのがあって、特にNavigationStack
をまたぐと切れるような切れないような、なんか変な動きをするので、明示的にコントロールしたほうが良いのではないかという気がします。
というか、EditButton
で環境変数のeditMode
を書き換える、ということ自体、シンプルな画面以外はやめたほうが良いのかも知れません。
独自のEditMode
変数を用意し、そちらを主体的にコントロールして、それを適切なList
に.environment(\.editMode)
で反映する、という感じですね。
設計的にもeditMode
環境変数は、なかなかうまいこと制御できないような気がしています。
.isEditing(_ editing: Bool)
のようなモディファイアがあったら良かったのに、と思ってしまいますね。
例えば以下はうまくリストが編集可能になります。
struct TestView: View {
@State var items: [String] = ["Item 1", "Item 2", "Item 3", "Item 4"]
var body: some View {
NavigationStack {
List {
ForEach(items, id: \.self) { item in
Text(item)
}
.onMove { indexSet, index in
items.move(fromOffsets: indexSet, toOffset: index)
}
}
.toolbar {
ToolbarItem {
EditButton()
}
}
}
}
}
この場合、ナビゲーションバーの部分にEditButton
が付きます。
もしそうでない部分にEditButton
を付けようとして以下のように書いても、リストは編集可能になりません。
struct TestView: View {
@State var items: [String] = ["Item 1", "Item 2", "Item 3", "Item 4"]
var body: some View {
NavigationStack {
EditButton()
List {
ForEach(items, id: \.self) { item in
Text(item)
}
.onMove { indexSet, index in
items.move(fromOffsets: indexSet, toOffset: index)
}
}
}
}
}
この場合でも、もし以下のようにNavigationStack
ではなくVStack
なら、期待通りにリストが編集可能になります。
struct TestView: View {
@State var items: [String] = ["Item 1", "Item 2", "Item 3", "Item 4"]
var body: some View {
VStack {
EditButton()
List {
ForEach(items, id: \.self) { item in
Text(item)
}
.onMove { indexSet, index in
items.move(fromOffsets: indexSet, toOffset: index)
}
}
}
}
}
ですがVStack
では画面遷移できないので、NavigationStack
を使いたい。
.isEditing(_ editing: Bool)
のようなモディファイアがあったら良かったのに、と思ってしまいますね。
ということで、モディファイアを作ってみました。
struct EditModeModifier: ViewModifier {
var editing: Bool
var localEditMode: Binding<EditMode> {
Binding {
return editing ? .active : .inactive
} set: { _ in
}
}
func body(content: Content) -> some View {
content
.environment(\.editMode, localEditMode)
}
}
extension View {
func isEditing(_ editing: Bool) -> some View {
self.modifier(EditModeModifier(editing: editing))
}
}
これを使う場合は以下のようになります。
struct TestView: View {
@State var items: [String] = ["Item 1", "Item 2", "Item 3", "Item 4"]
@State var isListEditing: Bool = false
var body: some View {
NavigationStack {
Button(isListEditing ? "Done" : "Edit") {
withAnimation { isListEditing.toggle() }
}
//EditButton()
List {
ForEach(items, id: \.self) { item in
Text(item)
}
.onMove { indexSet, index in
items.move(fromOffsets: indexSet, toOffset: index)
}
}
.isEditing(isListEditing)
}
}
}
これで自前のButton
のアクションとして@State
変数であるisListEditing
を書き換えてやると、リストの編集可能状態が変わるようになります。
このカスタムisEditing
モディファイアを使うように直したところ、おそらくほとんどの部分で思った通りにリストの編集可不可を制御できるようになった気がします。
一方で、編集ボタンからリストまで、バインディング変数を使ってどれを編集中か自前で伝播させる必要があるので、ちょっと面倒くさい部分はあります。
編集ボタンとリストが階層的に遠い場合は、自前のEnvironment
変数を定義するか、という気になりますね。仕組みとしては元の鞘に戻るというか。
元々やりたいことはそういうことで、ただし1個しかeditMode
環境変数を用意しなかった結果として、やたらややこしい伝播制御となったというように見えます。
isEditing
モディファイアに渡す引数はバインディング変数でないと、編集ボタン側で編集状態を切り替えても、リストの側に再描画がかかりませんので注意。
もうちょっと言うと、削除マークとか並べ換えのマークとか、リスト単体で編集可不可状態の描画が閉じる部分自体は描画されますが、例えば行の内容を編集可能かどうか変わる場合なんかでも行は再描画されません。
バインディング変数を使っておけばリストの項目も再描画されます。