Teabyte

App development for Apple platforms and Swift

SwiftUI's editMode environment

In the last week, I was confronted with implementing a similar edit mode to a SwiftUI List as seen in the Mail app. So on tapping on an "Edit" button the list should go into edit mode making it possible to:

  1. Select one or more entries in the list
  2. Conditionally showing a toolbar with auxiliary buttons, like "Select all" and "Delete"

While bringing the native List view into edit mode was quite simple, displaying a toolbar conditionally based on the editMode was not as straightforward as I thought and involved some knowledge about how SwiftUI's environment works. In this small blog post, I want to highlight my findings while implementing this.

Regular Edit Button

The default way to alter the editMode is to use an EditButton. This button is directly connected to the environment property and alters it accordingly.

To explore its functionality, let's setup some small entries which we can utilise in lists.

Item.swift
struct Item: Identifiable {
    let id: UUID
    let title: String
 
    init(title: String) {
        self.id = UUID()
        self.title = title
    }
}

Next we will implement a basic List and place an EditButton into the primary action placement in the toolbar.

ContentView.swift
struct ContentView: View {
    @State var items: [Item] = [
        .init(title: "Hello"),
        .init(title: "World"),
        .init(title: "What's"),
        .init(title: "going"),
        .init(title: "on?")
    ]
 
    var body: some View {
        NavigationStack {
            List(items) { item in
                Text(item.title)
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    EditButton()
                }
            }
        }
    }
}

The next thing we want to add is the ability to select items in the list. For this, we will use the Binding based initialiser of a list

ContentView.swift
struct ContentView: View {
    @State var items: [Item] = [
        .init(title: "Hello"),
        .init(title: "World"),
        .init(title: "What's"),
        .init(title: "going"),
        .init(title: "on?")
    ]
 
    @State var selectedIems: Set<UUID> = []
 
    var body: some View {
        NavigationStack {
            List(items, selection: $selectedIems) { item in
                Text(item.title)
            }
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    EditButton()
                }
            }
        }
    }
}

This will result in the following behaviour:

The missing part is now to add a toolbar that is shown when the edit mode is active. To read out the editMode we can utilise the environment editMode property.

@Environment(\.editMode) var editMode

Let's use it in our view definition:

ContentView.swift
struct ContentView: View {
    @Environment(\.editMode) var editMode
    ...
    var body: some View {
        NavigationStack {
            ...
            .toolbar {
                ...
                ToolbarItem(placement: .bottomBar) {
                    if editMode?.wrappedValue.isEditing == true {
                        HStack {
                            Button("Select All") { }
                            Spacer()
                            Button("Delete") { }
                        }
                    }
                }
            }
        }
    }
}

When running this, we see this will not work, even though the edit mode is definitely triggered for the List, but the environment for our view is not changing. Even Apple's documentation for the EditButton showcases this example. Why is that then not working?

The Problem

The problem with the current solution is that the edit mode is properly set for the List view but not for our view. This is due to the nature of the EditButton working with the Environment. The environment will only be modified for children of the current view definition but not views on the same level. Therefore the List will receive the updates but not our view definition.

One way to fix that would be to introduce an intermediate view which acts as a "resolver" of the environment's editMode property. For my taste though, this adds an unnecessary layer of views into the hierarchy. Therefore we will go with another solution.

The Solution

In order to solve this small issue in a non-intrusive way, we make us of the fact that every view can modify every environment key for its children. By introducing a custom button that sets a @State property on our view level representing the editMode we can utilise this state to propagate it as the editMode environment key to the List view.

ContentView.swift
struct ContentView: View {
    @State private var editMode: EditMode = .inactive
    ...
    var body: some View {
        NavigationStack {
            List(items, selection: $selectedIems) { ... }
            .environment(\.editMode, $editMode)
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button(editMode.isEditing ? "Done" : "Edit") {
                        withAnimation {
                            editMode = editMode.isEditing ? .inactive : .active
                        }
                    }
                }
                ToolbarItem(placement: .bottomBar) {
                    if editMode.isEditing == true {
                        HStack {
                            Button("Select All") { }
                            Spacer()
                            Button("Delete") { }
                        }
                    }
                }
            }
        }
    }
}

By creating the intermediate state definition we can easily bypass the issue and do not need a "Resolver" view. We make sure that the editMode environment of the List stays in sync with the @State property and by that, we can easily show and hide the bottom toolbar. In addition by wrapping the state change with withAnimation we will get the same animation behaviour as with a regular EditButton.

Conclusion

In this small post, we discussed a solution on how to workaround the issue that we cannot read out the editMode property on the same level where the EditButton is added. By creating an intermediate state representing the edit mode and connecting it to the environment property of a views children we can easily read it and modify additional views accordingly.

Have feedback, questions or suggestions? Feel free to reach out! You can find ways to contact me on my About 🙂

Thanks for reading, and see you next time! 👋