• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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