Decorating QML Actions with extra behaviour

Problem statement

Let's assume our application was designed to preferredly use QML Actions. Although our components are now nicely decoupled [1], it won't be too long until we face some limitations. Namely that there is no easy way to intercept a QML Action's trigger such that we could run additional code just before its original payload. It's not possible to pass arguments to the slot either. Worst of all, if you're just starting out to use QML Actions, you might not even notice that this problem is real.

A problematic example

Here's an application that let's the user browse items, and, upon selecting an item (by flipping a tile), reveals further actions such as loadItem and removeItem.

The functionality and the bug can be easily discovered in two short video clips:

Video 1: A grid view of coloured tiles that, when tapped, flip around and reveal more actions. On the left we we see the currently loaded colour.
Video 2: The bug. Pay attention to the highlighted item with the golden frame.

Let's look at the code. We have an UI.ItemsView that displays everything provided by a backend model. Here is the essential implementation of the UI.ItemsView:

// UI.ItemsView
GridView {
    id: root

    required property Action loadItem
    required property Action removeItem
    readonly property var item: root.model.get(root.currentIndex)

    clip: true
    delegate: UI.FancyTile {
        id: tile

        modelData: root.model.get(tile.index)
        action: Action {
            checkable: true
            onTriggered: root.currentIndex = tile.index
        }

        Row {
            anchors.centerIn: parent
            spacing: 8
            visible: tile.flipped

            // A natural 3rd action would be `editItem`
            Button { action: root.loadItem }
            Button { action: root.removeItem }
        }
    }
}

Again, the obvious benefit of injecting the two actions at the top is better decoupling [2]: Our view doesn't have to know how items are loaded or removed.

The action to flip the tile (and to update currentIndex) does not benefit from being injected, as it is closely tied to the view's internal behaviour.

Problem analysis

Quite obviously, the view's selected item is the last tile we flipped, yet triggering one of the actions on the backside of a tile does not reselect the intended item.

If each tile had a singular action, say loadItem, we might not even notice the potential issue. But in our case, there are three actions. Given two items A & B, the bug is reproduced like so:

  1. Flip tile of item A, selecting A,
  2. Flip tile of item B, selecting B,
  3. Load (or remove) item A.

Because B is selected, the last action affects the wrong item. We could have chosen a different design that would circumvent the issue at hand, but let's assume there are some benefits to this design, and that it was a conscious choice. Luckily, the fix is also obvious now: Any of the item-bound actions needs to somehow select the proper item just in time.

Bug fix attempt #1

So let's just connect to the action's trigger of each of the buttons and set the currentIndex. This will run on top of the action's original payload:

Button {
    id: loadButton

    action: root.loadItem

    Connections {
        target: loadButton.action
        function onTriggered() {
            root.currentIndex = tile.index
        }
    }
}

Firstly, this code is wrong in a very surprising way: Each Connections instance in the view will now fire if one button is pressed, as the passed actions have reference symantics [3]. In a view that shows 30 tiles, one button click triggers 30 function calls as there will be 30 Connections instances, one per button.

Secondly, even if it worked, we have no guarantee that currentIndex would be updated before the action's payload, as the execution order of multiple onTriggered slots on the same target & signal is not defined.

Bug fix attempt #2

The faulty behaviour of the previous attempt points us in the right direction however: If we want to amend the button's action, we first need to create a separate action instance like so:

Button {
    action: Action {
        text: root.loadItem.text
        onTriggered: {
            root.currentIndex = tile.index;
            root.loadItem.trigger();
        }
    }
}

This fixes the bug! But how would we generalize this pattern?

Introducing: The Action Decorator

Decorators [4] are powerful and well-known to Python developers. The principle of using an outer and inner function, chained together, is exactly how we fixed our bug. To support the general case, we need to wrap the complete QML Action API [5] like so:

// Tools.ActionDecorator
Action {
    id: root

    required property Action action

    checkable: root.action.checkable
    checked: root.action.checked
    enabled: root.action.enabled
    icon: root.action.icon
    shortcut: root.action.shortcut
    text: root.action.text
    onTriggered: Qt.callLater(root.action.trigger)
}

Here, Qt.callLater() is used to reorder execution so that the outer action's onTriggered slot runs before the inner action's onTriggered slot. This allows us to use the action decorator in a very natural way:

Button {
    action: Tools.ActionDecorator {
        action: root.loadItem
        onTriggered: root.currentIndex = tile.index
    }
}

The extra behaviour we want to run before the original action's onTriggered slot is simply placed into the decorating action's own onTriggered slot!

Summary

We've shown the usefulness of QML Actions in encapsulating behaviour that originates from user interactions. We also demonstrated that injectable actions improve our software design through better decoupling. Then we took a deeper look at some of typical issues one encounters when employing actions. We saw that careless use of Connections can lead to surprising bugs. Eventually we discovered the ActionDecorator pattern that shares strong similarities with Python decorators.

As a bonus, here's the video clip of the correct application functionality:

Video 3: It all works now