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:
- Select one or more entries in the list
- 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.
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.
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
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:
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.
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! 👋