Add TapHandler.exclusiveSignals to enable single/double tap exclusivity

If exclusiveSignals == NotExclusive (the default), behavior remains as
it was: singleTapped() and doubleTapped() are emitted as the taps occur,
so it's not very useful to react on singleTapped() if you mean to
distinguish these two cases.

If exclusiveSignals == SingleTap, the doubleTapped signal will not be
emitted at all, and therefore singleTapped can be emitted immediately
and unambiguously.

If exclusiveSignals == DoubleTap, the singleTapped signal will not be
emitted at all, and therefore doubleTapped can be emitted immediately
and unambiguously.

If exclusiveSignals == SingleTap | DoubleTap, we must wait
qApp->styleHints()->mouseDoubleClickInterval() milliseconds after a tap
is detected before emitting either signal, so that they are distinct and
can be used to drive behavior that should not occur in other cases.
A triple-tap will not trigger either signal.

[ChangeLog][QtQuick][Event Handlers] TapHandler.exclusiveSignals now
lets you make the singleTapped and doubleTapped signals exclusive.

Task-number: QTBUG-65088
Fixes: QTBUG-107264
Change-Id: Ifb2c4b72759246c64b3bfa2f776c28266806b985
Reviewed-by: Fabian Kosmale <fabian.kosmale@qt.io>
Reviewed-by: Oliver Eftevaag <oliver.eftevaag@qt.io>
This commit is contained in:
Shawn Rutledge 2022-10-06 17:50:17 +02:00
parent e3343eb83f
commit d3f2c6ac42
6 changed files with 233 additions and 26 deletions

View File

@ -11,6 +11,7 @@ Rectangle {
property alias hovered: hoverHandler.hovered
property alias gesturePolicy: tap.gesturePolicy
property alias margin: tap.margin
property alias exclusiveSignals: tap.exclusiveSignals
signal tapped
implicitHeight: Math.max(Screen.pixelDensity * 7, label.implicitHeight * 2)
@ -29,7 +30,7 @@ Rectangle {
id: tap
margin: 10 // the user can tap a little beyond the edges
objectName: label.text + " Tap"
onTapped: {
onSingleTapped: {
tapFlash.start()
root.tapped()
}

View File

@ -30,6 +30,7 @@ Item {
text: "Launch Missile"
Layout.fillWidth: true
gesturePolicy: TapHandler.ReleaseWithinBounds
exclusiveSignals: TapHandler.SingleTap
onTapped: missileEmitter.burst(1)
Text {
anchors { top: parent.bottom; horizontalCenter: parent.horizontalCenter }

View File

@ -179,6 +179,14 @@ void QQuickTapHandler::timerEvent(QTimerEvent *event)
m_longPressTimer.stop();
qCDebug(lcTapHandler) << objectName() << "longPressed";
emit longPressed();
} else if (event->timerId() == m_doubleTapTimer.timerId()) {
m_doubleTapTimer.stop();
qCDebug(lcTapHandler) << objectName() << "double-tap timer expired; taps:" << m_tapCount;
Q_ASSERT(m_exclusiveSignals == (SingleTap | DoubleTap));
if (m_tapCount == 1)
emit singleTapped(m_singleTapReleasedPoint, m_singleTapReleasedButton);
else if (m_tapCount == 2)
emit doubleTapped(m_singleTapReleasedPoint, m_singleTapReleasedButton);
}
}
@ -247,6 +255,38 @@ void QQuickTapHandler::setGesturePolicy(QQuickTapHandler::GesturePolicy gestureP
emit gesturePolicyChanged();
}
/*!
\qmlproperty enumeration QtQuick::TapHandler::exclusiveSignals
\since 6.5
Determines the exclusivity of the singleTapped() and doubleTapped() signals.
\value NotExclusive (the default) singleTapped() and doubleTapped() are
emitted immediately when the user taps once or twice, respectively.
\value SingleTap singleTapped() is emitted immediately when the user taps
once, and doubleTapped() is never emitted.
\value DoubleTap doubleTapped() is emitted immediately when the user taps
twice, and singleTapped() is never emitted.
\value (SingleTap | DoubleTap) Both signals are delayed until
QStyleHints::mouseDoubleClickInterval(), such that either singleTapped()
or doubleTapped() can be emitted, but not both. But if 3 or more taps
occur within \c mouseDoubleClickInterval, neither signal is emitted.
\note The remaining signals such as tapped() and tapCountChanged() are
always emitted immediately, regardless of this property.
*/
void QQuickTapHandler::setExclusiveSignals(QQuickTapHandler::ExclusiveSignals exc)
{
if (m_exclusiveSignals == exc)
return;
m_exclusiveSignals = exc;
emit exclusiveSignalsChanged();
}
/*!
\qmlproperty bool QtQuick::TapHandler::pressed
\readonly
@ -270,6 +310,16 @@ void QQuickTapHandler::setPressed(bool press, bool cancel, QPointerEvent *event,
} else {
m_longPressTimer.stop();
m_holdTimer.invalidate();
if (m_exclusiveSignals == (SingleTap | DoubleTap)) {
if (m_tapCount == 0) {
m_singleTapReleasedPoint = point;
m_singleTapReleasedButton = event->isSinglePointEvent() ? static_cast<QSinglePointEvent *>(event)->button() : Qt::NoButton;
qCDebug(lcTapHandler) << objectName() << "waiting to emit singleTapped:" << qApp->styleHints()->mouseDoubleClickInterval() << "ms";
m_doubleTapTimer.start(qApp->styleHints()->mouseDoubleClickInterval(), this);
} else if (m_doubleTapTimer.isActive()) {
qCDebug(lcTapHandler) << objectName() << "tap" << (m_tapCount + 1) << "after" << event->timestamp() / 1000.0 - m_lastTapTimestamp << "sec";
}
}
}
if (press) {
// on press, grab before emitting changed signals
@ -281,22 +331,25 @@ void QQuickTapHandler::setPressed(bool press, bool cancel, QPointerEvent *event,
if (!cancel && !press && parentContains(point)) {
if (point.timeHeld() < longPressThreshold()) {
// Assuming here that pointerEvent()->timestamp() is in ms.
qreal ts = event->timestamp() / 1000.0;
if (ts - m_lastTapTimestamp < m_multiTapInterval &&
QVector2D(point.scenePosition() - m_lastTapPos).lengthSquared() <
const qreal ts = event->timestamp() / 1000.0;
const qreal interval = ts - m_lastTapTimestamp;
const auto distanceSquared = QVector2D(point.scenePosition() - m_lastTapPos).lengthSquared();
if (interval < m_multiTapInterval && distanceSquared <
(event->device()->type() == QInputDevice::DeviceType::Mouse ?
m_mouseMultiClickDistanceSquared : m_touchMultiTapDistanceSquared))
++m_tapCount;
else
m_tapCount = 1;
qCDebug(lcTapHandler) << objectName() << "tapped" << m_tapCount << "times";
qCDebug(lcTapHandler) << objectName() << "tapped" << m_tapCount << "times; interval since last:" << interval
<< "sec; distance since last:" << qSqrt(distanceSquared);
auto button = event->isSinglePointEvent() ? static_cast<QSinglePointEvent *>(event)->button() : Qt::NoButton;
emit tapped(point, button);
emit tapCountChanged();
if (m_tapCount == 1)
if (m_tapCount == 1 && !m_exclusiveSignals.testFlag(DoubleTap))
emit singleTapped(point, button);
else if (m_tapCount == 2)
else if (m_tapCount == 2 && !m_exclusiveSignals.testFlag(SingleTap)) {
emit doubleTapped(point, button);
}
m_lastTapTimestamp = ts;
m_lastTapPos = point.scenePosition();
} else {

View File

@ -30,6 +30,7 @@ class Q_QUICK_PRIVATE_EXPORT QQuickTapHandler : public QQuickSinglePointHandler
Q_PROPERTY(qreal timeHeld READ timeHeld NOTIFY timeHeldChanged)
Q_PROPERTY(qreal longPressThreshold READ longPressThreshold WRITE setLongPressThreshold NOTIFY longPressThresholdChanged)
Q_PROPERTY(GesturePolicy gesturePolicy READ gesturePolicy WRITE setGesturePolicy NOTIFY gesturePolicyChanged)
Q_PROPERTY(QQuickTapHandler::ExclusiveSignals exclusiveSignals READ exclusiveSignals WRITE setExclusiveSignals NOTIFY exclusiveSignalsChanged REVISION(6, 5))
QML_NAMED_ELEMENT(TapHandler)
QML_ADDED_IN_VERSION(2, 12)
@ -43,6 +44,14 @@ public:
};
Q_ENUM(GesturePolicy)
enum ExclusiveSignal {
NotExclusive = 0,
SingleTap = 1 << 1,
DoubleTap = 1 << 2
};
Q_DECLARE_FLAGS(ExclusiveSignals, ExclusiveSignal)
Q_FLAG(ExclusiveSignal)
explicit QQuickTapHandler(QQuickItem *parent = nullptr);
bool isPressed() const { return m_pressed; }
@ -56,12 +65,16 @@ public:
GesturePolicy gesturePolicy() const { return m_gesturePolicy; }
void setGesturePolicy(GesturePolicy gesturePolicy);
QQuickTapHandler::ExclusiveSignals exclusiveSignals() const { return m_exclusiveSignals; }
void setExclusiveSignals(QQuickTapHandler::ExclusiveSignals newexclusiveSignals);
Q_SIGNALS:
void pressedChanged();
void tapCountChanged();
void timeHeldChanged();
void longPressThresholdChanged();
void gesturePolicyChanged();
Q_REVISION(6, 5) void exclusiveSignalsChanged();
// the second argument (Qt::MouseButton) was added in 6.2: avoid name clashes with IDs by not naming it for now
void tapped(QEventPoint eventPoint, Qt::MouseButton /* button */);
void singleTapped(QEventPoint eventPoint, Qt::MouseButton /* button */);
@ -86,9 +99,13 @@ private:
qreal m_lastTapTimestamp = 0;
QElapsedTimer m_holdTimer;
QBasicTimer m_longPressTimer;
QBasicTimer m_doubleTapTimer;
QEventPoint m_singleTapReleasedPoint;
Qt::MouseButton m_singleTapReleasedButton;
int m_tapCount = 0;
int m_longPressThreshold = -1;
GesturePolicy m_gesturePolicy = GesturePolicy::DragThreshold;
ExclusiveSignals m_exclusiveSignals = NotExclusive;
bool m_pressed = false;
static qreal m_multiTapInterval;

View File

@ -26,7 +26,10 @@ Rectangle {
id: tap
objectName: label.text
longPressThreshold: 100 // CI can be insanely slow, so don't demand a timely release to generate onTapped
onSingleTapped: console.log("Single tap")
onDoubleTapped: console.log("Double tap")
onTapped: {
console.log("Tapped")
tapFlash.start()
root.tappedPosition = point.scenePosition
root.tapped()

View File

@ -42,7 +42,10 @@ private slots:
void gesturePolicyDragWithinBounds_data();
void gesturePolicyDragWithinBounds();
void touchMultiTap();
void mouseMultiTap_data();
void mouseMultiTap();
void singleTapDoubleTap_data();
void singleTapDoubleTap();
void touchLongPress();
void mouseLongPress();
void buttonsMultiTouch();
@ -523,8 +526,32 @@ void tst_TapHandler::touchMultiTap()
QCOMPARE(tappedSpy.size(), 4);
}
void tst_TapHandler::mouseMultiTap_data()
{
QTest::addColumn<QQuickTapHandler::ExclusiveSignals>("exclusiveSignals");
QTest::addColumn<int>("expectedSingleTaps");
QTest::addColumn<int>("expectedSingleTapsAfterMovingAway");
QTest::addColumn<int>("expectedSingleTapsAfterWaiting");
QTest::addColumn<int>("expectedDoubleTaps");
QTest::newRow("NotExclusive") << QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::NotExclusive)
<< 1 << 2 << 3 << 1;
QTest::newRow("SingleTap") << QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::SingleTap)
<< 1 << 2 << 3 << 0;
QTest::newRow("DoubleTap") << QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::DoubleTap)
<< 0 << 0 << 0 << 1;
QTest::newRow("SingleTap|DoubleTap") << QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::SingleTap | QQuickTapHandler::DoubleTap)
<< 0 << 0 << 0 << 0;
}
void tst_TapHandler::mouseMultiTap()
{
QFETCH(QQuickTapHandler::ExclusiveSignals, exclusiveSignals);
QFETCH(int, expectedSingleTaps);
QFETCH(int, expectedSingleTapsAfterMovingAway);
QFETCH(int, expectedSingleTapsAfterWaiting);
QFETCH(int, expectedDoubleTaps);
const int dragThreshold = QGuiApplication::styleHints()->startDragDistance();
QScopedPointer<QQuickView> windowPtr;
createView(windowPtr, "buttons.qml");
@ -532,38 +559,143 @@ void tst_TapHandler::mouseMultiTap()
QQuickItem *button = window->rootObject()->findChild<QQuickItem*>("DragThreshold");
QVERIFY(button);
QQuickTapHandler *tapHandler = button->findChild<QQuickTapHandler*>();
QVERIFY(tapHandler);
tapHandler->setExclusiveSignals(exclusiveSignals);
QSignalSpy tappedSpy(button, SIGNAL(tapped()));
QSignalSpy singleTapSpy(tapHandler, &QQuickTapHandler::singleTapped);
QSignalSpy doubleTapSpy(tapHandler, &QQuickTapHandler::doubleTapped);
// Tap once
// Click once
QPoint p1 = button->mapToScene(QPointF(2, 2)).toPoint();
QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, p1);
QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, p1, 10);
QTRY_VERIFY(button->property("pressed").toBool());
QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, p1);
QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, p1, 10);
QTRY_VERIFY(!button->property("pressed").toBool());
QCOMPARE(tappedSpy.size(), 1);
// If exclusiveSignals == SingleTap | DoubleTap:
// This would be a single-click if we waited longer than the double-click interval,
// but it's too early for the signal at this moment; and we're going to click again.
// If exclusiveSignals == DoubleTap: singleTapped() won't happen.
// Otherwise: we got singleTapped() immediately.
QCOMPARE(singleTapSpy.size(), expectedSingleTaps);
QCOMPARE(tapHandler->timeHeld(), -1);
// Tap again in exactly the same place (not likely with touch in the real world)
QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, p1);
QTRY_VERIFY(button->property("pressed").toBool());
QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, p1);
QTRY_VERIFY(!button->property("pressed").toBool());
// Click again in exactly the same place
QTest::mouseClick(window, Qt::LeftButton, Qt::NoModifier, p1, 10);
QCOMPARE(tappedSpy.size(), 2);
QCOMPARE(singleTapSpy.size(), expectedSingleTaps);
QCOMPARE(doubleTapSpy.size(), expectedDoubleTaps);
// Tap a third time, nearby
p1 += QPoint(dragThreshold, dragThreshold);
QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, p1);
QTRY_VERIFY(button->property("pressed").toBool());
QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, p1);
QTRY_VERIFY(!button->property("pressed").toBool());
// Click a third time, nearby: that'll be a triple-click
p1 += QPoint(1, 1);
QTest::mouseClick(window, Qt::LeftButton, Qt::NoModifier, p1, 10);
QCOMPARE(tappedSpy.size(), 3);
QCOMPARE(singleTapSpy.size(), expectedSingleTaps);
QCOMPARE(doubleTapSpy.size(), expectedDoubleTaps);
QCOMPARE(tapHandler->tapCount(), 3);
// Tap a fourth time, drifting farther away
// Click a fourth time, drifting farther away: treated as a separate click, regardless of timing
p1 += QPoint(dragThreshold, dragThreshold);
QTest::mousePress(window, Qt::LeftButton, Qt::NoModifier, p1);
QTRY_VERIFY(button->property("pressed").toBool());
QTest::mouseRelease(window, Qt::LeftButton, Qt::NoModifier, p1);
QTRY_VERIFY(!button->property("pressed").toBool());
QTest::mouseClick(window, Qt::LeftButton, Qt::NoModifier, p1); // default delay to prevent double-click
QCOMPARE(tappedSpy.size(), 4);
QCOMPARE(tapHandler->tapCount(), 1);
QTRY_COMPARE(singleTapSpy.size(), expectedSingleTapsAfterMovingAway);
// Click a fifth time later on at the same place: treated as a separate click
QTest::mouseClick(window, Qt::LeftButton, Qt::NoModifier, p1);
QCOMPARE(tappedSpy.size(), 5);
QCOMPARE(tapHandler->tapCount(), 1);
QCOMPARE(singleTapSpy.size(), expectedSingleTapsAfterWaiting);
}
void tst_TapHandler::singleTapDoubleTap_data()
{
QTest::addColumn<QPointingDevice::DeviceType>("deviceType");
QTest::addColumn<QQuickTapHandler::ExclusiveSignals>("exclusiveSignals");
QTest::addColumn<int>("expectedEndingSingleTapCount");
QTest::addColumn<int>("expectedDoubleTapCount");
QTest::newRow("mouse:NotExclusive")
<< QPointingDevice::DeviceType::Mouse
<< QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::NotExclusive)
<< 1 << 1;
QTest::newRow("mouse:SingleTap")
<< QPointingDevice::DeviceType::Mouse
<< QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::SingleTap)
<< 1 << 0;
QTest::newRow("mouse:DoubleTap")
<< QPointingDevice::DeviceType::Mouse
<< QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::DoubleTap)
<< 0 << 1;
QTest::newRow("mouse:SingleTap|DoubleTap")
<< QPointingDevice::DeviceType::Mouse
<< QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::SingleTap | QQuickTapHandler::DoubleTap)
<< 0 << 1;
QTest::newRow("touch:NotExclusive")
<< QPointingDevice::DeviceType::TouchScreen
<< QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::NotExclusive)
<< 1 << 1;
QTest::newRow("touch:SingleTap")
<< QPointingDevice::DeviceType::TouchScreen
<< QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::SingleTap)
<< 1 << 0;
QTest::newRow("touch:DoubleTap")
<< QPointingDevice::DeviceType::TouchScreen
<< QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::DoubleTap)
<< 0 << 1;
QTest::newRow("touch:SingleTap|DoubleTap")
<< QPointingDevice::DeviceType::TouchScreen
<< QQuickTapHandler::ExclusiveSignals(QQuickTapHandler::SingleTap | QQuickTapHandler::DoubleTap)
<< 0 << 1;
}
void tst_TapHandler::singleTapDoubleTap()
{
QFETCH(QPointingDevice::DeviceType, deviceType);
QFETCH(QQuickTapHandler::ExclusiveSignals, exclusiveSignals);
QFETCH(int, expectedEndingSingleTapCount);
QFETCH(int, expectedDoubleTapCount);
QScopedPointer<QQuickView> windowPtr;
createView(windowPtr, "buttons.qml");
QQuickView * window = windowPtr.data();
QQuickItem *button = window->rootObject()->findChild<QQuickItem*>("DragThreshold");
QVERIFY(button);
QQuickTapHandler *tapHandler = button->findChild<QQuickTapHandler*>();
QVERIFY(tapHandler);
tapHandler->setExclusiveSignals(exclusiveSignals);
QSignalSpy tappedSpy(tapHandler, &QQuickTapHandler::tapped);
QSignalSpy singleTapSpy(tapHandler, &QQuickTapHandler::singleTapped);
QSignalSpy doubleTapSpy(tapHandler, &QQuickTapHandler::doubleTapped);
auto tap = [window, tapHandler, deviceType, this](const QPoint &p1) {
switch (static_cast<QPointingDevice::DeviceType>(deviceType)) {
case QPointingDevice::DeviceType::Mouse:
QTest::mouseClick(window, Qt::LeftButton, Qt::NoModifier, p1, 10);
break;
case QPointingDevice::DeviceType::TouchScreen:
QTest::touchEvent(window, touchDevice).press(0, p1, window);
QTRY_VERIFY(tapHandler->isPressed());
QTest::touchEvent(window, touchDevice).release(0, p1, window);
break;
default:
break;
}
};
// tap once
const QPoint p1 = button->mapToScene(QPointF(2, 2)).toPoint();
tap(p1);
QCOMPARE(tappedSpy.size(), 1);
QCOMPARE(doubleTapSpy.size(), 0);
// tap again immediately afterwards
tap(p1);
QTRY_COMPARE(doubleTapSpy.size(), expectedDoubleTapCount);
QCOMPARE(tappedSpy.size(), 2);
QCOMPARE(singleTapSpy.size(), expectedEndingSingleTapCount);
}
void tst_TapHandler::touchLongPress()