[SwiftUI] editMode を参照する

SwiftUI2021

     

TAGS:

SwiftUI の Environment 変数の1つである .editMode の参照方法を調べてみました。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.4 beta
  • Xcode 13.3
  • iOS 15.4

EditMode

以下のような UI を持つアプリは多くあります。
ツールバーにある "Edit" button を押すことで、要素の削除や 並び替えができるようになっています。

ListWithEdit

編集中(Edit ボタン押下後)

underEditing

上記の画面作成に使用したコード


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/04/11
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var data = ["One", "Two", "Three"]
    var body: some View {
        NavigationView {
            List {
                ForEach(data, id: \.self) { data in
                    Text(data)
                }
                .onDelete { _ in } // 削除処理
                .onMove { _, _ in } // 並び替え処理
            }
            .toolbar {
                EditButton()
            }
        }
    }
}

EditButton を配置するだけで、モードを切り替える Edit ボタンを配置してくれますので、簡単に編集可能なアプリが作成できます。
上記の例では、NavigationView の ツールバーに配置しています。

Environment 変数 EditMode

アプリケーションに少し凝った挙動を行わせようと考えた時に、編集中であるかどうかによって画面構成を変更したくなる時があります。
自分で作成したボタンであれば、内部にフラグをセットして・・・ となりますが、EditButton で配置しているボタンでは、そのようなフラグをセットする余地はありません。

この編集中であるかという情報について Environment 変数として取得することができるようになっています。

該当 Environment 変数のキーは、.editMode です。

Apple のドキュメントは、こちら

変数の型は、Binding<EditMode>? です。

EditMode のisEditing というプロパティをチェックすることで、編集中かどうかを確認することができます。

Apple のサンプルコードにも以下のようなコードがあります。(コメントは追加しました)


@Environment(\.editMode) private var editMode
@State private var name = "Maria Ruiz"


var body: some View {
    Form {
             // editMode をチェックして処理を分岐
        if editMode?.wrappedValue.isEditing == true {
            TextField("Name", text: $name)
        } else {
            Text(name)
        }
    }
    .animation(nil, value: editMode?.wrappedValue)
    .toolbar { // Assumes embedding this view in a NavigationView.
        EditButton()
    }
}

参照しようとしてみると・・・?!

さっそく、変数を参照するサンプルコードを書いてみました。

編集中かどうかで、リストの表示要素を "EditMode", "Non-EditMode" と切り替えています。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/04/11
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @Environment(\.editMode) var editMode
    @State private var data = ["One", "Two", "Three"]
    var body: some View {
        NavigationView {
            List {
                ForEach(data, id: \.self) { data in
                    Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
                }
                .onDelete { _ in } // 削除処理
                .onMove { _, _ in } // 並び替え処理
            }
            .toolbar {
                EditButton()
            }
        }
    }
}

編集モードになっているけど編集モードではない

実行してみると以下のようになります。左側が未編集モード、右側が編集モードです。

Non-EditMode
EditMode_UnderNonEditMode

右側のスクリーンショットをみると、画面全体としては編集モードに切り替わっていますが、テキストは期待通りに変更されていません!?

Apple のサンプルに沿った書き方をしているので、参照方法が間違っているということは、考えにくいです。

ということで調べてみました。

調査方法の検討

最初に、どのように調べるかについて検討しました。
調査対象が Environment 変数なので、デバッガでは追いにくいです。

List としては編集モードに遷移できているので、Environment 変数は変更されているハズです。

ですので、
「View(?) に、Environment 変数の変更が通知されていないためにうまく動作していない」のではないか という仮説を置いて調査してみました。

EditMode は、struct です。確認すると Equatable にも準拠していますので、.onChange で変更を補足できるはずです。

以下のようなコードを、さまざまなビューに付与して確認することとしました。


.onChange(of: editMode?.wrappedValue) { newValue in
    print("EditMode -> \(newValue)")
}

上記コードはワーニングが出ますが、テスト用コードということで、無視ください・・・

変更は通知されているかを確認

最初に EditButton と Text に、付与して確認しました。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/04/11
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @Environment(\.editMode) var editMode
    @State private var data = ["One", "Two", "Three"]
    var body: some View {
        NavigationView {
            List {
                ForEach(data, id: \.self) { data in
                    Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
                        .onChange(of: editMode?.wrappedValue) { newValue in
                            print("EditMode@Text -> \(newValue)")
                        }
                }
                .onDelete { _ in } // 削除処理
                .onMove { _, _ in } // 並び替え処理
            }
            .toolbar {
                EditButton()
                    .onChange(of: editMode?.wrappedValue) { newValue in
                        print("EditMode@EditButton -> \(newValue)")
                    }
            }
        }
    }
}

結果は、EditButton を押下しても どちらの箇所も onChange がキックされません。つまり EditButton も Text も editMode が変更されたことを知りません。

当初、限定的な範囲で変更が通知されると予想していたのですが、EditButton だけでなく、Text にも通知されませんでした。

# もちろん、EditButton は、内部的には知っているはずです。

このことから ContentView の editMode はどうやら変更されていないようだと分かります。
でも、だとすると リストは どうやって editMode が変更されたことを検知して、表示を変更しているのでしょうか??

なぜ通知されないかを考える

ここで手詰まりになってしまい、Web で検索していろいろな資料を読みました。

最初にわかったことは、「現象を理論的に説明している Apple のドキュメントはなさそう」ということでした。

ということで、自分でも考えてみました。

editMode はどの単位で共有されるべきか?

最初に考えたのが、「editMode という Environment 変数になっているけれど、これはどこからでも参照して良い変数なのか?」でした。

iOS で考えていると、画面に表示される リストは、たいてい 1つです。ですので、常に参照してよさそうに感じます。

ですが、macOS でのアプリを考えると、複数存在するのも普通です。

そこで、以前書いた Drag&Drop のことを思い出しました。
2つのリストを並べて、要素を移動させると 簡単にアプリをクラッシュさせることができるという点です。
SwiftUI[SwiftUI] List/ForEach の onMove についての メモ書き

ここから連想したのが、2つのリストが並んでいて、それぞれ Edit ボタンを持っている時、一方を押したからと言って 2つのリストが 同時に編集モードになってほしくないということです。

リストを2つ並べてみた

さっそく、2つのリストを並べてみました。

2Lists
UpperUnderEditing
LowerUnderEditing

試してみると それぞれのリストを 個別に編集状態にすることができました。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/04/11
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @Environment(\.editMode) var editMode
    @State private var data = ["One", "Two", "Three"]
    @State private var anotherData = ["1", "2", "3", "4"]
    var body: some View {
        VStack {
            NavigationView {
                List {
                    ForEach(data, id: \.self) { data in
                        Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
                    }
                    .onDelete { _ in } // 削除処理
                    .onMove { _, _ in } // 並び替え処理
                }
                .toolbar {
                    EditButton()
                }
            }
            NavigationView {
                List {
                    ForEach(anotherData, id: \.self) { data in
                        Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
                    }
                    .onDelete { _ in } // 削除処理
                    .onMove { _, _ in } // 並び替え処理
                }
                .toolbar {
                    EditButton()
                }
            }
        }
    }
}

個別に編集モードにできるということは、ContentView の参照している Environment 変数 editMode の意味は??? となり、さらに混乱し始めました・・・

変更は、List/ForEach の(外部)子View にのみ設定される

# 考察・調査経過は長いので、後半に回してます。

(個人的な)結論

(個人的には) 以下の結論に到達しました。
・EditMode は、編集対象となる 子View にのみ かつ それが 外部 View のときのみ反映されている

"編集対象となる" とは、今回の例では、(EditButton を付与している) NavigationView/ListView 配下の要素を意味しています。

以降は、どうやって、上記結論に至ったかについての記録です。
# いずれにしても正解は Apple にしか分かりません。不足している調査箇所等の指摘は歓迎です。

行った調査

ふと 以下のような仮説を思いつきました。

1) 編集モードを表示要素として表現するものは、List そのものではなく、NavigationView/List の内部にある View の配置である
2) つまり 編集モードであるかどうかは NavigationView/List の 子 View がわかるだけで"も"良い
2') 同じ View には別の NavigationView/List 等があるかもしれないので、むやみに(?) 編集モードを変更しない方が良い
3) 子View に Environment変数を設定できるのは、外部 struct を作成する時だけ(な気がする)
4) 外部子 View だけが、きちんと environment 設定されているのではないか

ここまでに試したのは、NavigationView/List と同じ View 内でした。上記の 1) - 4) の仮説が正しいとすると 変更を検知できないのは当然です。確認するために Text を外部ビューにしたバージョンを作ってみました。

試してみると、”BINGO!"でした。

EditableChildView

外部の子View にしたセルは、"EditMode" の表示に切り替わっていることが分かります。

試したコードは以下です。ちょっと長いですが、同じようなコードばかりです。特殊なことはしていません。

コード概要
・TextInList という View は、List の行を表示するために作った struct で、子 View として使用します。
・TextNotInList は、List 外部に存在する 子View として作りました。List の子Viewだけかをチェックするための比較用です。(コードは同じですが、名前だけ変えています)
・2つの List のうち 下側の List には変更を加えていません。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/04/11
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @Environment(\.editMode) var editMode
    @State private var data = ["One", "Two", "Three"]
    @State private var anotherData = ["1", "2", "3", "4"]
    var body: some View {
        VStack {
            NavigationView {
                List {
                    ForEach(data, id: \.self) { data in
                        TextInList()
                    }
                    .onDelete { _ in } // 削除処理
                    .onMove { _, _ in } // 並び替え処理
                }
                .toolbar {
                    EditButton()
                }
            }
            TextNotInList()
            NavigationView {
                List {
                    ForEach(anotherData, id: \.self) { data in
                        Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
                    }
                    .onDelete { _ in } // 削除処理
                    .onMove { _, _ in } // 並び替え処理
                }
                .toolbar {
                    EditButton()
                }
            }
        }
    }
}

struct TextNotInList: View {
    @Environment(\.editMode) var editMode
    var body: some View {
        Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
    }
}
struct TextInList: View {
    @Environment(\.editMode) var editMode
    var body: some View {
        Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
    }
}

動作の背景(想像)

ここまでにみてきた動作を考えると、NavigationView/ListView は、ローカルに editMode の値を保持し、子ビューに対して、environment 設定を行なっているのではないかと想像します。

# 特に NavigationView/ListView に限定されていることではなく、EditButton が付与される View 全般に言えそうです。

Apple のドキュメントには何も記載されていませんが、複数 List の存在するケース等を考えると、それなりに妥当な実装な気がします。

editMode をList に反映する方法

おまけ(?)で、editMode の値で NavigationView や List の挙動を変えようとする時の方法も説明します。

ここまでにみたように、そのままではできません。

状態を伝えるには 外部の子 View から 親 View に通知する必要があります。

そのような時に使用する要素として、SwiftUI には、Preference が用意されています。
以下は、Preference を使用して、子ビューから EditMode の値を親ビューに渡して、List のタイトルを変更している例です。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/04/11
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @State private var data = ["One", "Two", "Three"]
    @State private var title = "List"
    var body: some View {
        NavigationView {
            List {
                ForEach(data, id: \.self) { data in
                    TextInList()
                        .onPreferenceChange(EditModePreferenceKey.self) { newValue in
                            title = newValue ? "List under editing" : "List"
                        }
                }
                .onDelete { _ in } // 削除処理
                .onMove { _, _ in } // 並び替え処理
            }
            .toolbar {
                EditButton()
            }
            .navigationBarTitle(title)
        }
    }
}

struct EditModePreferenceKey: PreferenceKey {
    typealias Value = Bool
    static var defaultValue: Bool = false
    static func reduce(value: inout Bool, nextValue: () -> Bool) {
        value = nextValue()
    }
}

struct TextInList: View {
    @Environment(\.editMode) var editMode
    var body: some View {
        Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
            .preference(key: EditModePreferenceKey.self, value: editMode?.wrappedValue.isEditing ?? false)
    }
}
ListWithNormalTitle
ListUnderEditing
MEMO
ViewModifier として使えるようにすると、.navigationTitle のように使うことも可能となります。
SwiftUI[SwiftUI] ViewModifierの作り方

問題点

上記で説明した方法は、外部子View で Environment を参照し、それを Preference 経由で 親 View に 渡す方法です。

この方法の問題は、子View が存在しないと機能しない点です。言い換えると、List に表示される要素が存在しないケースでは期待通りの動作をしません。

editMode をList に反映する方法(ちょっと本末転倒)

上であげた問題点を考えると、ユースケースによっては、EditButton を使わずに、自分で editMode を操作する Button を作る方が簡単かもしれません。

参考資料

以下の 記事を参考にさせていただいてます。
参考 【SwiftUI】編集モードの取得に関する問題カピ通信

まとめ

SwiftUI の Environment 変数の1つである editMode を参照するときに気をつけること

editMode を参照するときに気をつけること
  • View が、List/ForEach 等の編集対象となる 子 View でありかつ外部 View のとき editMode を参照できる

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。