shift-shell/kwin/mobiletaskswitcher/qml/TaskSwitcherHelpers.qml
Luis Büchi 617ba59de7 add haptic feedback to task switcher gestures
haptics are triggered when conditions are met to open task switcher or task scrub mode is engaged.
fixes related bug in task switcher gesture logic when invoked from homescreen

fixes https://invent.kde.org/plasma/plasma-mobile/-/issues/366
2024-07-24 10:57:57 +00:00

276 lines
9.8 KiB
QML

// SPDX-FileCopyrightText: 2021 Devin Lin <devin@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
import QtQuick 2.15
import org.kde.kirigami 2.20 as Kirigami
import org.kde.kwin 3.0 as KWinComponents
/**
* State object for the task switcher.
*/
QtObject {
id: root
// TaskSwitcher item component
// We assume that the taskSwitcher the size of the entire screen.
required property var taskSwitcher
property var state: taskSwitcher.state
required property var stateClass
// direction of the movement
readonly property bool gestureMovingRight: state.xVelocity > 0
readonly property bool gestureMovingUp: state.yVelocity < 0
readonly property bool currentlyBeingOpened: state.gestureInProgress || openAnim.running
readonly property bool currentlyBeingClosed: closeAnim.running || openAppAnim.running
// yPosition when the task switcher is completely open
readonly property real openedYPosition: (taskSwitcher.height - taskHeight) / 2
// yPosition threshold below which opening the task switcher should be undone and returned to the previously active task
readonly property real undoYThreshold: openedYPosition / 2
// whether the switcher has already triggered haptic feedback or not
// we don't want to continuously send haptics, just once is enough
property bool hasVibrated: false
// ~~ measurement constants ~~
// dimensions of a real window on the screen
readonly property real windowHeight: taskSwitcher.height - taskSwitcher.topMargin - taskSwitcher.bottomMargin
readonly property real windowWidth: taskSwitcher.width - taskSwitcher.leftMargin - taskSwitcher.rightMargin
// dimensions of the task previews
readonly property real previewHeight: windowHeight * scalingFactor
readonly property real previewWidth: windowWidth * scalingFactor
readonly property real previewAspectRatio: previewWidth / previewHeight
readonly property real taskHeight: previewHeight + taskHeaderHeight
readonly property real taskWidth: previewWidth
// spacing between each task preview
readonly property real taskSpacing: Kirigami.Units.gridUnit
// height of the task preview header
readonly property real taskHeaderHeight: Kirigami.Units.gridUnit * 2 + Kirigami.Units.smallSpacing * 2
// the scaling factor of the window preview compared to the actual window
// we need to ensure that window previews always fit on screen
readonly property real scalingFactor: {
let candidateFactor = 0.6;
let candidateTaskHeight = windowHeight * candidateFactor + taskHeaderHeight;
let candidateTaskWidth = windowWidth * candidateFactor;
let candidateHeight = (candidateTaskWidth / windowWidth) * windowHeight;
if (candidateHeight > windowHeight) {
return candidateTaskHeight / windowHeight;
} else {
return candidateTaskWidth / windowWidth;
}
}
// scaling factor during closing of the switcher
property real closingScalingFactor: 1
// scale of the task list (based on the progress of the swipe up gesture)
readonly property real currentScale: {
let maxScale = 1 / scalingFactor;
let subtract = (maxScale - 1) * Math.min(root.state.yPosition / openedYPosition, 1);
let finalScale = Math.min(maxScale, maxScale - subtract);
// if closing scaling factor is below 1 we want it to override the other scale
// to allow for a smoother closing animation
if (closingScalingFactor < 1) {
return closingScalingFactor;
}
// animate scale only if we are *not* opening from the homescreen
if (root.state.wasInActiveTask || !root.state.gestureInProgress) {
return finalScale;
}
return 1;
}
readonly property bool isScaleClamped: root.state.yPosition > openedYPosition
readonly property real taskScrubDistance: windowWidth / (2 * 6) // formula says how many tasks can be scrubbed through in half of the window width
property bool isInTaskScrubMode: false
// ~~ signals and functions ~~
// cancel all animated moving, as another flick source is taking over
signal cancelAnimations()
onCancelAnimations: {
openAnim.stop();
openAppAnim.stop();
closeAnim.stop();
closeScaleAnim.stop();
xAnim.stop();
}
function getTaskIndexFromWindow(window) {
for (let i = 0; i < taskSwitcher.tasksModel.rowCount(); i++) {
const modelWindow = taskSwitcher.tasksModel.data(taskSwitcher.tasksModel.index(i, 0), Qt.DisplayRole);
if (modelWindow == window) {
return i;
}
}
return 0;
}
function getTaskIndexFromXPosition() {
let candidateIndex = Math.round(-root.state.xPosition / (taskSpacing + taskWidth));
return Math.max(0, Math.min(taskSwitcher.tasksCount - 1, candidateIndex));
}
// TODO either use updateTaskIndex to always have the "newest current task index" in the state var or use "getNearestTaskIndex", not both it's redundant
function updateTaskIndex() {
root.state.currentTaskIndex = getTaskIndexFromXPosition();
}
function open() {
openAnim.restart();
}
function close() {
cancelAnimations();
closingScalingFactor = currentScale;
closeAnim.restart();
closeScaleAnim.restart();
}
function openApp(index, window, duration = Kirigami.Units.shortDuration, horizontalEasing = Easing.OutBack) {
// cancel any opening animations ongoing
cancelAnimations();
animateGoToTaskIndex(index, duration);
openAppAnim.restart();
KWinComponents.Workspace.activeWindow = window
}
// get the xPosition where the task will be centered on the screen
function xPositionFromTaskIndex(index) {
return -index * (taskWidth + taskSpacing);
}
// instantly go to the task index
function goToTaskIndex(index) {
root.state.xPosition = xPositionFromTaskIndex(index);
}
// go to the task index, animated
function animateGoToTaskIndex(index, duration = Kirigami.Units.longDuration * 2, easing = Easing.OutExpo) {
xAnim.duration = duration;
xAnim.to = xPositionFromTaskIndex(index);
xAnim.easing.type = easing;
xAnim.restart();
}
function getNearestTaskIndex() {
let newTaskIndex = getTaskIndexFromXPosition();
let currentTaskIndexPosition = xPositionFromTaskIndex(root.state.currentTaskIndex);
if (root.state.xPosition > currentTaskIndexPosition) {
// moving to task further to the right
if (newTaskIndex != root.state.currentTaskIndex) {
// reset back to current task index
return root.state.currentTaskIndex;
} else {
// animate snapping to new task index
return Math.max(0, newTaskIndex);
}
} else {
// moving to task further to the left
if (newTaskIndex != root.state.currentTaskIndex) {
// animate snapping to new task index
return Math.min(taskSwitcher.tasksCount - 1, newTaskIndex);
} else {
// reset back to current task index
return root.state.currentTaskIndex;
}
}
}
function snapToNearestTask() {
let index = getNearestTaskIndex();
animateGoToTaskIndex(index);
}
// This is a workaround for flickable not actually flicking, so we just snap to the next task
// based on old movement direction, ignoring momentum (because flickable doesn't give us any momentum)
function snapToNearestTaskWorkaround(movingRight) {
let currentTaskIndexPosition = xPositionFromTaskIndex(root.state.currentTaskIndex);
if (root.state.xPosition > currentTaskIndexPosition) {
if (movingRight) {
animateGoToTaskIndex(root.state.currentTaskIndex);
} else {
animateGoToTaskIndex(Math.max(0, root.state.currentTaskIndex - 1));
}
} else {
if (movingRight) {
animateGoToTaskIndex(Math.min(taskSwitcher.tasksCount - 1, root.state.currentTaskIndex + 1));
} else {
animateGoToTaskIndex(root.state.currentTaskIndex);
}
}
}
// ~~ property animators ~~
property var xAnim: NumberAnimation {
target: root.state
property: "xPosition"
easing.type: Easing.OutBack
}
property var openAnim: NumberAnimation {
target: root.state
property: "yPosition"
to: openedYPosition
duration: 250
easing.type: Easing.OutExpo
onFinished: {
root.state.status = stateClass.Active;
}
}
// TODO: This animation should ideally be replaced by some
// speed tracking to track finger movement better. Until then
// InBack at least pretends to go in the finger move direction
property var closeAnim: NumberAnimation {
target: root.state
property: "yPosition"
to: 0
duration: Kirigami.Units.longDuration
easing.type: Easing.InBack
onFinished: {
root.state.status = stateClass.Inactive;
taskSwitcher.instantHide();
}
}
property var closeScaleAnim: NumberAnimation {
target: root
property: "closingScalingFactor"
to: 0.1
duration: Kirigami.Units.longDuration
easing.type: Easing.InQuad
onStopped: {
closingScalingFactor = 1;
}
}
property var openAppAnim: NumberAnimation {
target: root.state
property: "yPosition"
to: 0
duration: 300
easing.type: Easing.OutQuint
onFinished: {
root.state.status = stateClass.Inactive;
taskSwitcher.instantHide();
}
}
}