/* * Copyright (C) 2024 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.taskbar; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.animation.ValueAnimator; import android.content.Context; import android.graphics.BlendMode; import android.graphics.BlendModeColorFilter; import android.graphics.Canvas; import android.graphics.Paint; import android.graphics.drawable.Drawable; import android.util.AttributeSet; import android.util.FloatProperty; import android.util.IntProperty; import android.view.LayoutInflater; import android.view.ViewGroup; import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; import androidx.core.graphics.ColorUtils; import com.android.app.animation.Interpolators; import com.android.launcher3.Reorderable; import com.android.launcher3.Utilities; import com.android.launcher3.icons.IconNormalizer; import com.android.launcher3.util.MultiTranslateDelegate; import com.android.launcher3.util.Themes; import com.android.systemui.shared.recents.model.Task; import java.util.ArrayList; import java.util.List; /** * View used as overflow icon within task bar, when the list of recent/running apps overflows the * available display bounds - if display is not wide enough to show all running apps in the taskbar, * this icon is added to the taskbar as an entry point to open UI that surfaces all running apps. * The icon contains icon representations of up to 4 more recent tasks in overflow, stacked on top * each other in counter clockwise manner (icons of tasks partially overlapping with each other). */ public class TaskbarOverflowView extends FrameLayout implements Reorderable { private static final int ALPHA_TRANSPARENT = 0; private static final int ALPHA_OPAQUE = 255; private static final long ANIMATION_DURATION_APPS_TO_LEAVE_BEHIND = 300L; private static final long ANIMATION_DURATION_LEAVE_BEHIND_TO_APPS = 500L; private static final long ANIMATION_SET_DURATION = 1000L; private static final long ITEM_ICON_CENTER_OFFSET_ANIMATION_DURATION = 500L; private static final long ITEM_ICON_COLOR_FILTER_OPACITY_ANIMATION_DURATION = 600L; private static final long ITEM_ICON_SIZE_ANIMATION_DURATION = 500L; private static final long ITEM_ICON_STROKE_WIDTH_ANIMATION_DURATION = 500L; private static final long LEAVE_BEHIND_ANIMATIONS_DELAY = 500L; private static final long LEAVE_BEHIND_OPACITY_ANIMATION_DURATION = 100L; private static final long LEAVE_BEHIND_SIZE_ANIMATION_DURATION = 500L; private static final float LEAVE_BEHIND_SIZE_SCALE_DOWN_MULTIPLIER = 0.83f; private static final int MAX_ITEMS_IN_PREVIEW = 4; // The height divided by the width of the horizontal box containing two overlapping app icons. // According to the spec, this ratio is constant for different sizes of taskbar app icons. // Assuming the width of this box = taskbar app icon size - 2 paddings - 2 stroke widths, and // the height = width * 0.61, which is also equal to the height of a single item in the preview. private static final float TWO_ITEM_ICONS_BOX_ASPECT_RATIO = 0.61f; private static final FloatProperty ITEM_ICON_CENTER_OFFSET = new FloatProperty<>("itemIconCenterOffset") { @Override public Float get(TaskbarOverflowView view) { return view.mItemIconCenterOffset; } @Override public void setValue(TaskbarOverflowView view, float value) { view.mItemIconCenterOffset = value; view.invalidate(); } }; private static final IntProperty ITEM_ICON_COLOR_FILTER_OPACITY = new IntProperty<>("itemIconColorFilterOpacity") { @Override public Integer get(TaskbarOverflowView view) { return view.mItemIconColorFilterOpacity; } @Override public void setValue(TaskbarOverflowView view, int value) { view.mItemIconColorFilterOpacity = value; view.invalidate(); } }; private static final FloatProperty ITEM_ICON_SIZE = new FloatProperty<>("itemIconSize") { @Override public Float get(TaskbarOverflowView view) { return view.mItemIconSize; } @Override public void setValue(TaskbarOverflowView view, float value) { view.mItemIconSize = value; view.invalidate(); } }; private static final FloatProperty ITEM_ICON_STROKE_WIDTH = new FloatProperty<>("itemIconStrokeWidth") { @Override public Float get(TaskbarOverflowView view) { return view.mItemIconStrokeWidth; } @Override public void setValue(TaskbarOverflowView view, float value) { view.mItemIconStrokeWidth = value; view.invalidate(); } }; private static final IntProperty LEAVE_BEHIND_OPACITY = new IntProperty<>("leaveBehindOpacity") { @Override public Integer get(TaskbarOverflowView view) { return view.mLeaveBehindOpacity; } @Override public void setValue(TaskbarOverflowView view, int value) { view.mLeaveBehindOpacity = value; view.invalidate(); } }; private static final FloatProperty LEAVE_BEHIND_SIZE = new FloatProperty<>("leaveBehindSize") { @Override public Float get(TaskbarOverflowView view) { return view.mLeaveBehindSize; } @Override public void setValue(TaskbarOverflowView view, float value) { view.mLeaveBehindSize = value; view.invalidate(); } }; private boolean mIsRtlLayout; private final List mItems = new ArrayList(); private int mIconSize; private Paint mItemBackgroundPaint; private final MultiTranslateDelegate mTranslateDelegate = new MultiTranslateDelegate(this); private float mScaleForReorderBounce = 1f; private int mItemBackgroundColor; private int mLeaveBehindColor; // Active means the overflow icon has been pressed, which replaces the app icons with the // leave-behind circle and shows the KQS UI. private boolean mIsActive = false; private ValueAnimator mStateTransitionAnimationWrapper; private float mItemIconCenterOffsetDefault; private float mItemIconCenterOffset; // [0..mItemIconCenterOffsetDefault] private int mItemIconColorFilterOpacity; // [ALPHA_TRANSPARENT..ALPHA_OPAQUE] private float mItemIconSizeDefault; private float mItemIconSizeScaledDown; private float mItemIconSize; // [mItemIconSizeScaledDown..mItemIconSizeDefault] private float mItemIconStrokeWidthDefault; private float mItemIconStrokeWidth; // [0..mItemIconStrokeWidthDefault] private int mLeaveBehindOpacity; // [ALPHA_TRANSPARENT..ALPHA_OPAQUE] private float mLeaveBehindSizeScaledDown; private float mLeaveBehindSizeDefault; private float mLeaveBehindSize; // [mLeaveBehindSizeScaledDown..mLeaveBehindSizeDefault] public TaskbarOverflowView(Context context, AttributeSet attrs) { super(context, attrs); init(); } public TaskbarOverflowView(Context context) { super(context); init(); } /** * Inflates the taskbar overflow button view. * @param resId The resource to inflate the view from. * @param group The parent view. * @param iconSize The size of the overflow button icon. * @param padding The internal padding of the overflow view. * @return A taskbar overflow button. */ public static TaskbarOverflowView inflateIcon(int resId, ViewGroup group, int iconSize, int padding) { LayoutInflater inflater = LayoutInflater.from(group.getContext()); TaskbarOverflowView icon = (TaskbarOverflowView) inflater.inflate(resId, group, false); icon.mIconSize = iconSize; final float taskbarIconRadius = (iconSize - padding * 2f) * IconNormalizer.ICON_VISIBLE_AREA_FACTOR / 2f; icon.mLeaveBehindSizeDefault = taskbarIconRadius; // 1/2 of taskbar app icon size icon.mLeaveBehindSizeScaledDown = icon.mLeaveBehindSizeDefault * LEAVE_BEHIND_SIZE_SCALE_DOWN_MULTIPLIER; icon.mLeaveBehindSize = icon.mLeaveBehindSizeScaledDown; icon.mItemIconStrokeWidthDefault = taskbarIconRadius / 10f; // 1/20 of taskbar app icon size icon.mItemIconStrokeWidth = icon.mItemIconStrokeWidthDefault; icon.mItemIconSizeDefault = 2f * taskbarIconRadius * TWO_ITEM_ICONS_BOX_ASPECT_RATIO; icon.mItemIconSizeScaledDown = icon.mLeaveBehindSizeScaledDown; icon.mItemIconSize = icon.mItemIconSizeDefault; icon.mItemIconCenterOffsetDefault = taskbarIconRadius - icon.mItemIconSizeDefault * IconNormalizer.ICON_VISIBLE_AREA_FACTOR / 2f - icon.mItemIconStrokeWidthDefault; icon.mItemIconCenterOffset = icon.mItemIconCenterOffsetDefault; return icon; } private void init() { mIsRtlLayout = Utilities.isRtl(getResources()); mItemBackgroundPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mItemBackgroundColor = getContext().getColor( com.android.internal.R.color.materialColorInverseOnSurface); mLeaveBehindColor = Themes.getAttrColor(getContext(), android.R.attr.textColorTertiary); setWillNotDraw(false); } @Override protected void onDraw(@NonNull Canvas canvas) { super.onDraw(canvas); drawAppIcons(canvas); drawLeaveBehindCircle(canvas); } private void drawAppIcons(@NonNull Canvas canvas) { mItemBackgroundPaint.setColor(mItemBackgroundColor); float canvasCenterXY = mIconSize / 2f; int adjustedItemIconSize = Math.round(mItemIconSize); float itemIconRadius = adjustedItemIconSize / 2f; int itemsToShow = Math.min(mItems.size(), MAX_ITEMS_IN_PREVIEW); for (int i = itemsToShow - 1; i >= 0; --i) { Drawable icon = mItems.get(mItems.size() - i - 1).icon; if (icon == null) { continue; } float itemCenterX = getItemXOffset(mItemIconCenterOffset, mIsRtlLayout, i, itemsToShow); float itemCenterY = getItemYOffset(mItemIconCenterOffset, i, itemsToShow); Drawable iconCopy = icon.getConstantState().newDrawable().mutate(); iconCopy.setBounds(0, 0, adjustedItemIconSize, adjustedItemIconSize); iconCopy.setColorFilter(new BlendModeColorFilter( ColorUtils.setAlphaComponent(mLeaveBehindColor, mItemIconColorFilterOpacity), BlendMode.SRC_ATOP)); canvas.save(); canvas.translate( canvasCenterXY + itemCenterX - itemIconRadius, canvasCenterXY + itemCenterY - itemIconRadius); canvas.drawCircle(itemIconRadius, itemIconRadius, itemIconRadius * IconNormalizer.ICON_VISIBLE_AREA_FACTOR + mItemIconStrokeWidth, mItemBackgroundPaint); iconCopy.draw(canvas); canvas.restore(); } } private void drawLeaveBehindCircle(@NonNull Canvas canvas) { mItemBackgroundPaint.setColor( ColorUtils.setAlphaComponent(mLeaveBehindColor, mLeaveBehindOpacity)); final float xyCenter = mIconSize / 2f; canvas.drawCircle(xyCenter, xyCenter, mLeaveBehindSize / 2f, mItemBackgroundPaint); } /** * Clears the list of tasks tracked by the view. */ public void clearItems() { mItems.clear(); invalidate(); } /** * Update the view to represent a new list of recent tasks. * @param items Items to be shown in the view. */ public void setItems(List items) { mItems.clear(); mItems.addAll(items); invalidate(); } @VisibleForTesting public List getItemIds() { return mItems.stream().map(task -> task.key.id).toList(); } /** * Called when a task is updated. If the task is contained within the view, it's cached value * gets updated. If the task is shown within the icon, invalidates the view, so the task icon * gets updated. * @param task The updated task. */ public void updateTaskIsShown(Task task) { for (int i = 0; i < mItems.size(); ++i) { if (mItems.get(i).key.id == task.key.id) { mItems.set(i, task); if (i >= mItems.size() - MAX_ITEMS_IN_PREVIEW) { invalidate(); } break; } } } /** * Returns the view's state (whether it shows a set of app icons or a leave-behind circle). */ public boolean getIsActive() { return mIsActive; } /** * Updates the view's state to draw either a set of app icons or a leave-behind circle. * @param isActive The next state of the view. */ public void setIsActive(boolean isActive) { if (mIsActive == isActive) { return; } mIsActive = isActive; if (mStateTransitionAnimationWrapper != null && mStateTransitionAnimationWrapper.isRunning()) { mStateTransitionAnimationWrapper.reverse(); return; } final AnimatorSet stateTransitionAnimation = getStateTransitionAnimation(); mStateTransitionAnimationWrapper = ValueAnimator.ofFloat(0, 1f); mStateTransitionAnimationWrapper.setDuration(mIsActive ? ANIMATION_DURATION_APPS_TO_LEAVE_BEHIND : ANIMATION_DURATION_LEAVE_BEHIND_TO_APPS); mStateTransitionAnimationWrapper.setInterpolator( mIsActive ? Interpolators.STANDARD : Interpolators.EMPHASIZED); mStateTransitionAnimationWrapper.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mStateTransitionAnimationWrapper = null; } }); mStateTransitionAnimationWrapper.addUpdateListener( new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animator) { stateTransitionAnimation.setCurrentPlayTime( (long) (ANIMATION_SET_DURATION * animator.getAnimatedFraction())); } }); mStateTransitionAnimationWrapper.start(); } private AnimatorSet getStateTransitionAnimation() { final AnimatorSet animation = new AnimatorSet(); animation.setInterpolator(Interpolators.LINEAR); animation.playTogether( buildAnimator(ITEM_ICON_CENTER_OFFSET, 0f, mItemIconCenterOffsetDefault, ITEM_ICON_CENTER_OFFSET_ANIMATION_DURATION, 0L, ITEM_ICON_CENTER_OFFSET_ANIMATION_DURATION), buildAnimator(ITEM_ICON_COLOR_FILTER_OPACITY, ALPHA_OPAQUE, ALPHA_TRANSPARENT, ITEM_ICON_COLOR_FILTER_OPACITY_ANIMATION_DURATION, 0L, ANIMATION_SET_DURATION - ITEM_ICON_COLOR_FILTER_OPACITY_ANIMATION_DURATION), buildAnimator(ITEM_ICON_SIZE, mItemIconSizeScaledDown, mItemIconSizeDefault, ITEM_ICON_SIZE_ANIMATION_DURATION, 0L, ITEM_ICON_SIZE_ANIMATION_DURATION), buildAnimator(ITEM_ICON_STROKE_WIDTH, 0f, mItemIconStrokeWidthDefault, ITEM_ICON_STROKE_WIDTH_ANIMATION_DURATION, 0L, ITEM_ICON_STROKE_WIDTH_ANIMATION_DURATION), buildAnimator(LEAVE_BEHIND_OPACITY, ALPHA_OPAQUE, ALPHA_TRANSPARENT, LEAVE_BEHIND_OPACITY_ANIMATION_DURATION, LEAVE_BEHIND_ANIMATIONS_DELAY, ANIMATION_SET_DURATION - LEAVE_BEHIND_ANIMATIONS_DELAY - LEAVE_BEHIND_OPACITY_ANIMATION_DURATION), buildAnimator(LEAVE_BEHIND_SIZE, mLeaveBehindSizeDefault, mLeaveBehindSizeScaledDown, LEAVE_BEHIND_SIZE_ANIMATION_DURATION, LEAVE_BEHIND_ANIMATIONS_DELAY, 0L) ); return animation; } private ObjectAnimator buildAnimator(IntProperty property, int finalValueWhenAnimatingToLeaveBehind, int finalValueWhenAnimatingToAppIcons, long duration, long delayWhenAnimatingToLeaveBehind, long delayWhenAnimatingToAppIcons) { final ObjectAnimator animator = ObjectAnimator.ofInt(this, property, mIsActive ? finalValueWhenAnimatingToLeaveBehind : finalValueWhenAnimatingToAppIcons); applyTiming(animator, duration, delayWhenAnimatingToLeaveBehind, delayWhenAnimatingToAppIcons); return animator; } private ObjectAnimator buildAnimator(FloatProperty property, float finalValueWhenAnimatingToLeaveBehind, float finalValueWhenAnimatingToAppIcons, long duration, long delayWhenAnimatingToLeaveBehind, long delayWhenAnimatingToAppIcons) { final ObjectAnimator animator = ObjectAnimator.ofFloat(this, property, mIsActive ? finalValueWhenAnimatingToLeaveBehind : finalValueWhenAnimatingToAppIcons); applyTiming(animator, duration, delayWhenAnimatingToLeaveBehind, delayWhenAnimatingToAppIcons); return animator; } private void applyTiming(ObjectAnimator animator, long duration, long delayWhenAnimatingToLeaveBehind, long delayWhenAnimatingToAppIcons) { animator.setDuration(duration); animator.setStartDelay( mIsActive ? delayWhenAnimatingToLeaveBehind : delayWhenAnimatingToAppIcons); } @Override public MultiTranslateDelegate getTranslateDelegate() { return mTranslateDelegate; } @Override public float getReorderBounceScale() { return mScaleForReorderBounce; } @Override public void setReorderBounceScale(float scale) { mScaleForReorderBounce = scale; super.setScaleX(scale); super.setScaleY(scale); } private float getItemXOffset(float baseOffset, boolean isRtl, int itemIndex, int itemCount) { // Item with index 1 is on the left in all cases. if (itemIndex == 1) { return (isRtl ? 1 : -1) * baseOffset; } // First item is centered if total number of items shown is 3, on the right otherwise. if (itemIndex == 0) { if (itemCount == 3) { return 0; } return (isRtl ? -1 : 1) * baseOffset; } // Last item is on the right when there are more than 2 items (case which is already handled // as `itemIndex == 1`). if (itemIndex == itemCount - 1) { return (isRtl ? -1 : 1) * baseOffset; } return (isRtl ? 1 : -1) * baseOffset; } private float getItemYOffset(float baseOffset, int itemIndex, int itemCount) { // If icon contains two items, they are both centered vertically. if (itemCount == 2) { return 0; } // First half of items is on top, later half is on bottom. return (itemIndex + 1 <= itemCount / 2 ? -1 : 1) * baseOffset; } }