[SwiftUI] How to access .editMode

SwiftUI2021

     

TAGS:

In this post, I'll explain how to get "correct" .editMode.

環境&対象

Following environment is used for checking.

  • macOS Monterey 12.4 beta
  • Xcode 13.3
  • iOS 15.4

EditMode

There are many Apps which has following UIs.
User can remove item, reorder item with using "Edit" button.

ListWithEdit

Editing mode(after clicking edit button)

underEditing

following code are used for above screen shots


//
//  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 } // remove
                .onMove { _, _ in } // reorder
            }
            .toolbar {
                EditButton()
            }
        }
    }
}

"EditButton" makes easy to build app with editable capability. (In above, EditButton is attached to NavigationView)

Environment Variable: EditMode

In some case, developer might want to check it is under editing or not for appropriate UI.
With using EditButton, there is no way to set internal flag for editing mode....

But you can get the information via Environment variable. EnvironmentVariable Key is .editMode.

please refer to here

Variable type is Binding<EditMode>? .

You can find there it is under editing or not with checking isEditing.

In Apple example, you can find following code for refering isEditing. (note: comment are added by me.)


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


var body: some View {
    Form {
        // check isEditing, then use appropriate UI
        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()
    }
}

Let's refine UI with using editMode... ?!

here is the first code for using editMode.

List cell will be "EditMode" or "Non-EditMode" whether current mode is Editing or not.


//
//  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 } // remove
                .onMove { _, _ in } // reorder
            }
            .toolbar {
                EditButton()
            }
        }
    }
}

Looks in editing mode, but not in editing mode??

left: non-editing mode right: editing mode

Non-EditMode
EditMode_UnderNonEditMode

But you can see "Non-EditMode" even in "Editing" mode....

I just copied code from Apple example, so it should be correct.

So I started to investigate why.

How to investigate?

Firstly, I need to decide how to investigate. Because editMode is environment variable, so it is not easy to debug with using debugger.

Looks List already knows it is "editing" mode, so I believe environment variable is changed accordingly somehow.

So my first hypothesis is following
"View does not receive variable update notification by some reason, so view is NOT updated correctly."

EditMode is struct, and it is conformance to Equatable, so we can observe it with using .onChange.

So I added following code to many place for checking.


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

variable change is notified?

at first I added above small code snippet to EditButton and 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 } // remove
                .onMove { _, _ in } // reorder
            }
            .toolbar {
                EditButton()
                    .onChange(of: editMode?.wrappedValue) { newValue in
                        print("EditMode@EditButton -> \(newValue)")
                    }
            }
        }
    }
}

Result: Clicking Edit button does not trigger "both" onChange. i.e. There are no way to know the editMode change for Text and EditButton.

Initially I expected notification will be sent to very limited Views. but not only EditButton but also Text did not receive.

# Of course, EditButton should know the change internally.

This result means editMode in ContentView does not have any change.

OK. but if so, how does NavigationView/List detect the change and change UI accordingly?

Consideration: Why no notification/no change?

I tried to google many times.

I found "There are many who is investigating this topic" and "No one has clear answer for this".
Of course Apple should know, but looks they don't provide detail documentation about this.

So I started to consider why by myself....

How widely does editMode be shared?

At first, I thought "editMode is defined as Environment variable, but can we refer this variable from anywhere?"

In iOS, usually there is only ONE List in the screen. so it looks OK to refer.

But considering macOS (or iPadOS), there might be many List in one screen.

This reminds me past my blog post about drag&drop.
"App which has 2 Lists in one screen can be crashed easily with using drag&drop"
SwiftUI[SwiftUI] List/ForEach の onMove についての メモ書き

From this, I thought if there are 2 lists those has separate "Edit" buttons, user want to make list in edit mode separately.)

Let's try with 2 List

2Lists
UpperUnderEditing
LowerUnderEditing

I found each List has individual editing state.


//
//  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 } // remove
                .onMove { _, _ in } // reorder
                }
                .toolbar {
                    EditButton()
                }
            }
            NavigationView {
                List {
                    ForEach(anotherData, id: \.self) { data in
                        Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
                    }
                .onDelete { _ in } // remove
                .onMove { _, _ in } // reorder
                }
                .toolbar {
                    EditButton()
                }
            }
        }
    }
}

It is nice to have separate editing mode, but if so, what is the meaning of editMode in ContentView??

this make me in confusion more.

updates will be only in external child view

# consideration/trial are after summary.

(personal) summary

In person, I believe followings are correct.
- EditMode will be updated only in target external child view.

"Target" means element under NavigationView/ListView in this example.
"external" means it should NOT be defined in the view same with NavigationView/ListView.

Followings are my thought and trials.
# anyway only Apple knows the fact. but comments are appreciated.

consideration/trials

After consideration, following hypothesis popped up in my mind.

1) for editing mode, (from UI perspective) Navigation/List does not do anything. Only child views do some layout update.
2) it means Navigation/List does not need to know editing state. It is enough that child views know the state.
2') additionally there might be other NavigationView/List in same view, so changing editMode might be dangerous.
3) Probably there is the chance to inject new variable only when creating external view.
4) so only external view has correct environment variable info.

past trial is done only in the same view with Navigation/List. if above hypothesis, it is natural to fail to get changed.
To confirm, I created external Text version.

In short, "BINGO!!"

EditableChildView

The cell which use external Text is updated correctly. (i.e. "EditMode" appeared)

Followings are code that I used. But nothing special

overview
- TextInList is external TextView
- TextNotInList is external TextView which is same with TextInList. but for comparison, I gave different name.
- 2 List are used in this example, but no change in lower 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 } // remove
                .onMove { _, _ in } // reorder
                }
                .toolbar {
                    EditButton()
                }
            }
            TextNotInList()
            NavigationView {
                List {
                    ForEach(anotherData, id: \.self) { data in
                        Text(editMode?.wrappedValue.isEditing == true ? "EditMode" : "Non-EditMode")
                    }
                .onDelete { _ in } // remove
                .onMove { _, _ in } // reorder
                }
                .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")
    }
}

Background(my assumption)

After this investigation, I believe followings are the

NavigationView/ListView has local state for editMode. They will give local state as environment for their child view.
But only for views which will be affected by editMode.

# I believe this can be true not only for NavigationView/ListView but also for the view which is supported by EditButton.

As usual :(, Apple documents does not say anything. but I start to consider this would be reasonable implementation for this.

how to reflect editMode to NavigationView/List

In addition, I'll try to explain how to reflect editMode change to NavigationView/List.

as mentioned above, there is no straight way.

for this, we need to pass data from child view to parent view.

In SwiftUI, there is Preference for supporting such request.

in following example, I used Preference to pass editMode value from child to parent, then NavigationViewTitle is changed accordingly.


//
//  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 } // remove
                .onMove { _, _ in } // reorder
            }
            .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
if you put above into ViewModifier, it will looks like .navigationTitle.
SwiftUI[SwiftUI] ViewModifierの作り方

Issue

In above example, parent view get the info from child view.

but in other words, without child view parent view can not get any info.

how to reflect editMode to NavigationView/List (cheating...)

With considering above issue, in some cases, implementing own EditButton would be better than using EditButton.

Reference

For this post, I learned a lot from followings. (sorry in Japanese)
参考 【SwiftUI】編集モードの取得に関する問題カピ通信

Summary

In this post, I explained "How to access .editMode".

How to access .editMode
  • editMode will be updated only in target external child view.

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

Leave a Reply

Your email address will not be published.