/* * Copyright (C) 2019 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; import static com.android.launcher3.anim.Interpolators.ACCEL_DEACCEL; import android.animation.Animator; import android.animation.AnimatorSet; import android.animation.ArgbEvaluator; import android.animation.Keyframe; import android.animation.ObjectAnimator; import android.animation.PropertyValuesHolder; import android.animation.ValueAnimator; import android.annotation.Nullable; import android.content.Context; import android.graphics.BlurMaskFilter; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Matrix; import android.graphics.Paint; import android.graphics.Path; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Process; import android.util.AttributeSet; import android.util.FloatProperty; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.core.graphics.ColorUtils; import com.android.launcher3.CellLayout; import com.android.launcher3.DeviceProfile; import com.android.launcher3.Launcher; import com.android.launcher3.LauncherSettings; import com.android.launcher3.R; import com.android.launcher3.anim.AnimatorListeners; import com.android.launcher3.icons.BitmapInfo; import com.android.launcher3.icons.GraphicsUtils; import com.android.launcher3.icons.IconNormalizer; import com.android.launcher3.icons.LauncherIcons; import com.android.launcher3.model.data.WorkspaceItemInfo; import com.android.launcher3.touch.ItemClickHandler; import com.android.launcher3.touch.ItemLongClickListener; import com.android.launcher3.util.SafeCloseable; import com.android.launcher3.views.ActivityContext; import com.android.launcher3.views.DoubleShadowBubbleTextView; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * A BubbleTextView with a ring around it's drawable */ public class PredictedAppIcon extends DoubleShadowBubbleTextView { private static final int RING_SHADOW_COLOR = 0x99000000; private static final float RING_EFFECT_RATIO = 0.095f; private static final long ICON_CHANGE_ANIM_DURATION = 360; private static final long ICON_CHANGE_ANIM_STAGGER = 50; boolean mIsDrawingDot = false; private final DeviceProfile mDeviceProfile; private final Paint mIconRingPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path mRingPath = new Path(); private final int mNormalizedIconSize; private final Path mShapePath; private final Matrix mTmpMatrix = new Matrix(); private final BlurMaskFilter mShadowFilter; private boolean mIsPinned = false; private int mPlateColor; boolean mDrawForDrag = false; // Used for the "slot-machine" education animation. private List mSlotMachineIcons; private Animator mSlotMachineAnim; private float mSlotMachineIconTranslationY; private static final FloatProperty SLOT_MACHINE_TRANSLATION_Y = new FloatProperty("slotMachineTranslationY") { @Override public void setValue(PredictedAppIcon predictedAppIcon, float transY) { predictedAppIcon.mSlotMachineIconTranslationY = transY; predictedAppIcon.invalidate(); } @Override public Float get(PredictedAppIcon predictedAppIcon) { return predictedAppIcon.mSlotMachineIconTranslationY; } }; public PredictedAppIcon(Context context) { this(context, null, 0); } public PredictedAppIcon(Context context, AttributeSet attrs) { this(context, attrs, 0); } public PredictedAppIcon(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); mDeviceProfile = ActivityContext.lookupContext(context).getDeviceProfile(); mNormalizedIconSize = IconNormalizer.getNormalizedCircleSize(getIconSize()); int shadowSize = context.getResources().getDimensionPixelSize( R.dimen.blur_size_thin_outline); mShadowFilter = new BlurMaskFilter(shadowSize, BlurMaskFilter.Blur.OUTER); mShapePath = GraphicsUtils.getShapePath(mNormalizedIconSize); } @Override public void onDraw(Canvas canvas) { int count = canvas.save(); boolean isSlotMachineAnimRunning = mSlotMachineAnim != null; if (!mIsPinned) { drawEffect(canvas); if (isSlotMachineAnimRunning) { // Clip to to outside of the ring during the slot machine animation. canvas.clipPath(mRingPath); } canvas.translate(getWidth() * RING_EFFECT_RATIO, getHeight() * RING_EFFECT_RATIO); canvas.scale(1 - 2 * RING_EFFECT_RATIO, 1 - 2 * RING_EFFECT_RATIO); } if (isSlotMachineAnimRunning) { drawSlotMachineIcons(canvas); } else { super.onDraw(canvas); } canvas.restoreToCount(count); } private void drawSlotMachineIcons(Canvas canvas) { canvas.translate((getWidth() - getIconSize()) / 2f, (getHeight() - getIconSize()) / 2f + mSlotMachineIconTranslationY); for (Drawable icon : mSlotMachineIcons) { icon.setBounds(0, 0, getIconSize(), getIconSize()); icon.draw(canvas); canvas.translate(0, getSlotMachineIconPlusSpacingSize()); } } private float getSlotMachineIconPlusSpacingSize() { return getIconSize() + getOutlineOffsetY(); } @Override protected void drawDotIfNecessary(Canvas canvas) { mIsDrawingDot = true; int count = canvas.save(); canvas.translate(-getWidth() * RING_EFFECT_RATIO, -getHeight() * RING_EFFECT_RATIO); canvas.scale(1 + 2 * RING_EFFECT_RATIO, 1 + 2 * RING_EFFECT_RATIO); super.drawDotIfNecessary(canvas); canvas.restoreToCount(count); mIsDrawingDot = false; } @Override public void applyFromWorkspaceItem(WorkspaceItemInfo info, boolean animate, int staggerIndex) { // Create the slot machine animation first, since it uses the current icon to start. Animator slotMachineAnim = animate ? createSlotMachineAnim(Collections.singletonList(info.bitmap), false) : null; super.applyFromWorkspaceItem(info, animate, staggerIndex); int oldPlateColor = mPlateColor; int newPlateColor = ColorUtils.setAlphaComponent(mDotParams.color, 200); if (!animate) { mPlateColor = newPlateColor; } if (mIsPinned) { setContentDescription(info.contentDescription); } else { setContentDescription( getContext().getString(R.string.hotseat_prediction_content_description, info.contentDescription)); } if (animate) { ValueAnimator plateColorAnim = ValueAnimator.ofObject(new ArgbEvaluator(), oldPlateColor, newPlateColor); plateColorAnim.addUpdateListener(valueAnimator -> { mPlateColor = (int) valueAnimator.getAnimatedValue(); invalidate(); }); AnimatorSet changeIconAnim = new AnimatorSet(); if (slotMachineAnim != null) { changeIconAnim.play(slotMachineAnim); } changeIconAnim.play(plateColorAnim); changeIconAnim.setStartDelay(staggerIndex * ICON_CHANGE_ANIM_STAGGER); changeIconAnim.setDuration(ICON_CHANGE_ANIM_DURATION).start(); } } /** * Returns an Animator that translates the given icons in a "slot-machine" fashion, beginning * and ending with the original icon. */ public @Nullable Animator createSlotMachineAnim(List iconsToAnimate) { return createSlotMachineAnim(iconsToAnimate, true); } /** * Returns an Animator that translates the given icons in a "slot-machine" fashion, beginning * with the original icon, then cycling through the given icons, optionally ending back with * the original icon. * @param endWithOriginalIcon Whether we should land back on the icon we started with, rather * than the last item in iconsToAnimate. */ public @Nullable Animator createSlotMachineAnim(List iconsToAnimate, boolean endWithOriginalIcon) { if (mIsPinned || iconsToAnimate == null || iconsToAnimate.isEmpty()) { return null; } if (mSlotMachineAnim != null) { mSlotMachineAnim.end(); } // Bookend the other animating icons with the original icon on both ends. mSlotMachineIcons = new ArrayList<>(iconsToAnimate.size() + 2); mSlotMachineIcons.add(getIcon()); iconsToAnimate.stream() .map(iconInfo -> iconInfo.newThemedIcon(mContext)) .forEach(mSlotMachineIcons::add); if (endWithOriginalIcon) { mSlotMachineIcons.add(getIcon()); } float finalTrans = -getSlotMachineIconPlusSpacingSize() * (mSlotMachineIcons.size() - 1); Keyframe[] keyframes = new Keyframe[] { Keyframe.ofFloat(0f, 0f), Keyframe.ofFloat(0.82f, finalTrans - getOutlineOffsetY() / 2f), // Overshoot Keyframe.ofFloat(1f, finalTrans) // Ease back into the final position }; keyframes[1].setInterpolator(ACCEL_DEACCEL); keyframes[2].setInterpolator(ACCEL_DEACCEL); mSlotMachineAnim = ObjectAnimator.ofPropertyValuesHolder(this, PropertyValuesHolder.ofKeyframe(SLOT_MACHINE_TRANSLATION_Y, keyframes)); mSlotMachineAnim.addListener(AnimatorListeners.forEndCallback(() -> { mSlotMachineIcons = null; mSlotMachineAnim = null; mSlotMachineIconTranslationY = 0; invalidate(); })); return mSlotMachineAnim; } /** * Removes prediction ring from app icon */ public void pin(WorkspaceItemInfo info) { if (mIsPinned) return; mIsPinned = true; applyFromWorkspaceItem(info); setOnLongClickListener(ItemLongClickListener.INSTANCE_WORKSPACE); ((CellLayout.LayoutParams) getLayoutParams()).canReorder = true; invalidate(); } /** * prepares prediction icon for usage after bind */ public void finishBinding(OnLongClickListener longClickListener) { setOnLongClickListener(longClickListener); ((CellLayout.LayoutParams) getLayoutParams()).canReorder = false; setTextVisibility(false); verifyHighRes(); } @Override public void getIconBounds(Rect outBounds) { super.getIconBounds(outBounds); if (!mIsPinned && !mIsDrawingDot) { int predictionInset = (int) (getIconSize() * RING_EFFECT_RATIO); outBounds.inset(predictionInset, predictionInset); } } public boolean isPinned() { return mIsPinned; } private int getOutlineOffsetX() { return (getMeasuredWidth() - mNormalizedIconSize) / 2; } private int getOutlineOffsetY() { if (mDisplay != DISPLAY_TASKBAR) { return getPaddingTop() + mDeviceProfile.folderIconOffsetYPx; } return (getMeasuredHeight() - mNormalizedIconSize) / 2; } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); updateRingPath(); } @Override public void setTag(Object tag) { super.setTag(tag); updateRingPath(); } private void updateRingPath() { boolean isBadged = false; if (getTag() instanceof WorkspaceItemInfo) { WorkspaceItemInfo info = (WorkspaceItemInfo) getTag(); isBadged = !Process.myUserHandle().equals(info.user) || info.itemType == LauncherSettings.Favorites.ITEM_TYPE_SHORTCUT || info.itemType == LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT; } mRingPath.reset(); mTmpMatrix.setTranslate(getOutlineOffsetX(), getOutlineOffsetY()); mRingPath.addPath(mShapePath, mTmpMatrix); if (isBadged) { float outlineSize = mNormalizedIconSize * RING_EFFECT_RATIO; float iconSize = getIconSize() * (1 - 2 * RING_EFFECT_RATIO); float badgeSize = LauncherIcons.getBadgeSizeForIconSize((int) iconSize) + outlineSize; float scale = badgeSize / mNormalizedIconSize; mTmpMatrix.postTranslate(mNormalizedIconSize, mNormalizedIconSize); mTmpMatrix.preScale(scale, scale); mTmpMatrix.preTranslate(-mNormalizedIconSize, -mNormalizedIconSize); mRingPath.addPath(mShapePath, mTmpMatrix); } } private void drawEffect(Canvas canvas) { // Don't draw ring effect if item is about to be dragged. if (mDrawForDrag) { return; } mIconRingPaint.setColor(RING_SHADOW_COLOR); mIconRingPaint.setMaskFilter(mShadowFilter); canvas.drawPath(mRingPath, mIconRingPaint); mIconRingPaint.setColor(mPlateColor); mIconRingPaint.setMaskFilter(null); canvas.drawPath(mRingPath, mIconRingPaint); } @Override public void getSourceVisualDragBounds(Rect bounds) { super.getSourceVisualDragBounds(bounds); if (!mIsPinned) { int internalSize = (int) (bounds.width() * RING_EFFECT_RATIO); bounds.inset(internalSize, internalSize); } } @Override public SafeCloseable prepareDrawDragView() { mDrawForDrag = true; invalidate(); SafeCloseable r = super.prepareDrawDragView(); return () -> { r.close(); mDrawForDrag = false; }; } /** * Creates and returns a new instance of PredictedAppIcon from WorkspaceItemInfo */ public static PredictedAppIcon createIcon(ViewGroup parent, WorkspaceItemInfo info) { PredictedAppIcon icon = (PredictedAppIcon) LayoutInflater.from(parent.getContext()) .inflate(R.layout.predicted_app_icon, parent, false); icon.applyFromWorkspaceItem(info); icon.setOnClickListener(ItemClickHandler.INSTANCE); icon.setOnFocusChangeListener(Launcher.getLauncher(parent.getContext()).getFocusHandler()); return icon; } /** * Draws Predicted Icon outline on cell layout */ public static class PredictedIconOutlineDrawing extends CellLayout.DelegatedCellDrawing { private final PredictedAppIcon mIcon; private final Paint mOutlinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); public PredictedIconOutlineDrawing(int cellX, int cellY, PredictedAppIcon icon) { mDelegateCellX = cellX; mDelegateCellY = cellY; mIcon = icon; mOutlinePaint.setStyle(Paint.Style.FILL); mOutlinePaint.setColor(Color.argb(24, 245, 245, 245)); } /** * Draws predicted app icon outline under CellLayout */ @Override public void drawUnderItem(Canvas canvas) { canvas.save(); canvas.translate(mIcon.getOutlineOffsetX(), mIcon.getOutlineOffsetY()); canvas.drawPath(mIcon.mShapePath, mOutlinePaint); canvas.restore(); } /** * Draws PredictedAppIcon outline over CellLayout */ @Override public void drawOverItem(Canvas canvas) { // Does nothing } } }