Open6

SwiftUI: NavigationStackとEditModeが鬼門

kabeyakabeya

SwiftUIでListを編集(行の入れ替えや削除)可能にするには、環境変数のeditMode.activeにしてやる必要があります。
editModeを切り替えるには、EditButtonを画面に追加して押してもらうか、editModeを何かの処理で書き換えるかになります。

一方、Listのアイテムをクリックしたら別のページに飛ぶ、というような場合はNavigationStackを使って右に(?)遷移するパターンと、.sheetで現在の画面の上にかぶせてモーダル画面を表示するパターンとがあります。

今回はNavigationStackを使う場合の話になります。

もともとListeditModeが紐付いていないので、どのListをどのEditButtonで編集可能にするか管理できません。

kabeyakabeya

List内の要素用のカスタムViewを用意して、複数の画面の異なるListで同じ要素を表示する、ということをやろうとしています。

画面によってNavigationStackListとの間のViewの階層構造などが異なります。
結果、発生している不具合がいくつかあります。

  • ある画面ではListが編集可能になるけど別の画面では編集可能にならない
    • 一瞬だけ編集可能になって戻るケース
    • 編集可能にまったくならないケース
  • 編集可能になるけど、遷移しないケース

再現用の小さいコードを書こうとしていますが、それがまたなかなか再現しないというか安定しないというか、そういう状況です。

NavigationStackをどこかの階層に入れたり抜いたり階層を変えたりするごとに挙動が変わるので、これが影響しているのは確かなのですがよく分かりません。

kabeyakabeya

再現コードが書けてはないのですが、なんとなく以下のような感じにすべきかも知れないと思いつつあります。

  • editModeを変更する機能の部分に、editModeを外部に伝えるための@Binding変数(例えばlocalEditMode)を用意する
  • NavigationStackの内部のListなどに、.envirionment(\.editMode, $localEditMode)のようにモディファイアをつけて、伝わってきたeditModeを設定する。

EditButtonなどで変更したeditModeは、伝播する範囲というのがあって、特にNavigationStackをまたぐと切れるような切れないような、なんか変な動きをするので、明示的にコントロールしたほうが良いのではないかという気がします。

というか、EditButtonで環境変数のeditModeを書き換える、ということ自体、シンプルな画面以外はやめたほうが良いのかも知れません。
独自のEditMode変数を用意し、そちらを主体的にコントロールして、それを適切なList.environment(\.editMode)で反映する、という感じですね。

設計的にもeditMode環境変数は、なかなかうまいこと制御できないような気がしています。
.isEditing(_ editing: Bool)のようなモディファイアがあったら良かったのに、と思ってしまいますね。

kabeyakabeya

例えば以下はうまくリストが編集可能になります。

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を書き換えてやると、リストの編集可能状態が変わるようになります。

kabeyakabeya

このカスタムisEditingモディファイアを使うように直したところ、おそらくほとんどの部分で思った通りにリストの編集可不可を制御できるようになった気がします。

一方で、編集ボタンからリストまで、バインディング変数を使ってどれを編集中か自前で伝播させる必要があるので、ちょっと面倒くさい部分はあります。
編集ボタンとリストが階層的に遠い場合は、自前のEnvironment変数を定義するか、という気になりますね。仕組みとしては元の鞘に戻るというか。

元々やりたいことはそういうことで、ただし1個しかeditMode環境変数を用意しなかった結果として、やたらややこしい伝播制御となったというように見えます。

kabeyakabeya

isEditingモディファイアに渡す引数はバインディング変数でないと、編集ボタン側で編集状態を切り替えても、リストの側に再描画がかかりませんので注意。

もうちょっと言うと、削除マークとか並べ換えのマークとか、リスト単体で編集可不可状態の描画が閉じる部分自体は描画されますが、例えば行の内容を編集可能かどうか変わる場合なんかでも行は再描画されません。

バインディング変数を使っておけばリストの項目も再描画されます。