| /* |
| * Copyright (C) 2023 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.quickstep.util |
| |
| import android.animation.Animator |
| import android.animation.AnimatorListenerAdapter |
| import android.animation.AnimatorSet |
| import android.animation.ObjectAnimator |
| import android.graphics.Bitmap |
| import android.graphics.Rect |
| import android.graphics.RectF |
| import android.graphics.drawable.Drawable |
| import android.view.View |
| import com.android.app.animation.Interpolators |
| import com.android.launcher3.DeviceProfile |
| import com.android.launcher3.Utilities |
| import com.android.launcher3.anim.PendingAnimation |
| import com.android.launcher3.config.FeatureFlags |
| import com.android.launcher3.statemanager.StatefulActivity |
| import com.android.launcher3.util.SplitConfigurationOptions.SplitSelectSource |
| import com.android.launcher3.views.BaseDragLayer |
| import com.android.quickstep.views.FloatingTaskView |
| import com.android.quickstep.views.RecentsView |
| import com.android.quickstep.views.SplitInstructionsView |
| import com.android.quickstep.views.TaskThumbnailView |
| import com.android.quickstep.views.TaskView |
| import com.android.quickstep.views.TaskView.TaskIdAttributeContainer |
| import com.android.quickstep.views.TaskViewIcon |
| import java.util.Optional |
| import java.util.function.Supplier |
| |
| /** |
| * Utils class to help run animations for initiating split screen from launcher. |
| * Will be expanded with future refactors. Works in conjunction with the state stored in |
| * [SplitSelectStateController] |
| */ |
| class SplitAnimationController(val splitSelectStateController: SplitSelectStateController) { |
| companion object { |
| // Break this out into maybe enums? Abstractions into its own classes? Tbd. |
| data class SplitAnimInitProps( |
| val originalView: View, |
| val originalBitmap: Bitmap?, |
| val iconDrawable: Drawable, |
| val fadeWithThumbnail: Boolean, |
| val isStagedTask: Boolean, |
| val iconView: View? |
| ) |
| } |
| |
| /** |
| * Returns different elements to animate for the initial split selection animation |
| * depending on the state of the surface from which the split was initiated |
| */ |
| fun getFirstAnimInitViews(taskViewSupplier: Supplier<TaskView>, |
| splitSelectSourceSupplier: Supplier<SplitSelectSource?>) |
| : SplitAnimInitProps { |
| val splitSelectSource = splitSelectSourceSupplier.get() |
| if (!splitSelectStateController.isAnimateCurrentTaskDismissal) { |
| // Initiating from home |
| return SplitAnimInitProps(splitSelectSource!!.view, originalBitmap = null, |
| splitSelectSource.drawable, fadeWithThumbnail = false, isStagedTask = true, |
| iconView = null) |
| } else if (splitSelectStateController.isDismissingFromSplitPair) { |
| // Initiating split from overview, but on a split pair |
| val taskView = taskViewSupplier.get() |
| for (container : TaskIdAttributeContainer in taskView.taskIdAttributeContainers) { |
| if (container.task.getKey().getId() == splitSelectStateController.initialTaskId) { |
| val drawable = getDrawable(container.iconView, splitSelectSource) |
| return SplitAnimInitProps(container.thumbnailView, |
| container.thumbnailView.thumbnail, drawable!!, |
| fadeWithThumbnail = true, isStagedTask = true, |
| iconView = container.iconView.asView() |
| ) |
| } |
| } |
| throw IllegalStateException("Attempting to init split from existing split pair " + |
| "without a valid taskIdAttributeContainer") |
| } else { |
| // Initiating split from overview on fullscreen task TaskView |
| val taskView = taskViewSupplier.get() |
| val drawable = getDrawable(taskView.iconView, splitSelectSource) |
| return SplitAnimInitProps(taskView.thumbnail, taskView.thumbnail.thumbnail, |
| drawable!!, fadeWithThumbnail = true, isStagedTask = true, |
| taskView.iconView.asView() |
| ) |
| } |
| } |
| |
| /** |
| * Returns the drawable that's provided in iconView, however if that |
| * is null it falls back to the drawable that's in splitSelectSource. |
| * TaskView's icon drawable can be null if the TaskView is scrolled far enough off screen |
| * @return [Drawable] |
| */ |
| fun getDrawable(iconView: TaskViewIcon, splitSelectSource: SplitSelectSource?) : Drawable? { |
| if (iconView.drawable == null && splitSelectSource != null) { |
| return splitSelectSource.drawable |
| } |
| return iconView.drawable |
| } |
| |
| /** |
| * When selecting first app from split pair, second app's thumbnail remains. This animates |
| * the second thumbnail by expanding it to take up the full taskViewWidth/Height and overlaying |
| * it with [TaskThumbnailView]'s splashView. Adds animations to the provided builder. |
| * Note: The app that **was not** selected as the first split app should be the container that's |
| * passed through. |
| * |
| * @param builder Adds animation to this |
| * @param taskIdAttributeContainer container of the app that **was not** selected |
| * @param isPrimaryTaskSplitting if true, task that was split would be top/left in the pair |
| * (opposite of that representing [taskIdAttributeContainer]) |
| */ |
| fun addInitialSplitFromPair(taskIdAttributeContainer: TaskIdAttributeContainer, |
| builder: PendingAnimation, deviceProfile: DeviceProfile, |
| taskViewWidth: Int, taskViewHeight: Int, |
| isPrimaryTaskSplitting: Boolean) { |
| val thumbnail = taskIdAttributeContainer.thumbnailView |
| val iconView: View = taskIdAttributeContainer.iconView.asView() |
| builder.add(ObjectAnimator.ofFloat(thumbnail, TaskThumbnailView.SPLASH_ALPHA, 1f)) |
| thumbnail.setShowSplashForSplitSelection(true) |
| if (deviceProfile.isLeftRightSplit) { |
| // Center view first so scaling happens uniformly, alternatively we can move pivotX to 0 |
| val centerThumbnailTranslationX: Float = (taskViewWidth - thumbnail.width) / 2f |
| val centerIconTranslationX: Float = (taskViewWidth - iconView.width) / 2f |
| val finalScaleX: Float = taskViewWidth.toFloat() / thumbnail.width |
| builder.add(ObjectAnimator.ofFloat(thumbnail, |
| TaskThumbnailView.SPLIT_SELECT_TRANSLATE_X, centerThumbnailTranslationX)) |
| // icons are anchored from Gravity.END, so need to use negative translation |
| builder.add(ObjectAnimator.ofFloat(iconView, View.TRANSLATION_X, |
| -centerIconTranslationX)) |
| builder.add(ObjectAnimator.ofFloat(thumbnail, View.SCALE_X, finalScaleX)) |
| |
| // Reset other dimensions |
| // TODO(b/271468547), can't set Y translate to 0, need to account for top space |
| thumbnail.scaleY = 1f |
| val translateYResetVal: Float = if (!isPrimaryTaskSplitting) 0f else |
| deviceProfile.overviewTaskThumbnailTopMarginPx.toFloat() |
| builder.add(ObjectAnimator.ofFloat(thumbnail, |
| TaskThumbnailView.SPLIT_SELECT_TRANSLATE_Y, |
| translateYResetVal)) |
| } else { |
| val thumbnailSize = taskViewHeight - deviceProfile.overviewTaskThumbnailTopMarginPx |
| // Center view first so scaling happens uniformly, alternatively we can move pivotY to 0 |
| // primary thumbnail has layout margin above it, so secondary thumbnail needs to take |
| // that into account. We should migrate to only using translations otherwise this |
| // asymmetry causes problems.. |
| |
| // Icon defaults to center | horizontal, we add additional translation for split |
| val centerIconTranslationX = 0f |
| var centerThumbnailTranslationY: Float |
| |
| // TODO(b/271468547), primary thumbnail has layout margin above it, so secondary |
| // thumbnail needs to take that into account. We should migrate to only using |
| // translations otherwise this asymmetry causes problems.. |
| if (isPrimaryTaskSplitting) { |
| centerThumbnailTranslationY = (thumbnailSize - thumbnail.height) / 2f |
| centerThumbnailTranslationY += deviceProfile.overviewTaskThumbnailTopMarginPx |
| .toFloat() |
| } else { |
| centerThumbnailTranslationY = (thumbnailSize - thumbnail.height) / 2f |
| } |
| val finalScaleY: Float = thumbnailSize.toFloat() / thumbnail.height |
| builder.add(ObjectAnimator.ofFloat(thumbnail, |
| TaskThumbnailView.SPLIT_SELECT_TRANSLATE_Y, centerThumbnailTranslationY)) |
| |
| // icons are anchored from Gravity.END, so need to use negative translation |
| builder.add(ObjectAnimator.ofFloat(iconView, View.TRANSLATION_X, |
| centerIconTranslationX)) |
| builder.add(ObjectAnimator.ofFloat(thumbnail, View.SCALE_Y, finalScaleY)) |
| |
| // Reset other dimensions |
| thumbnail.scaleX = 1f |
| builder.add(ObjectAnimator.ofFloat(thumbnail, |
| TaskThumbnailView.SPLIT_SELECT_TRANSLATE_X, 0f)) |
| } |
| } |
| |
| /** Does not play any animation if user is not currently in split selection state. */ |
| fun playPlaceholderDismissAnim(launcher: StatefulActivity<*>) { |
| if (!splitSelectStateController.isSplitSelectActive) { |
| return |
| } |
| |
| val anim = createPlaceholderDismissAnim(launcher) |
| anim.start() |
| } |
| |
| /** Returns [AnimatorSet] which slides initial split placeholder view offscreen. */ |
| fun createPlaceholderDismissAnim(launcher: StatefulActivity<*>) : AnimatorSet { |
| val animatorSet = AnimatorSet() |
| val recentsView : RecentsView<*, *> = launcher.getOverviewPanel() |
| val floatingTask: FloatingTaskView = splitSelectStateController.firstFloatingTaskView |
| ?: return animatorSet |
| |
| // We are in split selection state currently, transitioning to another state |
| val dragLayer: BaseDragLayer<*> = launcher.dragLayer |
| val onScreenRectF = RectF() |
| Utilities.getBoundsForViewInDragLayer(dragLayer, floatingTask, |
| Rect(0, 0, floatingTask.width, floatingTask.height), |
| false, null, onScreenRectF) |
| // Get the part of the floatingTask that intersects with the DragLayer (i.e. the |
| // on-screen portion) |
| onScreenRectF.intersect( |
| dragLayer.left.toFloat(), |
| dragLayer.top.toFloat(), |
| dragLayer.right.toFloat(), |
| dragLayer.bottom |
| .toFloat() |
| ) |
| animatorSet.play(ObjectAnimator.ofFloat(floatingTask, |
| FloatingTaskView.PRIMARY_TRANSLATE_OFFSCREEN, |
| recentsView.pagedOrientationHandler |
| .getFloatingTaskOffscreenTranslationTarget( |
| floatingTask, |
| onScreenRectF, |
| floatingTask.stagePosition, |
| launcher.deviceProfile |
| ))) |
| animatorSet.addListener(object : AnimatorListenerAdapter() { |
| override fun onAnimationEnd(animation: Animator) { |
| splitSelectStateController.resetState() |
| safeRemoveViewFromDragLayer(launcher, |
| splitSelectStateController.splitInstructionsView) |
| } |
| }) |
| return animatorSet |
| } |
| |
| /** |
| * Returns a [PendingAnimation] to animate in the chip to instruct a user to select a second |
| * app for splitscreen |
| */ |
| fun getShowSplitInstructionsAnim(launcher: StatefulActivity<*>) : PendingAnimation { |
| safeRemoveViewFromDragLayer(launcher, splitSelectStateController.splitInstructionsView) |
| val splitInstructionsView = SplitInstructionsView.getSplitInstructionsView(launcher) |
| splitSelectStateController.splitInstructionsView = splitInstructionsView |
| val timings = AnimUtils.getDeviceOverviewToSplitTimings(launcher.deviceProfile.isTablet) |
| val anim = PendingAnimation(100 /*duration */) |
| anim.setViewAlpha(splitInstructionsView, 1f, |
| Interpolators.clampToProgress(Interpolators.LINEAR, |
| timings.instructionsContainerFadeInStartOffset, |
| timings.instructionsContainerFadeInEndOffset)) |
| anim.setViewAlpha(splitInstructionsView!!.textView, 1f, |
| Interpolators.clampToProgress(Interpolators.LINEAR, |
| timings.instructionsTextFadeInStartOffset, |
| timings.instructionsTextFadeInEndOffset)) |
| anim.addFloat(splitInstructionsView, SplitInstructionsView.UNFOLD, 0.1f, 1f, |
| Interpolators.clampToProgress(Interpolators.EMPHASIZED_DECELERATE, |
| timings.instructionsUnfoldStartOffset, |
| timings.instructionsUnfoldEndOffset)) |
| return anim |
| } |
| |
| /** Removes the split instructions view from [launcher] drag layer. */ |
| fun removeSplitInstructionsView(launcher: StatefulActivity<*>) { |
| safeRemoveViewFromDragLayer(launcher, splitSelectStateController.splitInstructionsView) |
| } |
| |
| /** |
| * Animates the first placeholder view to fullscreen and launches its task. |
| * TODO(b/276361926): Remove the [resetCallback] option once contextual launches |
| */ |
| fun playAnimPlaceholderToFullscreen(launcher: StatefulActivity<*>, view: View, |
| resetCallback: Optional<Runnable>) { |
| val stagedTaskView = view as FloatingTaskView |
| |
| val isTablet: Boolean = launcher.deviceProfile.isTablet |
| val duration = if (isTablet) SplitAnimationTimings.TABLET_CONFIRM_DURATION else |
| SplitAnimationTimings.PHONE_CONFIRM_DURATION |
| val pendingAnimation = PendingAnimation(duration.toLong()) |
| val firstTaskStartingBounds = Rect() |
| val firstTaskEndingBounds = Rect() |
| |
| stagedTaskView.getBoundsOnScreen(firstTaskStartingBounds) |
| launcher.dragLayer.getBoundsOnScreen(firstTaskEndingBounds) |
| splitSelectStateController.setLaunchingFirstAppFullscreen() |
| |
| stagedTaskView.addConfirmAnimation( |
| pendingAnimation, |
| RectF(firstTaskStartingBounds), |
| firstTaskEndingBounds, |
| false /* fadeWithThumbnail */, |
| true /* isStagedTask */) |
| |
| pendingAnimation.addEndListener { |
| splitSelectStateController.launchInitialAppFullscreen { |
| if (FeatureFlags.enableSplitContextually()) { |
| splitSelectStateController.resetState() |
| } else if (resetCallback.isPresent) { |
| resetCallback.get().run() |
| } |
| } |
| } |
| |
| pendingAnimation.buildAnim().start() |
| } |
| |
| private fun safeRemoveViewFromDragLayer(launcher: StatefulActivity<*>, view: View?) { |
| if (view != null) { |
| launcher.dragLayer.removeView(view) |
| } |
| } |
| } |