magnuskahr

writing code

Passing actions through the Environment in SwiftUI

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 to NewNoteView, however that weakens the focus and quality of NotesList, and the same can be said as passing down the manager itself through an ObservedObject.
  • We can inject the manager into our app as an EnvironmentObject and have our NewNoteView require this as a property. However, when we define our previews we will then have to also inject a NotesManager 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".

typealias CreateNoteAction = (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) -> some View {
        self.environment(\.createNote, action)
    }
}

With this code, we have four parts:

  1. We define the action that creates a new note
  2. 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.
  3. Next we link our environment key to the environment through an extension on environmental values.
  4. 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:

typealias EditNoteHandler = (inout EditableNoteData) -> ()
typealias EditNoteAction = (Note, EditNoteHandler) -> ()

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 = { note, editAction in
            var editable = EditableNoteData(title: note.title, description: note.description)
            editAction(&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.