/* * 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.wm.shell.desktopmode; import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM; import static android.app.WindowConfiguration.WINDOWING_MODE_FULLSCREEN; import static android.app.WindowConfiguration.WINDOWING_MODE_MULTI_WINDOW; import static android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE; import static android.view.WindowManager.LayoutParams.TYPE_APPLICATION; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.RectEvaluator; import android.animation.ValueAnimator; import android.annotation.NonNull; import android.app.ActivityManager; import android.app.WindowConfiguration; import android.content.Context; import android.content.res.Resources; import android.graphics.PixelFormat; import android.graphics.PointF; import android.graphics.Rect; import android.graphics.Region; import android.graphics.drawable.LayerDrawable; import android.util.DisplayMetrics; import android.view.SurfaceControl; import android.view.SurfaceControlViewHost; import android.view.View; import android.view.WindowManager; import android.view.WindowlessWindowManager; import android.view.animation.DecelerateInterpolator; import androidx.annotation.VisibleForTesting; import com.android.wm.shell.R; import com.android.wm.shell.RootTaskDisplayAreaOrganizer; import com.android.wm.shell.common.DisplayController; import com.android.wm.shell.common.DisplayLayout; import com.android.wm.shell.common.SyncTransactionQueue; /** * Animated visual indicator for Desktop Mode windowing transitions. */ public class DesktopModeVisualIndicator { public enum IndicatorType { /** To be used when we don't want to indicate any transition */ NO_INDICATOR, /** Indicates impending transition into desktop mode */ TO_DESKTOP_INDICATOR, /** Indicates impending transition into fullscreen */ TO_FULLSCREEN_INDICATOR, /** Indicates impending transition into split select on the left side */ TO_SPLIT_LEFT_INDICATOR, /** Indicates impending transition into split select on the right side */ TO_SPLIT_RIGHT_INDICATOR } private final Context mContext; private final DisplayController mDisplayController; private final RootTaskDisplayAreaOrganizer mRootTdaOrganizer; private final ActivityManager.RunningTaskInfo mTaskInfo; private final SurfaceControl mTaskSurface; private SurfaceControl mLeash; private final SyncTransactionQueue mSyncQueue; private SurfaceControlViewHost mViewHost; private View mView; private IndicatorType mCurrentType; public DesktopModeVisualIndicator(SyncTransactionQueue syncQueue, ActivityManager.RunningTaskInfo taskInfo, DisplayController displayController, Context context, SurfaceControl taskSurface, RootTaskDisplayAreaOrganizer taskDisplayAreaOrganizer) { mSyncQueue = syncQueue; mTaskInfo = taskInfo; mDisplayController = displayController; mContext = context; mTaskSurface = taskSurface; mRootTdaOrganizer = taskDisplayAreaOrganizer; mCurrentType = IndicatorType.NO_INDICATOR; } /** * Based on the coordinates of the current drag event, determine which indicator type we should * display, including no visible indicator. */ @NonNull IndicatorType updateIndicatorType(PointF inputCoordinates, int windowingMode) { final DisplayLayout layout = mDisplayController.getDisplayLayout(mTaskInfo.displayId); // If we are in freeform, we don't want a visible indicator in the "freeform" drag zone. IndicatorType result = IndicatorType.NO_INDICATOR; final int transitionAreaWidth = mContext.getResources().getDimensionPixelSize( com.android.wm.shell.R.dimen.desktop_mode_transition_area_width); // Because drags in freeform use task position for indicator calculation, we need to // account for the possibility of the task going off the top of the screen by captionHeight final int captionHeight = mContext.getResources().getDimensionPixelSize( com.android.wm.shell.R.dimen.desktop_mode_freeform_decor_caption_height); final Region fullscreenRegion = calculateFullscreenRegion(layout, windowingMode, captionHeight); final Region splitLeftRegion = calculateSplitLeftRegion(layout, windowingMode, transitionAreaWidth, captionHeight); final Region splitRightRegion = calculateSplitRightRegion(layout, windowingMode, transitionAreaWidth, captionHeight); final Region toDesktopRegion = calculateToDesktopRegion(layout, windowingMode, splitLeftRegion, splitRightRegion, fullscreenRegion); if (fullscreenRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) { result = IndicatorType.TO_FULLSCREEN_INDICATOR; } if (splitLeftRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) { result = IndicatorType.TO_SPLIT_LEFT_INDICATOR; } if (splitRightRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) { result = IndicatorType.TO_SPLIT_RIGHT_INDICATOR; } if (toDesktopRegion.contains((int) inputCoordinates.x, (int) inputCoordinates.y)) { result = IndicatorType.TO_DESKTOP_INDICATOR; } transitionIndicator(result); return result; } @VisibleForTesting Region calculateFullscreenRegion(DisplayLayout layout, @WindowConfiguration.WindowingMode int windowingMode, int captionHeight) { final Region region = new Region(); int transitionHeight = windowingMode == WINDOWING_MODE_FREEFORM ? mContext.getResources().getDimensionPixelSize( com.android.wm.shell.R.dimen.desktop_mode_fullscreen_from_desktop_height) : 2 * layout.stableInsets().top; // A thin, short Rect at the top of the screen. if (windowingMode == WINDOWING_MODE_FREEFORM) { int fromFreeformWidth = mContext.getResources().getDimensionPixelSize( com.android.wm.shell.R.dimen.desktop_mode_fullscreen_from_desktop_width); region.union(new Rect((layout.width() / 2) - (fromFreeformWidth / 2), -captionHeight, (layout.width() / 2) + (fromFreeformWidth / 2), transitionHeight)); } // A screen-wide, shorter Rect if the task is in fullscreen or split. if (windowingMode == WINDOWING_MODE_FULLSCREEN || windowingMode == WINDOWING_MODE_MULTI_WINDOW) { region.union(new Rect(0, -captionHeight, layout.width(), transitionHeight)); } return region; } @VisibleForTesting Region calculateToDesktopRegion(DisplayLayout layout, @WindowConfiguration.WindowingMode int windowingMode, Region splitLeftRegion, Region splitRightRegion, Region toFullscreenRegion) { final Region region = new Region(); // If in desktop, we need no region. Otherwise it's the same for all windowing modes. if (windowingMode != WINDOWING_MODE_FREEFORM) { region.union(new Rect(0, 0, layout.width(), layout.height())); region.op(splitLeftRegion, Region.Op.DIFFERENCE); region.op(splitRightRegion, Region.Op.DIFFERENCE); region.op(toFullscreenRegion, Region.Op.DIFFERENCE); } return region; } @VisibleForTesting Region calculateSplitLeftRegion(DisplayLayout layout, @WindowConfiguration.WindowingMode int windowingMode, int transitionEdgeWidth, int captionHeight) { final Region region = new Region(); // In freeform, keep the top corners clear. int transitionHeight = windowingMode == WINDOWING_MODE_FREEFORM ? mContext.getResources().getDimensionPixelSize( com.android.wm.shell.R.dimen.desktop_mode_split_from_desktop_height) : -captionHeight; region.union(new Rect(0, transitionHeight, transitionEdgeWidth, layout.height())); return region; } @VisibleForTesting Region calculateSplitRightRegion(DisplayLayout layout, @WindowConfiguration.WindowingMode int windowingMode, int transitionEdgeWidth, int captionHeight) { final Region region = new Region(); // In freeform, keep the top corners clear. int transitionHeight = windowingMode == WINDOWING_MODE_FREEFORM ? mContext.getResources().getDimensionPixelSize( com.android.wm.shell.R.dimen.desktop_mode_split_from_desktop_height) : -captionHeight; region.union(new Rect(layout.width() - transitionEdgeWidth, transitionHeight, layout.width(), layout.height())); return region; } /** * Create a fullscreen indicator with no animation */ private void createView() { final SurfaceControl.Transaction t = new SurfaceControl.Transaction(); final Resources resources = mContext.getResources(); final DisplayMetrics metrics = resources.getDisplayMetrics(); final int screenWidth = metrics.widthPixels; final int screenHeight = metrics.heightPixels; mView = new View(mContext); final SurfaceControl.Builder builder = new SurfaceControl.Builder(); mRootTdaOrganizer.attachToDisplayArea(mTaskInfo.displayId, builder); mLeash = builder .setName("Desktop Mode Visual Indicator") .setContainerLayer() .setCallsite("DesktopModeVisualIndicator.createView") .build(); t.show(mLeash); final WindowManager.LayoutParams lp = new WindowManager.LayoutParams(screenWidth, screenHeight, TYPE_APPLICATION, FLAG_NOT_FOCUSABLE, PixelFormat.TRANSPARENT); lp.setTitle("Desktop Mode Visual Indicator"); lp.setTrustedOverlay(); final WindowlessWindowManager windowManager = new WindowlessWindowManager( mTaskInfo.configuration, mLeash, null /* hostInputToken */); mViewHost = new SurfaceControlViewHost(mContext, mDisplayController.getDisplay(mTaskInfo.displayId), windowManager, "DesktopModeVisualIndicator"); mViewHost.setView(mView, lp); // We want this indicator to be behind the dragged task, but in front of all others. t.setRelativeLayer(mLeash, mTaskSurface, -1); mSyncQueue.runInSync(transaction -> { transaction.merge(t); t.close(); }); } /** * Fade indicator in as provided type. Animator fades it in while expanding the bounds outwards. */ private void fadeInIndicator(IndicatorType type) { mView.setBackgroundResource(R.drawable.desktop_windowing_transition_background); final VisualIndicatorAnimator animator = VisualIndicatorAnimator .fadeBoundsIn(mView, type, mDisplayController.getDisplayLayout(mTaskInfo.displayId)); animator.start(); mCurrentType = type; } /** * Fade out indicator without fully releasing it. Animator fades it out while shrinking bounds. */ private void fadeOutIndicator() { final VisualIndicatorAnimator animator = VisualIndicatorAnimator .fadeBoundsOut(mView, mCurrentType, mDisplayController.getDisplayLayout(mTaskInfo.displayId)); animator.start(); mCurrentType = IndicatorType.NO_INDICATOR; } /** * Takes existing indicator and animates it to bounds reflecting a new indicator type. */ private void transitionIndicator(IndicatorType newType) { if (mCurrentType == newType) return; if (mView == null) { createView(); } if (mCurrentType == IndicatorType.NO_INDICATOR) { fadeInIndicator(newType); } else if (newType == IndicatorType.NO_INDICATOR) { fadeOutIndicator(); } else { final VisualIndicatorAnimator animator = VisualIndicatorAnimator.animateIndicatorType( mView, mDisplayController.getDisplayLayout(mTaskInfo.displayId), mCurrentType, newType); mCurrentType = newType; animator.start(); } } /** * Release the indicator and its components when it is no longer needed. */ public void releaseVisualIndicator(SurfaceControl.Transaction t) { if (mViewHost == null) return; if (mViewHost != null) { mViewHost.release(); mViewHost = null; } if (mLeash != null) { t.remove(mLeash); mLeash = null; } } /** * Animator for Desktop Mode transitions which supports bounds and alpha animation. */ private static class VisualIndicatorAnimator extends ValueAnimator { private static final int FULLSCREEN_INDICATOR_DURATION = 200; private static final float FULLSCREEN_SCALE_ADJUSTMENT_PERCENT = 0.015f; private static final float INDICATOR_FINAL_OPACITY = 0.35f; private static final int MAXIMUM_OPACITY = 255; /** * Determines how this animator will interact with the view's alpha: * Fade in, fade out, or no change to alpha */ private enum AlphaAnimType { ALPHA_FADE_IN_ANIM, ALPHA_FADE_OUT_ANIM, ALPHA_NO_CHANGE_ANIM } private final View mView; private final Rect mStartBounds; private final Rect mEndBounds; private final RectEvaluator mRectEvaluator; private VisualIndicatorAnimator(View view, Rect startBounds, Rect endBounds) { mView = view; mStartBounds = new Rect(startBounds); mEndBounds = endBounds; setFloatValues(0, 1); mRectEvaluator = new RectEvaluator(new Rect()); } private static VisualIndicatorAnimator fadeBoundsIn( @NonNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout) { final Rect startBounds = getIndicatorBounds(displayLayout, type); view.getBackground().setBounds(startBounds); final VisualIndicatorAnimator animator = new VisualIndicatorAnimator( view, startBounds, getMaxBounds(startBounds)); animator.setInterpolator(new DecelerateInterpolator()); setupIndicatorAnimation(animator, AlphaAnimType.ALPHA_FADE_IN_ANIM); return animator; } private static VisualIndicatorAnimator fadeBoundsOut( @NonNull View view, IndicatorType type, @NonNull DisplayLayout displayLayout) { final Rect endBounds = getIndicatorBounds(displayLayout, type); final Rect startBounds = getMaxBounds(endBounds); view.getBackground().setBounds(startBounds); final VisualIndicatorAnimator animator = new VisualIndicatorAnimator( view, startBounds, endBounds); animator.setInterpolator(new DecelerateInterpolator()); setupIndicatorAnimation(animator, AlphaAnimType.ALPHA_FADE_OUT_ANIM); return animator; } /** * Create animator for visual indicator changing type (i.e., fullscreen to freeform, * freeform to split, etc.) * * @param view the view for this indicator * @param displayLayout information about the display the transitioning task is currently on * @param origType the original indicator type * @param newType the new indicator type */ private static VisualIndicatorAnimator animateIndicatorType(@NonNull View view, @NonNull DisplayLayout displayLayout, IndicatorType origType, IndicatorType newType) { final Rect startBounds = getIndicatorBounds(displayLayout, origType); final Rect endBounds = getIndicatorBounds(displayLayout, newType); final VisualIndicatorAnimator animator = new VisualIndicatorAnimator( view, startBounds, endBounds); animator.setInterpolator(new DecelerateInterpolator()); setupIndicatorAnimation(animator, AlphaAnimType.ALPHA_NO_CHANGE_ANIM); return animator; } private static Rect getIndicatorBounds(DisplayLayout layout, IndicatorType type) { final int padding = layout.stableInsets().top; switch (type) { case TO_FULLSCREEN_INDICATOR: return new Rect(padding, padding, layout.width() - padding, layout.height() - padding); case TO_DESKTOP_INDICATOR: final float adjustmentPercentage = 1f - DesktopTasksController.DESKTOP_MODE_INITIAL_BOUNDS_SCALE; return new Rect((int) (adjustmentPercentage * layout.width() / 2), (int) (adjustmentPercentage * layout.height() / 2), (int) (layout.width() - (adjustmentPercentage * layout.width() / 2)), (int) (layout.height() - (adjustmentPercentage * layout.height() / 2))); case TO_SPLIT_LEFT_INDICATOR: return new Rect(padding, padding, layout.width() / 2 - padding, layout.height() - padding); case TO_SPLIT_RIGHT_INDICATOR: return new Rect(layout.width() / 2 + padding, padding, layout.width() - padding, layout.height() - padding); default: throw new IllegalArgumentException("Invalid indicator type provided."); } } /** * Add necessary listener for animation of indicator */ private static void setupIndicatorAnimation(@NonNull VisualIndicatorAnimator animator, AlphaAnimType animType) { animator.addUpdateListener(a -> { if (animator.mView != null) { animator.updateBounds(a.getAnimatedFraction(), animator.mView); if (animType == AlphaAnimType.ALPHA_FADE_IN_ANIM) { animator.updateIndicatorAlpha(a.getAnimatedFraction(), animator.mView); } else if (animType == AlphaAnimType.ALPHA_FADE_OUT_ANIM) { animator.updateIndicatorAlpha(1 - a.getAnimatedFraction(), animator.mView); } } else { animator.cancel(); } }); animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { animator.mView.getBackground().setBounds(animator.mEndBounds); } }); animator.setDuration(FULLSCREEN_INDICATOR_DURATION); } /** * Update bounds of view based on current animation fraction. * Use of delta is to animate bounds independently, in case we need to * run multiple animations simultaneously. * * @param fraction fraction to use, compared against previous fraction * @param view the view to update */ private void updateBounds(float fraction, View view) { if (mStartBounds.equals(mEndBounds)) { return; } final Rect currentBounds = mRectEvaluator.evaluate(fraction, mStartBounds, mEndBounds); view.getBackground().setBounds(currentBounds); } /** * Fade in the fullscreen indicator * * @param fraction current animation fraction */ private void updateIndicatorAlpha(float fraction, View view) { final LayerDrawable drawable = (LayerDrawable) view.getBackground(); drawable.findDrawableByLayerId(R.id.indicator_stroke) .setAlpha((int) (MAXIMUM_OPACITY * fraction)); drawable.findDrawableByLayerId(R.id.indicator_solid) .setAlpha((int) (MAXIMUM_OPACITY * fraction * INDICATOR_FINAL_OPACITY)); } /** * Return the max bounds of a visual indicator */ private static Rect getMaxBounds(Rect startBounds) { return new Rect((int) (startBounds.left - (FULLSCREEN_SCALE_ADJUSTMENT_PERCENT * startBounds.width())), (int) (startBounds.top - (FULLSCREEN_SCALE_ADJUSTMENT_PERCENT * startBounds.height())), (int) (startBounds.right + (FULLSCREEN_SCALE_ADJUSTMENT_PERCENT * startBounds.width())), (int) (startBounds.bottom + (FULLSCREEN_SCALE_ADJUSTMENT_PERCENT * startBounds.height()))); } } }