/* * Copyright (C) 2025 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.launcher3.uioverrides.touchcontrollers import android.content.Context import android.graphics.Rect import android.view.MotionEvent import com.android.app.animation.Interpolators.ZOOM_IN import com.android.launcher3.AbstractFloatingView import com.android.launcher3.LauncherAnimUtils import com.android.launcher3.Utilities.EDGE_NAV_BAR import com.android.launcher3.Utilities.boundToRange import com.android.launcher3.Utilities.debugLog import com.android.launcher3.Utilities.isRtl import com.android.launcher3.anim.AnimatorPlaybackController import com.android.launcher3.touch.BaseSwipeDetector import com.android.launcher3.touch.SingleAxisSwipeDetector import com.android.launcher3.util.DisplayController import com.android.launcher3.util.FlingBlockCheck import com.android.launcher3.util.TouchController import com.android.quickstep.views.RecentsView import com.android.quickstep.views.RecentsViewContainer import com.android.quickstep.views.TaskView import kotlin.math.abs /** Touch controller which handles dragging task view cards for launch. */ class TaskViewLaunchTouchController( private val container: CONTAINER, private val taskViewRecentsTouchContext: TaskViewRecentsTouchContext, ) : TouchController, SingleAxisSwipeDetector.Listener where CONTAINER : Context, CONTAINER : RecentsViewContainer { private val tempRect = Rect() private val flingBlockCheck = FlingBlockCheck() private val recentsView: RecentsView<*, *> = container.getOverviewPanel() private val detector: SingleAxisSwipeDetector = SingleAxisSwipeDetector( container as Context, this, recentsView.pagedOrientationHandler.upDownSwipeDirection, ) private val isRtl = isRtl(container.resources) private val downDirection = recentsView.pagedOrientationHandler.getDownDirection(isRtl) private var taskBeingDragged: TaskView? = null private var launchEndDisplacement: Float = 0f private var playbackController: AnimatorPlaybackController? = null private var verticalFactor: Int = 0 private fun canTaskLaunchTaskView(taskView: TaskView?) = taskView != null && taskView === recentsView.currentPageTaskView && DisplayController.getNavigationMode(container).hasGestures && (!recentsView.showAsGrid() || taskView.isLargeTile) && recentsView.isTaskInExpectedScrollPosition(taskView) private fun canInterceptTouch(ev: MotionEvent): Boolean = when { // Don't intercept swipes on the nav bar, as user might be trying to go home during a // task dismiss animation. (ev.edgeFlags and EDGE_NAV_BAR) != 0 -> { debugLog(TAG, "Not intercepting edge swipe on nav bar.") false } // Floating views that a TouchController should not try to intercept touches from. AbstractFloatingView.getTopOpenViewWithType( container, AbstractFloatingView.TYPE_TOUCH_CONTROLLER_NO_INTERCEPT, ) != null -> { debugLog(TAG, "Not intercepting, open floating view blocking touch.") false } // Disable swiping if the task overlay is modal. taskViewRecentsTouchContext.isRecentsModal -> { debugLog(TAG, "Not intercepting touch in modal overlay.") false } else -> taskViewRecentsTouchContext.isRecentsInteractive.also { isRecentsInteractive -> if (!isRecentsInteractive) { debugLog(TAG, "Not intercepting touch, recents not interactive.") } } } override fun onControllerInterceptTouchEvent(ev: MotionEvent): Boolean { if ( (ev.action == MotionEvent.ACTION_UP || ev.action == MotionEvent.ACTION_CANCEL) && playbackController == null ) { clearState() } if (ev.action == MotionEvent.ACTION_DOWN) { if (!onActionDown(ev)) { clearState() return false } } onControllerTouchEvent(ev) val downDirectionIsNegative = downDirection == SingleAxisSwipeDetector.DIRECTION_NEGATIVE val wasInitialTouchDown = (downDirectionIsNegative && !detector.wasInitialTouchPositive()) || (!downDirectionIsNegative && detector.wasInitialTouchPositive()) return detector.isDraggingState && wasInitialTouchDown } override fun onControllerTouchEvent(ev: MotionEvent) = detector.onTouchEvent(ev) private fun onActionDown(ev: MotionEvent): Boolean { if (!canInterceptTouch(ev)) { return false } taskBeingDragged = recentsView.taskViews .firstOrNull { recentsView.isTaskViewVisible(it) && container.dragLayer.isEventOverView(it, ev) } ?.also { verticalFactor = recentsView.pagedOrientationHandler.getTaskDragDisplacementFactor(isRtl) } if (!canTaskLaunchTaskView(taskBeingDragged)) { debugLog(TAG, "Not intercepting touch, task cannot be launched.") return false } detector.setDetectableScrollConditions(downDirection, /* ignoreSlop= */ false) return true } override fun onDragStart(start: Boolean, startDisplacement: Float) { val taskBeingDragged = taskBeingDragged ?: return debugLog(TAG, "Handling touch event.") val secondaryLayerDimension: Int = recentsView.pagedOrientationHandler.getSecondaryDimension(container.getDragLayer()) val maxDuration = 2L * secondaryLayerDimension recentsView.clearPendingAnimation() val pendingAnimation = recentsView.createTaskLaunchAnimation(taskBeingDragged, maxDuration, ZOOM_IN) // Since the thumbnail is what is filling the screen, based the end displacement on it. taskBeingDragged.getThumbnailBounds(tempRect, /* relativeToDragLayer= */ true) launchEndDisplacement = recentsView.pagedOrientationHandler .getTaskLaunchLength(secondaryLayerDimension, tempRect) .toFloat() * verticalFactor playbackController = pendingAnimation.createPlaybackController()?.apply { taskViewRecentsTouchContext.onUserControlledAnimationCreated(this) dispatchOnStart() } } override fun onDrag(displacement: Float): Boolean { playbackController?.setPlayFraction( boundToRange(displacement / launchEndDisplacement, 0f, 1f) ) return true } override fun onDragEnd(velocity: Float) { val playbackController = playbackController ?: return val isBeyondLaunchThreshold = abs(playbackController.progressFraction) > abs(LAUNCH_THRESHOLD_FRACTION) val velocityIsNegative = !recentsView.pagedOrientationHandler.isGoingUp(velocity, isRtl) val isFlingingTowardsLaunch = detector.isFling(velocity) && velocityIsNegative val isFlingingTowardsRestState = detector.isFling(velocity) && !velocityIsNegative val isLaunching = isFlingingTowardsLaunch || (isBeyondLaunchThreshold && !isFlingingTowardsRestState) val progress = playbackController.progressFraction var animationDuration = BaseSwipeDetector.calculateDuration( velocity, if (isLaunching) (1 - progress) else progress, ) if (detector.isFling(velocity) && flingBlockCheck.isBlocked && !isLaunching) { animationDuration *= LauncherAnimUtils.blockedFlingDurationFactor(velocity).toLong() } playbackController.setEndAction(this::clearState) playbackController.startWithVelocity( container, isLaunching, velocity, launchEndDisplacement, animationDuration, ) } private fun clearState() { detector.finishedScrolling() detector.setDetectableScrollConditions(0, false) taskBeingDragged = null playbackController = null } companion object { private const val TAG = "TaskViewLaunchTouchController" private const val LAUNCH_THRESHOLD_FRACTION: Float = 0.5f } }