Passing actions through the Environment in SwiftUI
This post was updated on july 29th 2022 to indicate the importance of using
callAsFunction()
instead of placing closures directly in the environment-values.
SwiftUI offers a lot of ways to have actions take place, we can inject closures, interact with an ObservableObject
through the environment or pass it down as an observed object just to name a few. However, all of these have a major drawback: they take the view beyond the data they display, and suddenly our views can become complex to display, which especially can be confusing and annoying when populating our previews.
In the following, we will see how we can better this situation by passing actions down through the environment itself.
Briefly on the Environment
I'm not gonna go into detail about introducing the environment in SwiftUI, actually, I expect you to understand what the environment is, and how to work with it. However, if that is not the case, I think Keith Harrison has a great introduction to custom work with the environment over at useyourloaf.com.
While many have published work on the environment to pass down values, we will today see how we can pass down functions.
Calling a simple action from the Environment
We will be creating a note taking app, where the notes is simply modelled as following:
struct Note: Identifiable {
let id = UUID()
let title: String
let description: String
}
In our app struct, we have an object that manages our notes (this will not be our focus today), and presents a view that shows all our notes:
@main
struct NotesApp: App {
@StateObject private var manager = NotesManager()
var body: some Scene {
NavigationView {
NotesList(notes: manager.allNotes)
}
}
}
NotesList
is a simple view, it takes an array of Notes
, displays them in a list, and provides a button to create a new note from a sheet.
struct NotesList: View {
let notes: [Note]
@State private var isAddingNote = false
var body: some View {
List(notes) { note in
Text(note.title)
}
.sheet(isPresented: $isAddingNote) {
NewNoteView()
}
.toolbar {
Button("New note") {
isAddingNote = true
}
}
}
}
Now we begin to see how the problem in question can unfold. In our app struct, we define our NotesManager
, and only pass our notes down. If we imagine this manager has a function: create(note: Note)
, how will NewNoteView
know about this method?
- We can pass an action through
NotesList
toNewNoteView
, however that weakens the focus and quality ofNotesList
, and the same can be said as passing down the manager itself through anObservedObject
. - We can inject the manager into our app as an
EnvironmentObject
and have ourNewNoteView
require this as a property. However, when we define our previews we will then have to also inject aNotesManager
into the preview.
I think a great solution would allow us to freely use our NewNoteView
without providing it any methods or observable objects of any kind, however, we will still require the type safety of Swift. With Environment
we can do this. It takes a bit of code, but follow along, and we will have a great solution. Let us start by defining our "create note environment action".
struct CreateNoteAction {
typealias Action = (Note) -> ()
let action: Action
func callAsFunction(_ note: Note) {
action(note)
}
}
struct CreateNoteActionKey: EnvironmentKey {
static var defaultValue: CreateNoteAction? = nil
}
extension EnvironmentValues {
var createNote: CreateNoteAction? {
get { self[CreateNoteActionKey.self] }
set { self[CreateNoteActionKey.self] = newValue }
}
}
extension View {
func onCreateNote(_ action: @escaping CreateNoteAction.Action) -> some View {
self.environment(\.createNote, CreateNoteAction(action: action))
}
}
With this code, we have four parts:
- We define the action that creates a new note. This is done using
callAsFunction
where we wrap a closure in a struct, which allows us to call that struct as it simply was a closure. The reason to do this, is that we only should put value-types in the environment. - We create an environment key that describes the default value of the action when put into the environment. We define it as optional, so perhaps our calls to this action do nothing, but I think that is okay and allows us to easily use it. Other solutions could be to have it print an error telling us no action has been provided.
- Next we link our environment key to the environment through an extension on environmental values.
- Finally we define a convenience modifier to pass an action down our app.
Putting this code into action, we can in our NewNoteView
hook into our environment and retrieve this action:
struct NewNoteView: View {
@Environment(\.createNote) private var create
var body: some View {
Form {
// Abbreviated
Button("Create") {
let newNote = // Abbreviated
create?(newNote)
}
}
}
}
We now have the ability to create a new note from NewNoteView
without having changed its interface. Yes, the action to create that note is optional, given that the default value of it is nil
, however, we have discussed how an error message also could be printed.
For our current use, the call would be optional, as we have not passed down any action - that is when we are gonna use the extension we created on View
called: onCreateNote(:)
. In the body of our NotesApp
struct, let us apply the modifier:
var body: some Scene {
NavigationView {
NotesList(notes: manager.allNotes)
}
.onCreateNote { note in
manager.create(note: note)
}
}
Now, we have passed our action into our environment, and calling create
in NewNoteView
(or from any other place in our app!) will create a note! This was a rather lengthy description of how to do something pretty simple. We could go ahead and do the same for a delete action, however, I argue it would be much similar. Instead, let us look at a more advanced situation, where we would like to edit a note.
Advanced actions in the Environment
Looking at our Note
struct, we see that it is all defined by let
, so we can change nothing about any given note. For the method we will explore to change a note through the environment, we will define an editable version of the notes data:
struct EditableNoteData {
var title: String
var description: String
}
The idea we are striving for is to be able to edit a note in the following way:
edit?(note) { editable in
editable.title = "Edited title"
}
Thinking about this, the type of our action should be:
(Note, (inout EditableNoteData) -> ()) -> ()
For simplicity, let us define it as two different typealiases:
struct EditNoteAction {
typealias Editor = (inout EditableNoteData) -> ()
typealias Action = (Note, Editor) -> ()
let action: Action
func callAsFunction(_ note: Note, editor: Editor) {
action(note, editor)
}
}
The idea is then, that the note we pass into the action, is reflected in the editable version passed into the handler. Notice that the EditableNoteData
is an inout
, so any edit made to it at call-site will be available elsewhere.
Our corresponding environment key and link will be straight forward:
struct EditNoteActionKey: EnvironmentKey {
static var defaultValue: EditNoteAction? = nil
}
extension EnvironmentValues {
var editNote: EditNoteAction? {
get { self[EditNoteActionKey.self] }
set { self[EditNoteActionKey.self] = newValue }
}
}
We still need to define our view extension to pass down an action in the environment, as with our onCreateNote()
, and while this step is not necessary (we can just call .environment()
without the extension), we have in this case a very good reason to create the extension as we will make some magic happen:
extension View {
typealias OnNoteEditHandler = (_ note: Note, _ editable: EditableNoteData) -> Void
func onEditNote(_ handler: @escaping OnNoteEditHandler) -> some View {
let action: EditNoteAction.Action = .init { note, editor in
var editable = EditableNoteData(title: note.title, description: note.description)
editor(&editable)
handler(note, editable)
}
return self.environment(\.editNote, action)
}
}
We have a new type alias to wrap the original note and the changes applied, however, it is when we define the action that it becomes really interesting. The action has two parameters: note
and editAction
, which we also saw at our call site:
// we pass in the note
edit?(note) { editable in
// this is the edit-action
}
If what we just did, at first sight, can seem hard to understand, let us clarify it: We add a function to the environment that takes a note and a completion handler, and when we call the handler we in return get the edited data of the note.
With this, we can add the modifier in the body of our app:
var body: some Scene {
NavigationView {
NotesList(notes: manager.allNotes)
}
.onCreateNote { note in
manager.create(note: note)
}
.onEditNote { note, editable in
// Tell `manager` to update `note` with data from `editable`
}
}
Conclusion
We have provided a way to pass down actions through the environment, both simple and more complex actions.
With this approach, we are able to define actions on views without changing their interface, and we allow ourselves to easily use our views across the app and only care about what data any given view should see.
Compared to passing down objects as EnvironmentObject
we will not crash when an object is not present, because we always have a value defined in the environment, for us it is just nil
. Compared to passing down actions or objects as properties, we are not guaranteed the existence of an action (as it can be the default nil action), but we on the other hand do not clutter our other views because a subview needs an action.
Every way of passing down actions has its place, so use this as needed. For a complete project demonstrating the method in action, see this repository.