notifications: add 2 finger swipe gesture to dismiss on popups

fixes #3716
This commit is contained in:
bbedward
2026-10-08 09:05:07 -04:00
parent c0f08f08cb
commit 4dc45185ba
4 changed files with 129 additions and 13 deletions
@@ -44,6 +44,9 @@ Singleton {
readonly property var swipeDetachSpring: swipeSpring(800, 0.95)
readonly property var swipeSnapSpring: swipeSpring(550, 0.6)
readonly property var swipeAttachSpring: swipeSpring(850, 0.95)
readonly property real swipeWheelEngageDistance: 12
readonly property int swipeWheelSettleMs: 500
readonly property int swipeFlingProjectionMs: 200
readonly property int expandedLimit: 10
readonly property int collapsedLines: compact ? 1 : 2
readonly property real summarySize: SettingsData.notificationSummaryFontSize || Theme.fontSizeMedium
@@ -55,13 +55,14 @@ Item {
motion.retarget(target);
}
function release() {
function release(velocity = 0) {
if (group?.row !== root)
return;
if (Math.abs(group.translation) > width * NotificationMetrics.swipeThreshold) {
const projected = group.translation + velocity * NotificationMetrics.swipeFlingProjectionMs / 1000;
if (Math.abs(projected) > width * NotificationMetrics.swipeThreshold) {
dismissing = true;
motion.snapTo(motion.value);
dismissAnimation.to = (group.translation > 0 ? 1 : -1) * (width + bleed);
dismissAnimation.to = (projected > 0 ? 1 : -1) * (width + bleed);
dismissAnimation.start();
}
group.end(root);
@@ -142,4 +143,11 @@ Item {
}
onTranslationChanged: root.group?.drag(root, translation.x)
}
NotificationSwipeWheel {
enabled: root.group !== null && !root.dismissing
onBegan: root.group.begin(root)
onMoved: travel => root.group?.drag(root, travel)
onEnded: velocity => root.release(velocity)
}
}
@@ -0,0 +1,77 @@
import QtQuick
import "../../DCommon/Common/WheelInput.js" as WheelInput
import "../../DCommon/Widgets/ScrollConstants.js" as Scroll
WheelHandler {
id: handler
property real travel: 0
property real drift: 0
property bool engaged: false
property real lastTime: 0
property var samples: []
readonly property Timer settle: Timer {
interval: NotificationMetrics.swipeWheelSettleMs
onTriggered: handler.finish()
}
signal began
signal moved(real travel)
signal ended(real velocity)
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
orientation: Qt.Horizontal
blocking: false
target: null
function velocity() {
if (samples.length < 2)
return 0;
const span = samples[samples.length - 1].time - samples[0].time;
if (span <= 0)
return 0;
const total = samples.reduce((sum, s) => sum + s.delta, 0);
return Math.max(-Scroll.maxMomentumVelocity, Math.min(Scroll.maxMomentumVelocity, total / span * 1000));
}
function finish() {
settle.stop();
const lifted = Date.now() - lastTime < Scroll.velocitySampleWindowMs;
const fling = lifted ? velocity() : 0;
travel = 0;
drift = 0;
samples = [];
if (!engaged)
return;
engaged = false;
ended(fling);
}
onWheel: event => {
if (!WheelInput.isTouchpad(event))
return;
const now = Date.now();
// Qt Wayland negates finger deltas; natural scrolling flips them back and reports inverted
const delta = (event.inverted ? event.pixelDelta.x : -event.pixelDelta.x) * Scroll.touchpadSpeed;
travel += delta;
drift += event.pixelDelta.y * Scroll.touchpadSpeed;
samples = samples.filter(s => now - s.time < Scroll.velocitySampleWindowMs).concat({
"delta": delta,
"time": now
});
lastTime = now;
settle.restart();
if (!engaged) {
if (Math.abs(travel) < NotificationMetrics.swipeWheelEngageDistance || Math.abs(travel) <= Math.abs(drift))
return;
engaged = true;
began();
}
moved(travel);
}
onActiveChanged: {
if (!active)
finish();
}
}
@@ -589,7 +589,8 @@ PanelWindow {
readonly property real swipeTravelDistance: width
readonly property real swipeFadeStartOffset: swipeTravelDistance * swipeFadeStartRatio
readonly property real swipeFadeDistance: Math.max(1, swipeTravelDistance - swipeFadeStartOffset)
readonly property bool swipeActive: swipeDragHandler.active
property bool swipeWheelActive: false
readonly property bool swipeActive: swipeDragHandler.active || swipeWheelActive
property bool swipeDismissing: false
onSwipeDismissingChanged: {
if (!win.connectedFrameMode)
@@ -602,6 +603,19 @@ PanelWindow {
win.popupChromeGeometryChanged();
}
function releaseSwipe(velocity = 0) {
if (win.exiting || swipeDismissing)
return;
const projected = swipeOffset + velocity * NotificationMetrics.swipeFlingProjectionMs / 1000;
if (Math.abs(projected) <= dismissThreshold) {
swipeOffset = 0;
return;
}
swipeDismissDirection = projected < 0 ? -1 : 1;
swipeDismissing = true;
swipeDismissAnim.start();
}
readonly property bool shadowsAllowed: win.popupWindowShadowActive
readonly property var elevLevel: Theme.elevationLevel2
readonly property real cardInset: Theme.snap(Theme.spacingXS, win.dpr)
@@ -817,16 +831,9 @@ PanelWindow {
yAxis.enabled: false
onActiveChanged: {
if (active || win.exiting || content.swipeDismissing)
if (active)
return;
if (Math.abs(content.swipeOffset) > content.dismissThreshold) {
content.swipeDismissDirection = content.swipeOffset < 0 ? -1 : 1;
content.swipeDismissing = true;
swipeDismissAnim.start();
} else {
content.swipeOffset = 0;
}
content.releaseSwipe();
}
onTranslationChanged: {
@@ -893,6 +900,27 @@ PanelWindow {
]
}
// Sits on the untranslated card footprint: inside content the handler would lose the pointer once the card slides past it,
// and above content so it sees the gesture before DFlickable's blocking vertical handler
Item {
parent: slideClip
x: content.x
y: content.y
width: content.width
height: content.height
visible: content.visible
NotificationSwipeWheel {
enabled: !win.exiting && !content.swipeDismissing
onBegan: content.swipeWheelActive = true
onMoved: travel => content.swipeOffset = travel
onEnded: velocity => {
content.swipeWheelActive = false;
content.releaseSwipe(velocity);
}
}
}
DCommon.DAnim {
id: enterAnimation
target: win