magnuskahr

writing code

Cascading Environment actions in SwiftUI

I have earlier written about using the SwiftUI Environment to pass actions around, and how it enables us to focus on the data of our views.

Today we take a deeper dive into the same line of thought and apply it to an Observer-like-pattern implementation for SwiftUI, where we at several places in our view hierarchy can attach an action to an event. The goal is, that we from a subview can call a single method, that calls every action attached as it bubbles up through its parent views.

I will refer to this as cascading actions, as in a "cascade of events".

We have seen something like this with the new onSubmit(of:) modifier, where we in a view hierarchy can react to a textfield being submitted downwards. The same line of thought will be applied in this post, as we strive to implement a rather generic version of it.

Important: There is a bug in iOS 14, where all-most dismissing a sheet will clear all environment values, hence also the custom actions we will define in this post. It is therefore safest to use this method in iOS 15 and above, however, this tweet provides a fix for iOS 14.

Defining a hierarchy of actions

What we aim to do, is to build a generic and flexible system, where we can register a multitude of actions all bound to an EnvironmentKey, and then call all of the registered actions on said key, as the call bubbles up through the views and their respective environments.

To start off, we will define what a cascading action is, as follows:

indirect enum CascadingAction<Input> {
    
    typealias Action = (Input) -> Void
    
    case root
    case node(parent: Self, action: Action)
    
    func callAsFunction(_ input: Input) {
        if case let .node(parent, action) = self {
            action(input)
            parent(input)
        }
    }
}

CascadingAction is a generic enum that defines an action to have an input as the generic type and return nothing. The cascading itself comes from the indirectness of the enum: an action can either be the root or a node carrying both the parent and the action itself - in short, we model our cascading action as a one-dimensional tree structure.

In callAsFunction, we first call the action associated with the given node and then the parent - this is how we define the call-direction towards the root. As we build our actions with callAsFunction, we tap into a modern SwiftUI-environment API design, and enable a simple calling of our actions to match the new \.dismiss environment value-action.

Building up the hierarchy of actions

Now with our structure of actions in place, we still need a way to properly build the cascading tree of actions. From any given view, we would like it to be simple to append an action and let the system behind take care of the cascading and building of the structure.

Given that a node needs to know about its parent, a view needs to know about any actions already defined as it appends a new one.

This seems like a rather complex thing to keep in mind whenever we wanna attach an action. To make it easier, we define a new view modifier to solve this issue.

struct CascadingActionNodeModifier<ActionInput>: ViewModifier {
    
    typealias Path = WritableKeyPath<EnvironmentValues, CascadingAction<ActionInput>>
    
    @Environment private var parent: CascadingAction<ActionInput>
    let handler: (ActionInput) -> Void
    let path: Path
    
    init(path: Path, handler: @escaping (ActionInput) -> Void) {
        self._parent = Environment(path)
        self.handler = handler
        self.path = path
    }
    
    func body(content: Content) -> some View {
        content.environment(path, node)
    }
    
    private var node: CascadingAction<ActionInput> {
        .node(parent: parent, action: handler)
    }
}

The modifier is generic over the given input type to the action we wanna add. It takes a writeable key path on EnvironmentValues to a CascadingActing of said given input type, and a handler to append the cascading action system.

Now, the magic happens as we enter the environment in our initializer with this key path, so we can retrieve the parent action. We then modify the view content, as we simply add an environment modifier with the same path, but given a new CascadingAction-node - with the parent and the handler.

The missing link is now, that we have defined how nodes are added, but what do they stem from? Where is the root? As we built on the environment system with the EnvironmentKey protocol, we have to define a defaultValue.

To give an example, let us say that we are building a shopping app, and we wanna know at several places in the app when a user successfully has made a checkout. We may then define an actions as follows:

struct CheckoutCompletionAction: EnvironmentKey {
    static var defaultValue: CascadingAction<Void> = .root
}

extension EnvirontmentValues {
    var checkoutCompletion: CheckoutCompletionAction { 
        /* ... */
    }
}

With this we define the root of the call as the default value, and that the input to the action is Void, in other words: It doesn't take any arguments.

In the case that an cascading action is of type Void, we can add the following extension to ease the calling:

extension CascadingAction where Input == Void {
    func callAsFunction() {
        self.callAsFunction(_: ())
    }
}

Now, we can define a view, and call our action:

struct CheckoutScreen: View {
    // Tap into the environment and grab the action
    @Environment(\.checkoutCompletion) private var completion
    var body: some View {
        Button("Checkout") {
            // A simple calling as any other function
            completion()
        }
    }
}

Any parent view that has attached an action to \.checkoutCompletion will now have it called, as it bubbles up to the root. Speaking of attaching an action, we can also use the following extension on View to ease that:

extension View {
    func cascadingAction<ActionInput>(path: CascadingActionNodeModifier<ActionInput>.Path, handler: @escaping (ActionInput) -> Void) -> some View {
        self.modifier(CascadingActionNodeModifier(path: path, handler: handler))
    }
    
    // In case of an Void action
    func cascadingAction(path: CascadingActionNodeModifier<Void>.Path, handler: @escaping () -> Void) -> some View {
        self.modifier(CascadingActionNodeModifier(path: path, handler: handler))
    }
}

Now for any parent view, we can do:

struct ParentView {
    var body: some View {
        CheckoutScreen()
            .cascadingAction(for: \.checkoutCompletion) {
                print("this will be called first")
            }
    }
}

struct DistantParentView {
    var body: some View {
        ParentView()
            .cascadingAction(for: \.checkoutCompletion) {
                print("this will be called second")
            }
    }
} 

Conclusion

With the system we have built, we have seen how we can react to the same thing at different levels of our view hierarchy. By defining a cascading action type, and a helper modifier to handle the chain building, we have essentially introduced a variant of the observer pattern in SwiftUI.

On a final node, can extensions again be used to make the registrering of an action more readable:

extension View {
    func onCheckoutCompletion(_ handler: @escaping () -> Void) -> some View {
        cascadingAction(for: \.checkoutCompletion, handler: handler)    
    }
}

A gist of the full code can be found here.