Using QML Actions to decouple components

Problem statement

Imagine a simple application with three pages and a toolbar to navigate between the pages. In QML, we can use a StackView [1] to display the pages, together with nice transition animations.

Here's our Main.qml:

ApplicationWindow {
    id: root

    title: "Using QML Actions to Decouple Components"
    width: 1280
    height: 800
    visible: true
    menuBar: UI.Toolbar { view: view }

    StackView {
        id: view

        anchors.fill: parent
    }
}

And this is how it looks:

StackView with three pages

Simple enough, right? But you of course already guessed that all the heavy-lifting must have been done by the UI.Toolbar:

// UI.Toolbar
Container {
    id: root

    function showPage(view, page, objectName) { ... }

    required property StackView view
    readonly property Component browsePage: Page {
        objectName: "browsePage"
        text: "Showing <em>Browse</em> page"
    }
    readonly property Component detailsPage: Page {
        objectName: "detailsPage"
        text: "Showing <em>Details</em> page"
    }
    readonly property Component settingsPage: Page {
        objectName: "settingsPage"
        text: "Showing <em>Settings</em> page"
    }

    contentItem: Row { spacing: 8 }

    Button {
        text: "Browse"
        onClicked: root.showPage(root.view, root.browsePage, "browsePage")
    }
    Button {
        text: "View Details"
        onClicked: root.showPage(root.view, root.detailsPage, "detailsPage")
    }
    Button {
        text: "View Settings"
        onClicked: root.showPage(root.view, root.settingsPage, "settingsPage")
    }
    Component.onCompleted: root.showPage(root.view, root.browsePage, "browsePage")
}

The toolbar is not just responsible for showing a couple of buttons, it also implements the whole push'n'pop logic for pages in the StackView. This creates a tight coupling between toolbar, pages and StackView, so let's try to fix it.

Cleaning up the Mess

We start by moving the visual components into a custom StackView component:

// UI.View
StackView {
    id: root

    function showPage(view, page, objectName) { ... }

    readonly property Component browsePage: Page {
        objectName: "browsePage"
        text: "Showing <em>Browse</em> page"
    }
    readonly property Component detailsPage: Page {
        objectName: "detailsPage"
        text: "Showing <em>Details</em> page"
    }
    readonly property Component settingsPage: Page {
        objectName: "settingsPage"
        text: "Showing <em>Settings</em> page"
    }

    Component.onCompleted: root.showPage(root, root.browsePage, "browsePage")
}

But how do we fix the buttons' onClicked behaviour now that the pages are no longer available to them? This is where QML's Action [2] component comes into play.

Actions are a common pattern in UI toolkits. They encapsulate the user's intent to trigger predefined behaviour, usually through the click of a button. In QML, many interactive controls derive from AbstractButton [3]. They all can be set up and controlled through actions like so:

Button {
   action: Action {
       text: "Click me!"
       onTriggered: console.log("Action triggered!")
   }
}

Here, the button makes use of action's properties. This enables us to split off the visual presentation of a behaviour from its definition! Quite often, a neat trick to decouple components is to inject actions instead of defining them in place:

// UI.Toolbar
Container {
    id: root

    required property Action browse
    required property Action viewDetails
    required property Action viewSettings

    contentItem: Row { spacing: 8 }

    Button { action: root.browse }
    Button { action: root.viewDetails }
    Button { action: root.viewSettings }
}

Now what did we gain? Well, we can swap out or change the actions elsewhere without having to touch the visual presentation of them (via buttons) in the toolbar. Also, there's now much less logic in the toolbar that could break.

Tying it Together

View and toolbar no longer know of each other's existence. In fact, they are now loosely coupled. Their whole interaction depends entirely on how and where we define the actions. Main.qml is a good place for that:

ApplicationWindow {
    id: root

    title: "Using QML Actions to Decouple Components"
    width: 1280
    height: 800
    visible: true
    menuBar: UI.Toolbar {
        browse: Action {
            text: "Browse"
            onTriggered: view.showPage(view, view.browsePage, "browsePage")
        }
        viewDetails: Action {
            text: "View Details"
            onTriggered: view.showPage(view, view.detailsPage, "detailsPage")
        }
        viewSettings: Action {
            text: "View Settings"
            onTriggered: view.showPage(view, view.browsePage, "settingsPage")
        }
    }

    UI.View {
        id: view

        anchors.fill: parent
    }
}

The toolbar has gained three new Action properties, as compared to the single view property in the beginning. Yet the combined maintenance cost of the Action properties will be far less than if we had kept the view dependency in the toolbar.