1 /* <lambda>null2 * Copyright (C) 2025 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.launcher3.uioverrides.touchcontrollers 17 18 import android.content.Context 19 import android.graphics.Rect 20 import android.view.MotionEvent 21 import androidx.dynamicanimation.animation.SpringAnimation 22 import com.android.app.animation.Interpolators.DECELERATE 23 import com.android.app.animation.Interpolators.LINEAR 24 import com.android.launcher3.AbstractFloatingView 25 import com.android.launcher3.R 26 import com.android.launcher3.Utilities.EDGE_NAV_BAR 27 import com.android.launcher3.Utilities.boundToRange 28 import com.android.launcher3.Utilities.debugLog 29 import com.android.launcher3.Utilities.isRtl 30 import com.android.launcher3.Utilities.mapToRange 31 import com.android.launcher3.touch.SingleAxisSwipeDetector 32 import com.android.launcher3.util.MSDLPlayerWrapper 33 import com.android.launcher3.util.TouchController 34 import com.android.quickstep.views.RecentsView 35 import com.android.quickstep.views.RecentsView.RECENTS_SCALE_PROPERTY 36 import com.android.quickstep.views.RecentsViewContainer 37 import com.android.quickstep.views.TaskView 38 import com.google.android.msdl.data.model.MSDLToken 39 import kotlin.math.abs 40 41 /** Touch controller for handling task view card dismiss swipes */ 42 class TaskViewDismissTouchController<CONTAINER>( 43 private val container: CONTAINER, 44 private val taskViewRecentsTouchContext: TaskViewRecentsTouchContext, 45 ) : TouchController, SingleAxisSwipeDetector.Listener where 46 CONTAINER : Context, 47 CONTAINER : RecentsViewContainer { 48 private val recentsView: RecentsView<*, *> = container.getOverviewPanel() 49 private val detector: SingleAxisSwipeDetector = 50 SingleAxisSwipeDetector( 51 container as Context, 52 this, 53 recentsView.pagedOrientationHandler.upDownSwipeDirection, 54 ) 55 private val isRtl = isRtl(container.resources) 56 private val upDirection: Int = recentsView.pagedOrientationHandler.getUpDirection(isRtl) 57 58 private val tempTaskThumbnailBounds = Rect() 59 60 private var taskBeingDragged: TaskView? = null 61 private var springAnimation: SpringAnimation? = null 62 private var dismissLength: Int = 0 63 private var verticalFactor: Int = 0 64 private var hasDismissThresholdHapticRun = false 65 private var initialDisplacement: Float = 0f 66 private var recentsScaleAnimation: SpringAnimation? = null 67 private var isBlockedDuringDismissal = false 68 69 private fun canInterceptTouch(ev: MotionEvent): Boolean = 70 when { 71 // Don't intercept swipes on the nav bar, as user might be trying to go home during a 72 // task dismiss animation. 73 (ev.edgeFlags and EDGE_NAV_BAR) != 0 -> { 74 debugLog(TAG, "Not intercepting edge swipe on nav bar.") 75 false 76 } 77 78 // Floating views that a TouchController should not try to intercept touches from. 79 AbstractFloatingView.getTopOpenViewWithType( 80 container, 81 AbstractFloatingView.TYPE_TOUCH_CONTROLLER_NO_INTERCEPT, 82 ) != null -> { 83 debugLog(TAG, "Not intercepting, open floating view blocking touch.") 84 false 85 } 86 87 // Disable swiping if the task overlay is modal. 88 taskViewRecentsTouchContext.isRecentsModal -> { 89 debugLog(TAG, "Not intercepting touch in modal overlay.") 90 false 91 } 92 93 else -> 94 taskViewRecentsTouchContext.isRecentsInteractive.also { isRecentsInteractive -> 95 if (!isRecentsInteractive) { 96 debugLog(TAG, "Not intercepting touch, recents not interactive.") 97 } 98 } 99 } 100 101 override fun onControllerInterceptTouchEvent(ev: MotionEvent): Boolean { 102 if ((ev.action == MotionEvent.ACTION_UP || ev.action == MotionEvent.ACTION_CANCEL)) { 103 clearState() 104 } 105 if (ev.action == MotionEvent.ACTION_DOWN) { 106 if (!onActionDown(ev)) { 107 return false 108 } 109 } 110 111 onControllerTouchEvent(ev) 112 val upDirectionIsPositive = upDirection == SingleAxisSwipeDetector.DIRECTION_POSITIVE 113 val wasInitialTouchUp = 114 (upDirectionIsPositive && detector.wasInitialTouchPositive()) || 115 (!upDirectionIsPositive && !detector.wasInitialTouchPositive()) 116 return detector.isDraggingState && wasInitialTouchUp 117 } 118 119 override fun onControllerTouchEvent(ev: MotionEvent?): Boolean = detector.onTouchEvent(ev) 120 121 private fun onActionDown(ev: MotionEvent): Boolean { 122 springAnimation?.cancel() 123 recentsScaleAnimation?.cancel() 124 if (!canInterceptTouch(ev)) { 125 return false 126 } 127 taskBeingDragged = 128 recentsView.taskViews 129 .firstOrNull { 130 recentsView.isTaskViewVisible(it) && container.dragLayer.isEventOverView(it, ev) 131 } 132 ?.also { 133 val secondaryLayerDimension = 134 recentsView.pagedOrientationHandler.getSecondaryDimension( 135 container.dragLayer 136 ) 137 // Dismiss length as bottom of task so it is fully off screen when dismissed. 138 it.getThumbnailBounds(tempTaskThumbnailBounds, relativeToDragLayer = true) 139 dismissLength = 140 recentsView.pagedOrientationHandler.getTaskDismissLength( 141 secondaryLayerDimension, 142 tempTaskThumbnailBounds, 143 ) 144 verticalFactor = 145 recentsView.pagedOrientationHandler.getTaskDismissVerticalDirection() 146 } 147 detector.setDetectableScrollConditions(upDirection, /* ignoreSlop= */ false) 148 return true 149 } 150 151 override fun onDragStart(start: Boolean, startDisplacement: Float) { 152 if (isBlockedDuringDismissal) return 153 val taskBeingDragged = taskBeingDragged ?: return 154 debugLog(TAG, "Handling touch event.") 155 156 initialDisplacement = 157 taskBeingDragged.secondaryDismissTranslationProperty.get(taskBeingDragged) 158 159 // Add a tiny bit of translation Z, so that it draws on top of other views. This is relevant 160 // (e.g.) when we dismiss a task by sliding it upward: if there is a row of icons above, we 161 // want the dragged task to stay above all other views. 162 taskBeingDragged.translationZ = 0.1f 163 } 164 165 override fun onDrag(displacement: Float): Boolean { 166 if (isBlockedDuringDismissal) return true 167 val taskBeingDragged = taskBeingDragged ?: return false 168 val currentDisplacement = displacement + initialDisplacement 169 val boundedDisplacement = 170 boundToRange(abs(currentDisplacement), 0f, dismissLength.toFloat()) 171 // When swiping below origin, allow slight undershoot to simulate resisting the movement. 172 val totalDisplacement = 173 if (recentsView.pagedOrientationHandler.isGoingUp(currentDisplacement, isRtl)) 174 boundedDisplacement * verticalFactor 175 else 176 mapToRange( 177 boundedDisplacement, 178 0f, 179 dismissLength.toFloat(), 180 0f, 181 container.resources.getDimension(R.dimen.task_dismiss_max_undershoot), 182 DECELERATE, 183 ) * -verticalFactor 184 taskBeingDragged.secondaryDismissTranslationProperty.setValue( 185 taskBeingDragged, 186 totalDisplacement, 187 ) 188 if (taskBeingDragged.isRunningTask && recentsView.enableDrawingLiveTile) { 189 recentsView.runActionOnRemoteHandles { remoteTargetHandle -> 190 remoteTargetHandle.taskViewSimulator.taskSecondaryTranslation.value = 191 totalDisplacement 192 } 193 recentsView.redrawLiveTile() 194 } 195 val dismissFraction = displacement / (dismissLength * verticalFactor).toFloat() 196 RECENTS_SCALE_PROPERTY.setValue(recentsView, getRecentsScale(dismissFraction)) 197 playDismissThresholdHaptic(displacement) 198 return true 199 } 200 201 /** 202 * Play a haptic to alert the user they have passed the dismiss threshold. 203 * 204 * <p>Check within a range of the threshold value, as the drag event does not necessarily happen 205 * at the exact threshold's displacement. 206 */ 207 private fun playDismissThresholdHaptic(displacement: Float) { 208 val dismissThreshold = (DISMISS_THRESHOLD_FRACTION * dismissLength * verticalFactor) 209 val inHapticRange = 210 displacement >= (dismissThreshold - DISMISS_THRESHOLD_HAPTIC_RANGE) && 211 displacement <= (dismissThreshold + DISMISS_THRESHOLD_HAPTIC_RANGE) 212 if (!inHapticRange) { 213 hasDismissThresholdHapticRun = false 214 } else if (!hasDismissThresholdHapticRun) { 215 MSDLPlayerWrapper.INSTANCE.get(recentsView.context) 216 .playToken(MSDLToken.SWIPE_THRESHOLD_INDICATOR) 217 hasDismissThresholdHapticRun = true 218 } 219 } 220 221 override fun onDragEnd(velocity: Float) { 222 if (isBlockedDuringDismissal) return 223 val taskBeingDragged = taskBeingDragged ?: return 224 225 val currentDisplacement = 226 taskBeingDragged.secondaryDismissTranslationProperty.get(taskBeingDragged) 227 val isBeyondDismissThreshold = 228 abs(currentDisplacement) > abs(DISMISS_THRESHOLD_FRACTION * dismissLength) 229 val velocityIsGoingUp = recentsView.pagedOrientationHandler.isGoingUp(velocity, isRtl) 230 val isFlingingTowardsDismiss = detector.isFling(velocity) && velocityIsGoingUp 231 val isFlingingTowardsRestState = detector.isFling(velocity) && !velocityIsGoingUp 232 val isDismissing = 233 isFlingingTowardsDismiss || (isBeyondDismissThreshold && !isFlingingTowardsRestState) 234 springAnimation = 235 recentsView 236 .createTaskDismissSettlingSpringAnimation( 237 taskBeingDragged, 238 velocity, 239 isDismissing, 240 dismissLength, 241 this::clearState, 242 ) 243 .apply { 244 animateToFinalPosition( 245 if (isDismissing) (dismissLength * verticalFactor).toFloat() else 0f 246 ) 247 } 248 isBlockedDuringDismissal = true 249 recentsScaleAnimation = 250 recentsView.animateRecentsScale(RECENTS_SCALE_DEFAULT).addEndListener { _, _, _, _ -> 251 recentsScaleAnimation = null 252 } 253 } 254 255 private fun clearState() { 256 detector.finishedScrolling() 257 detector.setDetectableScrollConditions(0, false) 258 taskBeingDragged?.translationZ = 0f 259 taskBeingDragged = null 260 springAnimation = null 261 isBlockedDuringDismissal = false 262 } 263 264 private fun getRecentsScale(dismissFraction: Float): Float { 265 return when { 266 // Do not scale recents when dragging below origin. 267 dismissFraction <= 0 -> { 268 RECENTS_SCALE_DEFAULT 269 } 270 // Initially scale recents as the drag begins, up to the first threshold. 271 dismissFraction < RECENTS_SCALE_FIRST_THRESHOLD_FRACTION -> { 272 mapToRange( 273 dismissFraction, 274 0f, 275 RECENTS_SCALE_FIRST_THRESHOLD_FRACTION, 276 RECENTS_SCALE_DEFAULT, 277 RECENTS_SCALE_ON_DISMISS_CANCEL, 278 LINEAR, 279 ) 280 } 281 // Keep scale consistent until dragging to the dismiss threshold. 282 dismissFraction < RECENTS_SCALE_DISMISS_THRESHOLD_FRACTION -> { 283 RECENTS_SCALE_ON_DISMISS_CANCEL 284 } 285 // Scale beyond the dismiss threshold again, to indicate dismiss will occur on release. 286 dismissFraction < RECENTS_SCALE_SECOND_THRESHOLD_FRACTION -> { 287 mapToRange( 288 dismissFraction, 289 RECENTS_SCALE_DISMISS_THRESHOLD_FRACTION, 290 RECENTS_SCALE_SECOND_THRESHOLD_FRACTION, 291 RECENTS_SCALE_ON_DISMISS_CANCEL, 292 RECENTS_SCALE_ON_DISMISS_SUCCESS, 293 LINEAR, 294 ) 295 } 296 // Keep scale beyond the dismiss threshold scaling consistent. 297 else -> { 298 RECENTS_SCALE_ON_DISMISS_SUCCESS 299 } 300 } 301 } 302 303 companion object { 304 private const val TAG = "TaskViewDismissTouchController" 305 306 private const val DISMISS_THRESHOLD_FRACTION = 0.5f 307 private const val DISMISS_THRESHOLD_HAPTIC_RANGE = 10f 308 309 private const val RECENTS_SCALE_ON_DISMISS_CANCEL = 0.9875f 310 private const val RECENTS_SCALE_ON_DISMISS_SUCCESS = 0.975f 311 private const val RECENTS_SCALE_DEFAULT = 1f 312 private const val RECENTS_SCALE_FIRST_THRESHOLD_FRACTION = 0.2f 313 private const val RECENTS_SCALE_DISMISS_THRESHOLD_FRACTION = 0.5f 314 private const val RECENTS_SCALE_SECOND_THRESHOLD_FRACTION = 0.575f 315 } 316 } 317