Decoupling SwiftUI.View Configuration from Layout and Action Handling

Say you have a view component. You can control which subcomponents it displays for maximum reuse.

One day, you end up configuring the available effects, or actions, of the view component.

Let’s say it’s something like this, a made-up example:

enum AvailableAction {
    case cancel
    case sendViaEmail
    case delete(id: String)  // `id` is used to execute the effect
    case markAsRead
    // ...
}

struct ViewComponent: View {
    let availableActions: [AvailableAction]
    var body: View { ... }
}

The delete case with its associated value sticks out a bit.

I didn’t mind this approach in a recent project, because it looked sensible during code review: where this was used, it was used to great effect so that the operation was self-contained.

The delete action configuration passed all info to the view that the view needed to make the app perform the effect of the same name eventually.

That was until I thought a bit more about this ‘pattern’ and why only one of the actions needed this information.

Looking at a view from outside appears orderly: the view ‘V’ is configured with ‘C’ and contains text ‘T’ and actions ‘A’ and ‘B’

There is one superficial benefit of attaching the payload to delete(id:): the affordance, e.g. the button you show in the view, can execute a command and pass that payload along.

The view becomes its own controller, you could say.

Imagine a sketch of displaying all available actions like so:

ForEach(availableActions) { action in 
    Button {
        switch action {
        case .delete(id: let id): 
            performDeletion(id: id)
        // ...
        }
    } label: { ... }
}

I did not detect any odd “code smell”, but here’s what I now think can be wrong with this design:

  • The availableActions array determines the order of the buttons on behalf of the view.
  • The .delete(id:) payload is convenient purely to pass it to the performDeletion(id:) function. This way, it’s at least in part coupled to this method, and also used to configure the view, so it’s doing two things at once at the moment.

The availableActions property drives the view’s content and, by its sequentiality, parts of the layout; and the traditional “controller” responsibility to carry out an effect is also informed by at least one of the action configurations.

It worked for weeks, and it was a small component, so nobody bothered.

But I could not fix that availableActions’s sequentiality drives the layout by accident. The way I wanted to fix that piece did not work without getting rid of the associated value.

So I looked if there was truly a good reason for the value to be there in the first place – and if there’s any true benefit of applying decoupling techniques here, or if this is all just busywork.

The payload's usage couples the configuration to the effect though, so ‘C’ is actually, at least in part, coupled to action ‘B’. In other words, it goes right through the view.

In my understanding, decoupling is indeed an improvement here because the result of the following changes provides a much more sensible (and in my opinion, more elegant) API:

  1. Remove the ID payload from the .delete action. This reduces it
    purely to a view configuration: which kind of action is available?
    The view becomes even dumber this way. (That’s good.)

    enum AvailableAction: Hashable {
        case cancel
        case sendViaEmail
        case delete
        // ...
    }
    

    With that change and subsequent loss of informaiton (the ID is
    gone), we need to put the information somewhere else for the
    effect to work, keep that in mind and see (3) below.

  2. Change the availableActions array to a Set<AvailableAction>.
    This is now possible because the enum can easily be hashed, and
    the action kinds are unique.

    This tells the view what is available, not how or in which
    order. Let the view decide the means of displaying the actions. –
    With the old array approach, all users (i.e. call sites) of the
    view component would need to change at the same time to keep the
    sequence of actions in the view consistent across the app. (As a
    code-level heuristic, you could call this a violation of the
    Single Responsibility Principle, if you like, because there’s now
    multiple reasons to change the code; but remember:
    code-level heuristics can be mis-applied)

  3. Encapsulate the knowledge about what will be deleted in the place
    where this inevitably already is known, and keep it there.

    To produce the old code, someone had to configure .delete(id:)
    and pass the associated value along. That’s where the ID is still
    known even after removing the associated value from the enum case.
    This object, or one of its ancestors/parents, can and arguable
    should keep this information to itself.

This can produce a view like this:

struct ViewComponent: View {
    let availableActions: Set<AvailableAction>
    let performDeletion: () -> Void  // Dumb action handler
    
    var body: View {
        // You can check each action individually
        // and manage its affordance's placement.
        if availableActions.contains(.sendViaEmail) {
            Button { ... } label: { ... }
        }

        // This enables structured containment, which a
        // ForEach wouldn't have made possible:
        HStack {
            if availableActions.contains(.cancel) {
                Button { ... } label: { ... }
            }

            if availableActions.contains(.delete) {
                Button {
                    performDeletion()
                } label: { ... }
            }
        }
    }
}

Decoupling here literally means that two things can now vary independently: the way the view displays its available actions, and the configuration of available actions.

What was once one thing is now split in two. (Or actually: into three, because the view, its configuration, and effect handling are affected.)

Since the performDeletion() block in the view doesn’t know the ID of the thing that should be deleted anymore, interpreting this command is now the responsibility of some other object. Ideally, it’s the responsibility of the object that knows the ID already anyway. The performDeletion() closure might as well become an action in the @Environment, decoupling things even further.

Do keep in mind that “doing decoupling” is a tool, and not an objective. You need to apply some wisdom:

Decoupling to the extreme, until there’s nothing left to decouple, is not a worthwhile goal. This could be realized by never calling another object directly, ever, only sending NotificationCenter notifications around in your Swift codebase. While that’s totally decoupled, this makes everything harder to understand. Sending an object a message is a 1-to-1 object connection you can follow, but with notifications, the same message being realized via an anonymized N-to-M event bus, as a reader you can’t see the connection anymore and have a harder time understanding anything. (If you ever work on a code base that does this, discovering that the N-to-M mechanism is only used for 1-to-1 messages, but indirectly, you will be surprised at first and then quickly disappointed.)

For our project’s view component, the steps mentioned above were a win and avoid decoupling for decoupling’s sake:

  • We increased locality of information about how and what to delete: the action and the ID are now known in one place instead of spreading the knowledge around. This also means there can be no misunderstandings along the way (chinese whisper effect; see also: Chinese whisper);

  • We made the view autonomous, more self-contained and self-controlled: it can now drive its layout independent of the inputs. We replaced the sorted list of actions with a set that indicates mere presence of the actions, but no order. This reduces potential mis-configuration on the call-site, as we don’t have to keep the order of actions at all possible call-sites in sync for a consistent UI.

  • New components can copy this approach for inter-module consistency. We learned the downsides of the old approach, which introduced a payload for the wrong reasons, and can avoid similar mistakes.