diff --git a/kwin/mobiletaskswitcher/qml/FlickContainer.qml b/kwin/mobiletaskswitcher/qml/FlickContainer.qml index 0bbc21ad..1914ef14 100644 --- a/kwin/mobiletaskswitcher/qml/FlickContainer.qml +++ b/kwin/mobiletaskswitcher/qml/FlickContainer.qml @@ -25,6 +25,9 @@ Flickable { // update position from horizontal flickable movement property real oldContentX onContentXChanged: { + // disable if animations are running to prevent bugs + if (taskSwitcherHelpers.currentlyBeingClosed) {return} + if (moving) { // TODO whenever flicking actually works this should probably be swapped with // a minimum velocity after which it should snap to the nearest task @@ -35,14 +38,17 @@ Flickable { } onMovementStarted: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} taskSwitcherHelpers.cancelAnimations(); } onMovementEnded: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} taskSwitcherHelpers.snapToNearestTaskWorkaround(movingRight); resetPosition(); } onFlickStarted: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} root.cancelFlick(); } onFlickEnded: { @@ -51,6 +57,7 @@ Flickable { } onDraggingChanged: { + if (taskSwitcherHelpers.currentlyBeingClosed) {return} if (dragging) { taskSwitcherHelpers.cancelAnimations(); } else { diff --git a/kwin/mobiletaskswitcher/qml/Task.qml b/kwin/mobiletaskswitcher/qml/Task.qml index fdd540ee..3cf935e0 100644 --- a/kwin/mobiletaskswitcher/qml/Task.qml +++ b/kwin/mobiletaskswitcher/qml/Task.qml @@ -25,6 +25,8 @@ Item { readonly property real dragOffset: -control.y + readonly property int currentIndex: model.index + // whether this task is being interacted with readonly property bool interactingActive: control.pressed && control.passedDragThreshold @@ -136,6 +138,7 @@ Item { to: 0 onFinished: { if (to != 0) { // close app + taskSwitcherHelpers.lastClosedTask = currentIndex; delegate.closeApp(); uncloseTimer.start(); } @@ -182,7 +185,10 @@ Item { icon.name: "window-close" icon.width: Kirigami.Units.iconSizes.smallMedium icon.height: Kirigami.Units.iconSizes.smallMedium - onClicked: delegate.closeApp() + onClicked: { + taskSwitcherHelpers.lastClosedTask = currentIndex; + delegate.closeApp() + } } } diff --git a/kwin/mobiletaskswitcher/qml/TaskList.qml b/kwin/mobiletaskswitcher/qml/TaskList.qml index 44168b63..601952bb 100644 --- a/kwin/mobiletaskswitcher/qml/TaskList.qml +++ b/kwin/mobiletaskswitcher/qml/TaskList.qml @@ -23,31 +23,22 @@ MouseArea { property int taskInteractingCount: 0 // account for system header and footer offset (center the preview image) - readonly property real taskY: { + readonly property real taskYBase: { let headerHeight = shellTopMargin; let footerHeight = shellBottomMargin; let diff = headerHeight - footerHeight; let baseY = (taskSwitcher.height / 2) - (taskSwitcherHelpers.taskHeight / 2) - (taskSwitcherHelpers.taskHeaderHeight / 2); - return baseY + diff / 2 - shellTopMargin - trackFingerYOffset; + return baseY + diff / 2 - shellTopMargin; } - readonly property real trackFingerYOffset: { - if (taskSwitcherHelpers.isScaleClamped) { - let openedPos = taskSwitcherHelpers.openedYPosition; - let directTrackingOffset = openedPos * 0.2 - if (taskSwitcherState.yPosition < openedPos + directTrackingOffset) { - // Allow the task list to move further up than the fully opened position - return taskSwitcherState.yPosition - openedPos; - } else { - // but make it more reluctant the further up it goes - let overDragProgress = (taskSwitcherState.yPosition - directTrackingOffset - openedPos) / openedPos; - // Base formula is 1-2.3^(-progress) which asymptotically approaches 1 - return (1 - Math.pow(2.3, -overDragProgress)) * openedPos + directTrackingOffset; - } - } else { - return 0; + readonly property real taskY: { + let trackFingerYOffsetClamped = 0; + if (taskSwitcherHelpers.isScaleClamped && (taskSwitcherState.wasInActiveTask || taskSwitcherHelpers.taskDrawerOpened)) { + trackFingerYOffsetClamped = taskSwitcherHelpers.trackFingerYOffset; } + + return taskYBase - trackFingerYOffsetClamped; } function getTaskAt(index) { @@ -82,6 +73,59 @@ MouseArea { } } + // the postition offset value for non-active tasks in the task drawer + // this value is normalized and is usually set to 0, 1, or 2 (larger the number, the further they are from the active task) + property real baseTaskOffset: 0 + + // the position offset value tracked to the touch x position when opening the task drawer from the home screen + // this allows the task switcher to move left and right without causing problems with the task offset animations + property real homeTouchPositionX: 0 + + // the touch x position value normalized between 0 and 1 + // the base value should be 0.5 for when it is on the home screen + readonly property real touchPosition: { + let value = 0.5 + if (taskSwitcherState.wasInActiveTask || taskSwitcherHelpers.taskDrawerOpened) { + // since the touch position starts at 0, we add half the window width and then divide it by the full width to normalize it + value = ((taskSwitcherHelpers.notHomeScreenState ? taskSwitcherState.touchXPosition : 0) + (taskSwitcherHelpers.windowWidth / 2)) / taskSwitcherHelpers.windowWidth + value = Math.min(1, Math.max(0, value)) + } + return value + } + + // dynamic task offset animation duration based off of the touch position and task scale + function dynamicDuration(left = true) { + // if the close animation is running, use the standard long duration time for consistency + let duration = Kirigami.Units.longDuration * 1.75 + if (!taskSwitcherHelpers.closeAnim.running && taskSwitcherHelpers.notHomeScreenState && taskSwitcherHelpers.gestureState != TaskSwitcherHelpers.GestureStates.HorizontalSwipe && !taskSwitcherHelpers.isInTaskScrubMode) { + // max out the scale at 1 so it is not too fast when opening the task drawer with the button + let taskScale = Math.min(taskSwitcherHelpers.currentScale, 1) + // change the duration based off of the touch position and task scale + duration = duration * ((left ? touchPosition : (1 - touchPosition)) + 1) + } + return duration + } + + // the duration is set to 0 if setOffsetDurationImmediately is true so we can skip the animation + readonly property real taskOffsetDurationLeft: setOffsetDurationImmediately ? 0 : dynamicDuration(true) + readonly property real taskOffsetDurationRight: setOffsetDurationImmediately ? 0 : dynamicDuration(false) + + // the easing type for the task offset animation + property int taskOffsetEasing: Easing.InOutQuart + + // skips the animation and sets the task offset value immediately (only should be set by 'setTaskOffsetValue') + property bool setOffsetDurationImmediately: true + + // set the task offset value with an animation unless specified otherwise + function setTaskOffsetValue(value, immediately = false, taskEasing = ((taskSwitcherHelpers.notHomeScreenState || (value != 0)) && (baseTaskOffset != taskSwitcherHelpers.taskOffsetValue)) ? Easing.InOutQuart : Easing.OutQuart) { + if (baseTaskOffset == value && immediately) { + baseTaskOffset = value + 1; + } + setOffsetDurationImmediately = immediately; + taskOffsetEasing = taskEasing; + baseTaskOffset = value; + } + transform: Scale { origin.x: root.width / 2 origin.y: root.height / 2 @@ -95,7 +139,8 @@ MouseArea { } onPressedChanged: { - if (!taskSwitcherState.currentlyBeingOpened && pressed) { + // disable if being closed or opened to prevent bugs + if (!taskSwitcherHelpers.currentlyBeingOpened && !taskSwitcherHelpers.currentlyBeingClosed && pressed) { // ensure animations aren't running when finger is pressed taskSwitcherHelpers.cancelAnimations(); } @@ -111,9 +156,10 @@ MouseArea { delegate: Task { id: task readonly property int currentIndex: model.index + readonly property bool isCurrentTask: currentIndex == taskSwitcherHelpers.currentDisplayTask // this is the x-position with respect to the list - property real listX: taskSwitcherHelpers.xPositionFromTaskIndex(currentIndex); + property real listX: taskSwitcherHelpers.xPositionFromTaskIndex(currentIndex) Behavior on listX { NumberAnimation { duration: Kirigami.Units.longDuration @@ -121,23 +167,62 @@ MouseArea { } } + // the animated task offset value (always will be 0 if it is the current task in the task drawer) + property real taskOffsetNormalized: (baseTaskOffset * ((taskSwitcherHelpers.taskDrawerOpened && isCurrentTask) ? 0 : 1)) + Behavior on taskOffsetNormalized { + NumberAnimation { + duration: taskSwitcherHelpers.currentDisplayTask > currentIndex ? taskOffsetDurationRight : taskOffsetDurationLeft + easing.type: taskOffsetEasing + easing.overshoot: 0.85 + } + } + + // calculate which direction to offset + readonly property real offsetDir: (taskSwitcherHelpers.currentDisplayTask > currentIndex ? -1 : 1) + + // check if this task should be offset + readonly property real isOffScreenOffset: { + let isOffsetBase = ((!taskSwitcherState.wasInActiveTask && !taskSwitcherHelpers.taskDrawerOpened) || !isCurrentTask) ? (taskSwitcherHelpers.isInTaskScrubMode && taskSwitcherHelpers.notHomeScreenState ? 0 : 1) : 0 + let isOffsetTaskDrawer = (currentIndex == taskSwitcherHelpers.currentDisplayTask ? 0 : 1) + return taskSwitcherHelpers.taskDrawerOpened ? isOffsetTaskDrawer : isOffsetBase + } + + // how far the task needs to travel to be off screen + readonly property real scrollXOffset: Math.abs(taskSwitcherHelpers.xPositionFromTaskIndex(taskSwitcherHelpers.currentDisplayTask) - (taskSwitcherState.xPosition + (taskSwitcherState.touchXPosition / taskSwitcherHelpers.currentScale))) + readonly property real offScreenOffset: (taskSwitcherHelpers.windowWidth * (((taskSwitcherHelpers.notHomeScreenState ? taskSwitcherState.touchXPosition : 0) * offsetDir * ((homeTouchPositionX == 0) ? 1 : 0) + (taskSwitcherHelpers.windowWidth / 2)) / taskSwitcherHelpers.windowWidth)); + + + // calculate the actual task offset + readonly property real taskOffset: ((offScreenOffset + (taskSwitcherHelpers.notHomeScreenState ? scrollXOffset : 0)) / taskSwitcherHelpers.currentScale - (homeTouchPositionX * (1 - Math.max(0, Math.min(1, (taskOffsetNormalized - taskSwitcherHelpers.peekOffsetValue) / (taskSwitcherHelpers.homeOffsetValue - taskSwitcherHelpers.peekOffsetValue)))))) * taskOffsetNormalized * isOffScreenOffset * offsetDir + + // extra resistance calculated for non-current task in the task drawer + readonly property real nonCurrentScaleResistance: ((isCurrentTask && taskSwitcherHelpers.notHomeScreenState) || taskSwitcherHelpers.fromButton) ? 0 : 1 - Math.min(taskSwitcherHelpers.currentScale, 1) + readonly property real nonCurrentScaleXOffset: (isCurrentTask && taskSwitcherHelpers.notHomeScreenState) ? 0 : ((taskSwitcherHelpers.taskWidth) * (scale - 1) * (currentIndex - taskSwitcherHelpers.currentDisplayTask)) + readonly property real nonCurrentXPositionResistance: (isCurrentTask && taskSwitcherHelpers.notHomeScreenState) ? 0 : (taskSwitcherHelpers.taskWidth * (scale - 1)) * (taskSwitcherHelpers.notHomeScreenState ? 0.25 : 1.0) * offsetDir + readonly property real nonCurrentYPositionResistance: (isCurrentTask && taskSwitcherHelpers.notHomeScreenState) ? 0 : ((taskSwitcher.height / 2)) * nonCurrentScaleResistance + // this is the actual displayed x-position on screen - x: listX + repeater.leftMargin - taskSwitcherState.xPosition - y: root.taskY + x: listX + repeater.leftMargin - taskSwitcherState.xPosition - taskOffset - nonCurrentScaleXOffset + nonCurrentXPositionResistance + y: ((taskSwitcherState.wasInActiveTask || taskSwitcherHelpers.taskDrawerOpened) ? root.taskY + nonCurrentYPositionResistance * 0.5: root.taskY / (taskSwitcherHelpers.fromButton ? 1 : (1 + taskOffsetNormalized * 0.075))) // add more resistance when not the current task + + scale: ((isCurrentTask && taskSwitcherHelpers.notHomeScreenState) || taskSwitcherHelpers.fromButton) ? 1 : (1 + nonCurrentScaleResistance) * (1 + taskOffsetNormalized * 0.075) // add more resistance when not the current task and resist even further if the task is offset // ensure current task is above others - z: taskSwitcherState.currentTaskIndex === currentIndex ? 1 : 0 + z: isCurrentTask ? 1 : 0 // only show header once task switcher is opened showHeader: !taskSwitcherState.gestureInProgress && !taskSwitcherHelpers.currentlyBeingClosed && !taskSwitcherHelpers.isInTaskScrubMode // darken effect as task gets away from the center of the screen darken: { - const distFromCentreProgress = Math.abs(x - repeater.leftMargin) / taskSwitcherHelpers.taskWidth; - const upperBoundAdjust = Math.min(0.5, distFromCentreProgress) - 0.2; + const distFromCentreProgress = Math.abs(x - repeater.leftMargin - (taskSwitcherHelpers.currentlyBeingOpened || taskSwitcherHelpers.currentlyBeingClosed ? (taskSwitcherHelpers.xPositionFromTaskIndex(taskSwitcherHelpers.currentDisplayTask)) - taskSwitcherState.xPosition : 0)) / taskSwitcherHelpers.taskWidth; + const upperBoundAdjust = Math.min(0.25, distFromCentreProgress) - 0.2; return Math.max(0, upperBoundAdjust); } + // fade out as the task closes + opacity: taskSwitcherHelpers.closingFactor + // update count of tasks being interacted with, so we know whether we are in a swipe up action onInteractingActiveChanged: { let offset = interactingActive ? 1 : -1; diff --git a/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml b/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml index 0e841419..cdf2a51b 100644 --- a/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml +++ b/kwin/mobiletaskswitcher/qml/TaskSwitcher.qml @@ -53,11 +53,26 @@ FocusScope { // keep track of task list events property int oldTasksCount: tasksCount onTasksCountChanged: { + // we need to subtract 1 from the current index when the closed task index is smaller + // this is because this part of the list has been shifted down by 1 when the closed task was removed. + if (taskSwitcherHelpers.lastClosedTask < state.currentTaskIndex) { + state.currentTaskIndex -= 1; + + // animated at the same speed as the task x position in the TaskList so that the task appears not to move from the perspective of the user. + taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex, Kirigami.Units.longDuration, Easing.InOutQuad); + taskSwitcherHelpers.lastClosedTask = -1; + } + if (tasksCount === 0 && oldTasksCount !== 0) { hide(); - } else if (tasksCount < oldTasksCount && state.currentTaskIndex >= tasksCount) { - // if the user is on the last task, and it is closed, scroll left - taskSwitcherHelpers.animateGoToTaskIndex(tasksCount - 1, Kirigami.Units.longDuration); + } else if (tasksCount < oldTasksCount) { + if (state.currentTaskIndex < 0) { + // if the user is on the frist task, and it is closed, scroll right + taskSwitcherHelpers.animateGoToTaskIndex(0, Kirigami.Units.longDuration); + } else if (state.currentTaskIndex >= tasksCount) { + // if the user is on the last task, and it is closed, scroll left + taskSwitcherHelpers.animateGoToTaskIndex(tasksCount - 1, Kirigami.Units.longDuration); + } } oldTasksCount = tasksCount; @@ -66,20 +81,60 @@ FocusScope { Keys.onEscapePressed: hide(); Component.onCompleted: { + initialSetup(); + } + + function initialSetup() { + taskSwitcherHelpers.cancelAnimations(); state.updateWasInActiveTask(KWinComponents.Workspace.activeWindow); - // task index from last time using the switcher + // ensure the task drawer is not opened and reset values to defaults + taskSwitcherHelpers.reachedHeightThreshold = false; + taskSwitcherHelpers.gestureState = TaskSwitcherHelpers.GestureStates.Undecided; + taskSwitcherHelpers.isInTaskScrubMode = false; + + taskSwitcherHelpers.hasVibrated = false; + + taskSwitcherHelpers.closingFactor = 1; + + taskSwitcherHelpers.taskSwitchCanLaunch = false; + taskSwitchCanLaunchTimer.restart() + + taskList.taskOffsetEasing = Easing.InOutQuart; + taskList.homeTouchPositionX = 0; + + backgroundColorOpacityAn.enabled = false; + backgroundColorOpacity = state.wasInActiveTask ? 1 : 0; + backgroundColorOpacityAn.enabled = true; + + // reset the offset to have the task drawer off screen + taskList.setTaskOffsetValue(state.wasInActiveTask ? taskSwitcherHelpers.taskOffsetValue : taskSwitcherHelpers.homeOffsetValue, true); + + // task index from the last time using the switcher state.initialTaskIndex = Math.min(state.currentTaskIndex, tasksCount - 1); if (state.wasInActiveTask) { // if we were in an active task instead set initial task index to the position of that task state.initialTaskIndex = taskSwitcherHelpers.getTaskIndexFromWindow(KWinComponents.Workspace.activeWindow); + } else { + // reset the task index to the start if on home screen + state.initialTaskIndex = 0 } + state.currentTaskIndex = state.initialTaskIndex + + taskSwitcherHelpers.currentDisplayTask = state.currentTaskIndex; taskSwitcherHelpers.goToTaskIndex(state.initialTaskIndex); taskList.minimizeAll(); // fully open the switcher (if this is a button press, not gesture) if (!root.state.gestureInProgress) { + taskSwitcherHelpers.fromButton = true; + if (state.wasInActiveTask) { + taskList.setTaskOffsetValue(0, true); + } else { + taskList.setTaskOffsetValue(0); + } + backgroundColorOpacity = 1; taskSwitcherHelpers.open(); } } @@ -102,8 +157,9 @@ FocusScope { // task scrub mode allows scrubbing through a number of tasks with a mostly horizontal motion function taskScrubMode() { + taskList.setTaskOffsetValue(0, false, Easing.OutQuart); if (!taskSwitcherHelpers.isInTaskScrubMode) { - taskSwitcherHelpers.isInTaskScrubMode = true; + backgroundColorOpacity = 1; taskSwitcherHelpers.cancelAnimations(); taskSwitcherHelpers.open(); if (!taskSwitcherHelpers.hasVibrated) { @@ -111,43 +167,140 @@ FocusScope { haptics.buttonVibrate(); taskSwitcherHelpers.hasVibrated = true; } - } - let newTaskIndex = Math.max(0, Math.min(tasksCount - 1, Math.floor(state.touchXPosition / taskSwitcherHelpers.taskScrubDistance) + state.initialTaskIndex)); - if (newTaskIndex != state.currentTaskIndex) { + // TODO this makes sense, but makes scrub mode feel a bit weird + // improve trigger distance logic for task scrub mode to fix + let newTaskIndex = Math.max(0, Math.min(tasksCount - 1, Math.floor(state.touchXPosition / taskSwitcherHelpers.taskScrubDistance) + state.initialTaskIndex - (state.wasInActiveTask ? 0 : 1))); + if (newTaskIndex != state.currentTaskIndex || !taskSwitcherHelpers.isInTaskScrubMode) { taskSwitcherHelpers.animateGoToTaskIndex(newTaskIndex); + taskSwitcherHelpers.isInTaskScrubMode = true; } } function onTouchPositionChanged() { + let unmodifiedYposition = Math.abs(state.touchYPosition) if (taskSwitcherHelpers.isInTaskScrubMode || // once in scrub mode, let's not allow to go out, that can result in inconsistent UX (Math.abs(state.xVelocity) > Math.abs(state.yVelocity) * 3 && // gesture needs to be almost completely horizontal - Math.abs(state.xVelocity) < 2.5 && // and not with a fast flick TODO! evaluate whether to keep this, it's kinda awkward - Math.abs(state.touchXPosition) > taskSwitcherHelpers.taskScrubDistance * 0.95 && // and have moved far enough sideways - state.yPosition < taskSwitcherHelpers.undoYThreshold && // and be close to the screen edge - tasksCount > 1 // and there needs to be more than one task open + Math.abs(state.xVelocity) < 2.5 && // and not with a fast flick TODO! evaluate whether to keep this, it's kinda awkward + Math.abs(state.touchXPosition) > taskSwitcherHelpers.taskScrubDistance * 0.95 && // and have moved far enough sideways + unmodifiedYposition < Kirigami.Units.largeSpacing * 2 && // and be close to the screen edge + tasksCount > 0 && // and there needs to be more than none task open + !taskSwitcherHelpers.taskDrawerOpened // and the task drawer must not be open )) { taskScrubMode(); } else { - if (state.status == TaskSwitcherData.TaskSwitcherState.Active) { - // task switcher is already open - // TODO add some sort of feedback for dismissing task switcher (maybe opacity reduction?) - return; + if (taskSwitcherHelpers.currentlyBeingClosed) { + // if the task switch is still open but playing the close animation + // setup some values and return to the initial setup so that the user can always navigate with no down time + state.wasInActiveTask = taskSwitcherHelpers.openAppAnim.running ? true : false + taskList.setTaskOffsetValue(state.wasInActiveTask ? taskSwitcherHelpers.taskOffsetValue : taskSwitcherHelpers.homeOffsetValue, true); + state.status = !state.wasInActiveTask ? (taskSwitcherHelpers.openAppAnim.closeAnim && !taskSwitcherHelpers.taskDrawerWillOpen ? TaskSwitcherData.TaskSwitcherState.Active : TaskSwitcherData.TaskSwitcherState.Inactive) : TaskSwitcherData.TaskSwitcherState.Inactive + initialSetup(); + } else if (taskSwitcherHelpers.openAnim.running) { + taskSwitcherHelpers.cancelAnimations(); + state.status = taskSwitcherHelpers.stateClass.Active; } - state.yPosition = Math.abs(state.touchYPosition); - state.xPosition = taskSwitcherHelpers.xPositionFromTaskIndex(state.initialTaskIndex) - state.touchXPosition; + + state.yPosition = unmodifiedYposition + (taskSwitcherHelpers.taskDrawerOpened || !state.wasInActiveTask ? taskSwitcherHelpers.openedYPosition : 0); + + let newXPosition = taskSwitcherHelpers.xPositionFromTaskIndex(state.initialTaskIndex); + if (taskSwitcherHelpers.notHomeScreenState && !taskSwitcherHelpers.currentlyBeingClosed) { + newXPosition = newXPosition - (state.touchXPosition / taskSwitcherHelpers.currentScale); + } + state.xPosition = newXPosition; + + // allows the user to move the task drawer left and right when on the home screen + taskList.homeTouchPositionX = taskSwitcherHelpers.notHomeScreenState ? 0 : (state.touchXPosition * 0.35); + + // dynamically update the task switcher state based off of the touch position and velocity + updateTaskSwitcherState() } } - // actions on an upwards flick - function upwardsFlick() { - if (state.wasInActiveTask) { - // go to homescreen if we were in an active task - taskSwitcherHelpers.close(); + function updateTaskSwitcherState() { + let unmodifiedYposition = Math.abs(state.touchYPosition) + + // if the touch is above heightThreshold, set reachedHeightThreshold to true + if (unmodifiedYposition > taskSwitcherHelpers.heightThreshold) { + // set reachedHeightThreshold when above or below two separate points to helps prevent flickering when the task switcher moves in and out of view + taskSwitcherHelpers.reachedHeightThreshold = true; + backgroundColorOpacity = taskSwitcherHelpers.notHomeScreenState ? 0 : 1; + } else if (unmodifiedYposition > taskSwitcherHelpers.undoYThreshold) { + backgroundColorOpacity = 1; } else { - // or normally open task switcher if we were on the homescreen already - taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex); - taskSwitcherHelpers.open(); + backgroundColorOpacity = taskSwitcherHelpers.notHomeScreenState ? 1 : 0; + } + + if (state.totalSquaredVelocity > state.flickVelocityThreshold) { + // flick + // ratio between y and x velocity as threshold between vertical and horizontal flick + let xyVelocityRatio = 1.7; // with 1.7 swipes up to ~60° from horizontal are counted as horizontal + if (state.yVelocity > Math.abs(state.xVelocity) * xyVelocityRatio) { + // downwards flick + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Undecided); + if (unmodifiedYposition < taskSwitcherHelpers.undoYThreshold) { + taskList.setTaskOffsetValue(taskSwitcherHelpers.notHomeScreenState ? 0 : taskSwitcherHelpers.homeOffsetValue); + } + } else if (-state.yVelocity > Math.abs(state.xVelocity) * xyVelocityRatio || (taskSwitcherHelpers.reachedHeightThreshold && taskSwitcherHelpers.notHomeScreenState)) { + // upwards flick or if the touch is above heightThreshold + if (taskSwitcherHelpers.notHomeScreenState) { + // if in app or task switcher, go home + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home); + if (taskSwitcherHelpers.reachedHeightThreshold) { + taskList.setTaskOffsetValue(taskSwitcherHelpers.taskOffsetValue); + } + } else if (unmodifiedYposition > taskSwitcherHelpers.undoYThreshold) { + // else, keep the task switcher in view + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher); + taskList.setTaskOffsetValue(taskSwitcherHelpers.peekOffsetValue); + } + } else if (!taskSwitcherHelpers.reachedHeightThreshold && !taskSwitcherHelpers.isInTaskScrubMode) { + // sideways flick + if (taskSwitcherHelpers.notHomeScreenState) { + taskList.setTaskOffsetValue(0, unmodifiedYposition < taskSwitcherHelpers.openedYPosition ? true : false); + } + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.HorizontalSwipe); + } + } else { + if (unmodifiedYposition > taskSwitcherHelpers.undoYThreshold) { + // if just moveing out of undoYThreshold, set the state to home + if (taskSwitcherHelpers.gestureState < TaskSwitcherHelpers.GestureStates.TaskSwitcher) { + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home) + } + // if the touch is above heightThreshold, it will retrun home + if (unmodifiedYposition > taskSwitcherHelpers.heightThreshold) { + taskSwitcherHelpers.hasVibrated = true; + if (taskSwitcherHelpers.notHomeScreenState) { + // move the task switcher out of view + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Home); + taskList.setTaskOffsetValue(taskSwitcherHelpers.taskOffsetValue); + } else { + // keep the task switcher in view when above heightThreshold and from home + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher); + taskList.setTaskOffsetValue(taskSwitcherHelpers.peekOffsetValue); + } + // minus largeSpacing from the heightThreshold to help prevent flickering when the task switcher moves in and out of view + } else if ((unmodifiedYposition < taskSwitcherHelpers.heightThreshold - Kirigami.Units.largeSpacing) || taskSwitcherHelpers.reachedHeightThreshold == false) { + // set reachedHeightThreshold when above or below two separate points to helps prevent flickering when the task switcher moves in and out of view + taskSwitcherHelpers.reachedHeightThreshold = false; + if (state.totalSquaredVelocity < state.flickVelocityThreshold && taskSwitcherHelpers.taskSwitchCanLaunch) { + // if velocity is small enough, move the task switcher into view + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher); + taskList.setTaskOffsetValue(taskSwitcherHelpers.notHomeScreenState ? 0 : taskSwitcherHelpers.peekOffsetValue); + } + } + } else { + // if under the undo threshold, it will go back to the task switcher if it is open + if (taskSwitcherHelpers.taskDrawerOpened) { + taskSwitcherHelpers.reachedHeightThreshold = false; + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.TaskSwitcher) + taskList.setTaskOffsetValue(0); + } else { + taskSwitcherHelpers.reachedHeightThreshold = false; + setTaskDrawerState(TaskSwitcherHelpers.GestureStates.Undecided) + taskList.setTaskOffsetValue(taskSwitcherHelpers.notHomeScreenState ? 0 : taskSwitcherHelpers.homeOffsetValue); + } + } } } @@ -162,26 +315,43 @@ FocusScope { // diagonal quick switch gesture logic function quickSwitch() { // should "quick switch" to adjacent app in task switcher, but only if we were in an app before + let unmodifiedYposition = Math.abs(state.touchYPosition) let newIndex = state.currentTaskIndex; let shouldSwitch = false; - if (state.xVelocity > 0 && state.wasInActiveTask) { - // flick to the right, go to app to the left - newIndex = state.currentTaskIndex + 1; + if (state.xVelocity > 0) { + if (taskSwitcherHelpers.notHomeScreenState) { + // flick to the right, go to the app on the left + newIndex = state.currentTaskIndex + 1; + } if (newIndex < tasksCount) { // switch only if flick doesn't go over end of list shouldSwitch = true; } - } else if (state.xVelocity < 0 && state.wasInActiveTask) { - // flick to the left, go to app to the right - newIndex = state.currentTaskIndex - 1; - if (newIndex >= 0) { - // switch only if flick doesn't go over end of list - shouldSwitch = true; + } else if (state.xVelocity < 0) { + if (taskSwitcherHelpers.notHomeScreenState) { + // flick to the left, go to app to the right + newIndex = state.currentTaskIndex - 1; + if (newIndex >= 0) { + // switch only if flick doesn't go over end of list + shouldSwitch = true; + } + } else { + // flick to the left on the home screen, dismiss the gesture + taskSwitcherHelpers.close(); + retrun; } } if (shouldSwitch) { - let appAtNewIndex = taskList.getTaskAt(newIndex).window; - taskSwitcherHelpers.openApp(newIndex, appAtNewIndex, Kirigami.Units.longDuration * 4, Easing.OutExpo); + if (!taskSwitcherHelpers.taskDrawerOpened && unmodifiedYposition < taskSwitcherHelpers.openedYPosition) { + // if in a app, switch it to the new task when it is under the openedYPosition + taskList.setTaskOffsetValue(0, unmodifiedYposition < taskSwitcherHelpers.openedYPosition && taskSwitcherHelpers.notHomeScreenState); + let appAtNewIndex = taskList.getTaskAt(newIndex).window; + taskSwitcherHelpers.openApp(newIndex, appAtNewIndex, Kirigami.Units.longDuration * 4, Easing.OutExpo); + } else { + // if already in the task switcher or above the openedYPosition, only change the focus to the new task + taskSwitcherHelpers.animateGoToTaskIndex(newIndex); + taskSwitcherHelpers.open(); + } } else { // if not switching, just open task switcher taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex); @@ -191,68 +361,55 @@ FocusScope { // Logic for deciding how to handle the end of a gesture input function onGestureInProgressChanged() { + taskSwitcherHelpers.fromButton = false; if (state.gestureInProgress) { + taskSwitcherHelpers.currentDisplayTask = state.currentTaskIndex; return; } - if (state.status == TaskSwitcherData.TaskSwitcherState.Active) { - if (taskSwitcherHelpers.isInTaskScrubMode) { - // TODO! do we want to handle upwards flick to dismiss in task scrub mode? - // TODO do we want to show a list of thumbnails in task scrub mode? - taskSwitcherHelpers.openApp(state.currentTaskIndex, taskList.getTaskAt(state.currentTaskIndex).window); - } else if (state.yPosition > taskSwitcherHelpers.undoYThreshold) { - // close task switcher if it was already open but only if swipe was higher than the undo threshold - taskSwitcherHelpers.close(); - return; - } - } else if (state.status == TaskSwitcherData.TaskSwitcherState.Inactive) { - if (state.totalSquaredVelocity > state.flickVelocityThreshold) { - // flick - // ratio between y and x velocity as threshold between vertical and horizontal flick - let xyVelocityRatio = 1.7; // with 1.7 swipes up to ~60° from horizontal are counted as horizontal - if (-state.yVelocity > Math.abs(state.xVelocity) * xyVelocityRatio) { - upwardsFlick(); - } else if (state.yVelocity > Math.abs(state.xVelocity) * xyVelocityRatio) { - // downwards flick - returnToApp(); - } else { - // sideways flick - quickSwitch(); - } + if (taskList.count === 0) { + // dismiss the gesture if the task list is empty + taskSwitcherHelpers.close(); + } if (taskSwitcherHelpers.isInTaskScrubMode) { + // TODO! do we want to handle upwards flick to dismiss in task scrub mode? + // TODO do we want to show a list of thumbnails in task scrub mode? + let unmodifiedYposition = Math.abs(state.touchYPosition) + backgroundColorOpacity = 1; + if (taskSwitcherHelpers.taskDrawerOpened || unmodifiedYposition > taskSwitcherHelpers.undoYThreshold) { + taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex); + taskSwitcherHelpers.open(); + taskSwitcherHelpers.isInTaskScrubMode = false; } else { - // no flick - if (state.yPosition > taskSwitcherHelpers.undoYThreshold) { - // normal task switcher open - taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex); - taskSwitcherHelpers.open(); - } else { - // no flick and not enough activation to go to task switcher - if (state.wasInActiveTask) { - returnToApp(); - } else { - // do open switcher in case we were on homescreen before - taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex); - state.yPosition = taskSwitcherHelpers.openedYPosition; - taskSwitcherHelpers.open(); - } - - } + taskSwitcherHelpers.openApp(state.currentTaskIndex, taskList.getTaskAt(state.currentTaskIndex).window); } + } else if (taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.Undecided) { + if (taskSwitcherHelpers.taskDrawerOpened) { + // if in the task switcher, return to it + taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex); + taskSwitcherHelpers.open(); + } else if (state.wasInActiveTask) { + // if inside a app, return to it + returnToApp(); + } else { + // else dismiss the gesture + taskSwitcherHelpers.close(); + } + } else if (taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.HorizontalSwipe) { + // sideways flick + backgroundColorOpacity = 1; + quickSwitch(); + } else if (taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.TaskSwitcher) { + // open the task drawer + backgroundColorOpacity = 1; + taskSwitcherHelpers.animateGoToTaskIndex(state.currentTaskIndex); + taskSwitcherHelpers.open(); + } else if (taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.Home) { + taskSwitcherHelpers.close(); } } function onVelocityChanged() { - if (!taskSwitcherHelpers.hasVibrated) { - if (!state.wasInActiveTask || - (state.wasInActiveTask && - state.yPosition > taskSwitcherHelpers.undoYThreshold && - state.totalSquaredVelocity < state.flickVelocityThreshold)) { - // Haptic feedback when conditions are met for the task switcher to open - haptics.buttonVibrate(); - taskSwitcherHelpers.hasVibrated = true; - } - } } function onXPositionChanged() { @@ -260,6 +417,26 @@ FocusScope { } } + // kind of a hack, but this prevents the gesture from immediately activting the task switcher when it is not supposed to + Timer { + id: taskSwitchCanLaunchTimer + interval: 1; running: true; repeat: false + onTriggered: taskSwitcherHelpers.taskSwitchCanLaunch = true; + } + + function setTaskDrawerState(value) { + if (taskSwitcherHelpers.gestureState != TaskSwitcherHelpers.GestureStates.TaskSwitcher && value == TaskSwitcherHelpers.GestureStates.TaskSwitcher) { + // vibrate only if switching to task drawer + if (!taskSwitcherHelpers.hasVibrated) { + // Haptic feedback when the task scrub mode engages + haptics.buttonVibrate(); + taskSwitcherHelpers.hasVibrated = true; + } + + } + taskSwitcherHelpers.gestureState = value; + } + // view of the desktop background KWinComponents.DesktopBackground { id: backgroundItem @@ -275,12 +452,16 @@ FocusScope { opacity: container.opacity color: { - // animate background colour only if we are *not* opening from the homescreen - if (state.wasInActiveTask || !state.currentlyBeingOpened) { - return Qt.rgba(0, 0, 0, 0.6); - } else { - return Qt.rgba(0, 0, 0, 0.6 * Math.min(1, state.yPosition / state.openedYPosition)); - } + return Qt.rgba(0, 0, 0, 0.6 * taskSwitcherHelpers.closingFactor * backgroundColorOpacity); + } + } + + // animate the background opacity based off of the state. + property real backgroundColorOpacity: 1 + Behavior on backgroundColorOpacity { + id: backgroundColorOpacityAn + NumberAnimation { + duration: Kirigami.Units.longDuration } } @@ -413,7 +594,11 @@ FocusScope { ColumnLayout { id: placeholder spacing: Kirigami.Units.gridUnit - opacity: (root.tasksCount === 0 && !taskSwitcherHelpers.currentlyBeingClosed) ? 0.9 : 0 + + opacity: { + let baseOpacity = ((root.tasksCount === 0 && !taskSwitcherHelpers.currentlyBeingClosed) ? 0.9 : 0); + return taskSwitcherHelpers.gestureState == TaskSwitcherHelpers.GestureStates.TaskSwitcher ? baseOpacity : 0; + } Behavior on opacity { NumberAnimation { duration: 500 } } anchors.centerIn: parent @@ -549,35 +734,34 @@ FocusScope { x: flickable.contentX width: flickable.width height: flickable.height + z: 1 + } - PlasmaComponents.ToolButton { - id: closeAllButton - property bool closeRequested: false - visible: root.tasksCount !== 0 && !taskSwitcherHelpers.isInTaskScrubMode + PlasmaComponents.ToolButton { + id: closeAllButton + property bool closeRequested: false + visible: root.tasksCount !== 0 && !taskSwitcherHelpers.isInTaskScrubMode + enabled: !taskSwitcherHelpers.currentlyBeingClosed && !root.state.gestureInProgress - anchors { - bottom: parent.bottom - bottomMargin: (taskList.taskY + taskList.trackFingerYOffset) / 2 - horizontalCenter: parent.horizontalCenter - } + anchors { + bottom: parent.bottom + bottomMargin: (taskList.taskYBase) * 0.75 + horizontalCenter: taskList.horizontalCenter + } - Kirigami.Theme.colorSet: Kirigami.Theme.Complementary - Kirigami.Theme.inherit: false + opacity: (taskSwitcherHelpers.currentlyBeingClosed || root.state.gestureInProgress || !taskSwitcherHelpers.taskDrawerOpened) ? 0.0 : 1.0 + Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } } - opacity: (taskSwitcherHelpers.currentlyBeingClosed) ? 0.0 : 1.0 - Behavior on opacity { NumberAnimation { duration: Kirigami.Units.shortDuration } } + icon.name: "edit-clear-history" + font.bold: true - icon.name: "edit-clear-history" - font.bold: true + text: closeRequested ? i18n("Confirm Close All") : i18n("Close All") - text: closeRequested ? i18n("Confirm Close All") : i18n("Close All") - - onClicked: { - if (closeRequested) { - taskList.closeAll(); - } else { - closeRequested = true; - } + onClicked: { + if (closeRequested) { + taskList.closeAll(); + } else { + closeRequested = true; } } } diff --git a/kwin/mobiletaskswitcher/qml/TaskSwitcherHelpers.qml b/kwin/mobiletaskswitcher/qml/TaskSwitcherHelpers.qml index b63d7b0f..34ab74d8 100644 --- a/kwin/mobiletaskswitcher/qml/TaskSwitcherHelpers.qml +++ b/kwin/mobiletaskswitcher/qml/TaskSwitcherHelpers.qml @@ -4,6 +4,7 @@ import QtQuick 2.15 import org.kde.kirigami 2.20 as Kirigami +import org.kde.private.mobileshell.taskswitcher 1.0 as TaskSwitcherData import org.kde.kwin 3.0 as KWinComponents @@ -14,11 +15,27 @@ QtObject { id: root // TaskSwitcher item component - // We assume that the taskSwitcher the size of the entire screen. + // We assume that the taskSwitcher is the size of the entire screen. required property var taskSwitcher property var state: taskSwitcher.state required property var stateClass + // task switcher peek and pop setting for when it is toggled from the home screen + readonly property real peekOffsetValue: 1.85 + readonly property real homeOffsetValue: 2.6 + readonly property real taskOffsetValue: 1.5 + + // the index of the last task to be closed + // this will get reset to -1 in TaskSwitcher.qml when the TasksCount changes + property int lastClosedTask: -1 + + // this will only gets set to the currentTaskIndex when the gesture starts + // this helps remove visual glitches as the gesture animations play out and the currentTaskIndex changes + property int currentDisplayTask: state.currentTaskIndex + + // how much the the task will resist shrinking + // value of 1 will match the y position resistance one to one + readonly property real scaleResistance: 0.5 // direction of the movement readonly property bool gestureMovingRight: state.xVelocity > 0 @@ -33,10 +50,37 @@ QtObject { // yPosition threshold below which opening the task switcher should be undone and returned to the previously active task readonly property real undoYThreshold: openedYPosition / 2 + // the height threshold where if the yPosition is above this value the task switch will return home + readonly property real heightThreshold: windowHeight * 0.55 + + // whether the switcher is opened or not + readonly property bool taskDrawerOpened: state.status == TaskSwitcherData.TaskSwitcherState.Active + + // This is true when the task drawer is already opened or if within an app + readonly property bool notHomeScreenState: state.wasInActiveTask || taskDrawerOpened + + // set to true if the taskSwitcher is opened by the navbar button + property bool fromButton: false + + // gets set to true after 1 milliseconds + property bool taskSwitchCanLaunch: false + // 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 + // The current gesture state to decide what will happpen when it is completed + enum GestureStates { + Undecided, + HorizontalSwipe, + TaskSwitcher, + Home + } + property int gestureState: TaskSwitcherHelpers.GestureStates.Undecided + + // if the touch has reached the height threshold + property bool reachedHeightThreshold: false + // made as variables to keep x anim in task list and task scrub icon list in sync property int xAnimDuration: Kirigami.Units.longDuration * 2 property int xAnimEasingType: Easing.OutExpo @@ -60,6 +104,24 @@ QtObject { // height of the task preview header readonly property real taskHeaderHeight: Kirigami.Units.gridUnit * 2 + Kirigami.Units.smallSpacing * 2 + // finger position y with resistance + readonly property real trackFingerYOffset: { + if (taskSwitcherHelpers.isScaleClamped) { + let directTrackingOffset = openedYPosition * 0.2 + if (root.state.yPosition < openedYPosition + directTrackingOffset) { + // Allow the task list to move further up than the fully opened position + return root.state.yPosition - openedYPosition; + } else { + // but make it more reluctant the further up it goes + let overDragProgress = (root.state.yPosition - directTrackingOffset - openedYPosition) / openedYPosition; + // Base formula is 1-2.3^(-progress) which asymptotically approaches 1 + return (1 - Math.pow(2.3, -overDragProgress)) * openedYPosition + directTrackingOffset; + } + } else { + return 0; + } + } + // 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: { @@ -75,26 +137,25 @@ QtObject { } } - // scaling factor during closing of the switcher + // the closing factor during the closing of the switcher + property real closingFactor: 1 + + // scaling factor during the 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 subtract = (maxScale - 1) * ((Math.min(root.state.yPosition, openedYPosition) + trackFingerYOffset * scaleResistance) / openedYPosition) 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) { + if (closingScalingFactor < 1 && (root.state.wasInActiveTask || root.taskDrawerOpened)) { return closingScalingFactor; } - // animate scale only if we are *not* opening from the homescreen - if (root.state.wasInActiveTask || !root.state.gestureInProgress) { - return finalScale; - } - return 1; + return finalScale; } readonly property bool isScaleClamped: root.state.yPosition > openedYPosition @@ -106,10 +167,10 @@ QtObject { // cancel all animated moving, as another flick source is taking over signal cancelAnimations() onCancelAnimations: { - openAnim.stop(); openAppAnim.stop(); closeAnim.stop(); closeScaleAnim.stop(); + closeFactorAnim.stop(); xAnim.stop(); } @@ -130,22 +191,35 @@ QtObject { // 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(); + // only set if not gesture currently in progress to prevent glitching + if (!(state.gestureInProgress || root.closeAnim.running || root.openAppAnim.running) || root.isInTaskScrubMode) { + root.state.currentTaskIndex = getTaskIndexFromXPosition(); + } } function open() { + root.gestureState = TaskSwitcherHelpers.GestureStates.TaskSwitcher; openAnim.restart(); + + // update the task offset position + taskList.setTaskOffsetValue(0, false, Easing.OutQuart); } function close() { + // update the task offset position + taskList.setTaskOffsetValue(homeOffsetValue + 0.25, false, Easing.Linear); + + root.gestureState = TaskSwitcherHelpers.GestureStates.Undecided; cancelAnimations(); closingScalingFactor = currentScale; closeAnim.restart(); closeScaleAnim.restart(); + closeFactorAnim.restart(); } function openApp(index, window, duration = Kirigami.Units.shortDuration, horizontalEasing = Easing.OutBack) { // cancel any opening animations ongoing + openAnim.stop(); cancelAnimations(); animateGoToTaskIndex(index, duration); @@ -167,7 +241,7 @@ QtObject { function animateGoToTaskIndex(index, duration = Kirigami.Units.longDuration * 2, easing = Easing.OutExpo) { xAnimDuration = duration; xAnimEasingType = easing; - xAnim.to = xPositionFromTaskIndex(index); + xAnim.to = xPositionFromTaskIndex(index) - (gestureState == TaskSwitcherHelpers.GestureStates.HorizontalSwipe && !state.gestureInProgress && notHomeScreenState ? taskSpacing / 2 : 0); xAnim.restart(); } @@ -232,10 +306,12 @@ QtObject { property: "yPosition" to: openedYPosition duration: 250 - easing.type: Easing.OutExpo + easing.type: Easing.OutQuart onFinished: { - root.state.status = stateClass.Active; + if (!isInTaskScrubMode) { + root.state.status = stateClass.Active; + } } } @@ -267,6 +343,14 @@ QtObject { } } + property var closeFactorAnim: NumberAnimation { + target: root + property: "closingFactor" + to: 0 + duration: Kirigami.Units.longDuration + easing.type: Easing.InQuad + } + property var openAppAnim: NumberAnimation { target: root.state property: "yPosition"