Search code examples
qtqmltouch

Inside a Qt QML SwipeView, how do I make sure the Dials always handle touch input instead of the underlying SwipeView?


Background

I have a SwipeView with a number of pages. It's designed for a small round screen, 2.1" in diameter, with the idea being swipe left or right between pages, and each page has one or a few UI elements to interact with. Many of those pages contain a Dial and little/nothing else. Maybe a button at the bottom center.

Issue

With touch (the intended input device), a horizontal drag within the Dial is most often interpreted as navigating the SwipeView underneath. Very rarely does it recognize that I wish to adjust the Dial.

With a mouse, the Dial seems to pretty reliably grab the drag gesture so that even a horizontal drag (e.g. from the top or bottom of the dial) does not inadvertently navigate between pages in the SwipeView.

See this video for a comparison of the touch behavior vs. the mouse behavior (I wasn't able to upload it directly in the StackOverflow editor, so I hope the link to imgur is acceptable)

Question

Is there a way to give the Dial higher priority over interpreting touch/swipe gestures so that the user is not likely to accidentally swipe between pages? (More like the mouse behavior)

I can work around it by starting the gesture vertically and then moving to the desired position, but I would prefer not to have to tell my user that, and that seems to be their most frequent hangup in this app.

The dials are 70% of the size of the window itself so there's plenty of room outside of the dials for the user to swipe the SwipeView.

What I've tried/considered so far

I have tried setting SwipeView interactive: !(dial1.pressed || dial2.pressed) etc. but this does not work. It would seem the .pressed property of the dial does not take effect until the dial definitively gets selected for handling the input. I'd prefer it to behave more like it does with the mouse, in this particular instance.

In considering a SwipeView that is purely interactive: false--so that the Dial is guaranteed to pick up the gestures--I would have to add buttons to each screen to navigate between pages, and that will take away from the room I have for other UI elements. The Dial pages are the only ones giving me trouble. Other pages already have 1-3 buttons in places where there is little risk of a conflicting gesture. If I have to add a button to either side of the page, that starts to squish things down to a point where it becomes hard to hit on the intended screen size.

The only other alternative I can think of at the moment is an edit / done button (like a modal dialog?), but that adds some friction I'd rather avoid.

Code

This code is the minimal reproducible example as seen in the video. I last tried in Qt 6.3.0 on Ubuntu 20.04 with a USB/HDMI touchscreen.

import QtQuick
import QtQuick.Controls.Basic

Window {
    width: 480
    height: 480
    visible: true
    title: "SwipeView Dial Test"

    SwipeView {
        anchors.fill: parent
        Item {
            Dial {
                id: dial1
                anchors.centerIn: parent
                width: parent.width * 0.7
                height: parent.height * 0.7
            }
        }
        Item {
            Dial {
                id: dial2
                anchors.centerIn: parent
                width: parent.width * 0.7
                height: parent.height * 0.7
            }
        }
    }
}

Solution

  • It really feels like there's an issue with the Dial's drag handler. It feels like not all of the control is covered by a drag handler so the events are being picked up by the SwipeView at unexpected times.

    I've created a workaround by patching Dial with DialPatched.qml.

    In my implementation, I created a mock Rectangle that sits over the Dial. The mock rectangle will now receive drag events via its MouseArea which has preventStealing: true. We observe changes in mouse position and reverse engineer a value via Math.atan2() to convert vectors into angles and compute a new value for the dial.

    To see the mock Rectangle I colored it in a nearly transparent "red" so you can see both the Rectangle and the Dial underneath. If you're going to go with this approach, I recommend turning the opacity to 0 or swapping out the Rectangle with a placeholder Item.

    Also, I did a minor correction to your width/height calculation.

    import QtQuick
    import QtQuick.Controls
    Page {
        SwipeView {
            anchors.fill: parent
            Item {
                DialPatched {
                    id: dial1
                    anchors.centerIn: parent
                    debug: debugCheckBox.checked
                    width: Math.min(parent.width,parent.height) * 0.7
                    height: width
                }
            }
            Item {
                DialPatched {
                    id: dial2
                    anchors.centerIn: parent
                    debug: debugCheckBox.checked
                    width: Math.min(parent.width,parent.height) * 0.7
                    height: width
                }
            }
        }
        CheckBox { id: debugCheckBox; text: "Debug"; checked: true }
    }
    
    // DialPatched.qml
    import QtQuick
    import QtQuick.Controls
    Dial {
        id: dial
        property bool debug: true
        Item {
            anchors.fill: parent
            Rectangle {
                width: parent.width
                height: parent.height
                color: debug ? "red" : "transparent"
                opacity: debug ? 0.05 : 0
                property int startX: 0
                property int startY: 0
                onXChanged: Qt.callLater(mouseMove, startX+x, startY+y)
                onYChanged: Qt.callLater(mouseMove, startX+x, startY+y)
                MouseArea {
                    anchors.fill: parent
                    drag.target: parent
                    preventStealing: true
                    onPressed: function (mouse) {
                        parent.startX = mouse.x;
                        parent.startY = mouse.y;
                        parent.Drag.active = true;
                    }
                    onReleased: {
                        parent.Drag.active = false;
                        parent.x = 0;
                        parent.y = 0;
                    }
                }
                function mouseMove(mx,my) {
                    if (Drag.active) {
                        let ang = Math.atan2(height/2 - my, mx - width/2) * 180 / Math.PI;
                        ang = (360 + 270 - Math.round(ang)) % 360;
                        dial.value = (ang - 40) / 280 * (dial.to - dial.from) + dial.from;
                    }
                }
            }
        }
    }
    

    You can Try it Online!