1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.launcher3.uioverrides;
17 
18 import static com.android.app.animation.Interpolators.ACCELERATE_DECELERATE;
19 import static com.android.launcher3.icons.BitmapInfo.FLAG_THEMED;
20 import static com.android.launcher3.icons.FastBitmapDrawable.getDisabledColorFilter;
21 
22 import android.animation.Animator;
23 import android.animation.AnimatorSet;
24 import android.animation.ArgbEvaluator;
25 import android.animation.Keyframe;
26 import android.animation.ObjectAnimator;
27 import android.animation.PropertyValuesHolder;
28 import android.animation.ValueAnimator;
29 import android.annotation.Nullable;
30 import android.content.Context;
31 import android.graphics.BlurMaskFilter;
32 import android.graphics.Canvas;
33 import android.graphics.Color;
34 import android.graphics.Matrix;
35 import android.graphics.Paint;
36 import android.graphics.Path;
37 import android.graphics.Rect;
38 import android.graphics.drawable.Drawable;
39 import android.os.Process;
40 import android.util.AttributeSet;
41 import android.util.FloatProperty;
42 import android.view.LayoutInflater;
43 import android.view.ViewGroup;
44 
45 import androidx.core.graphics.ColorUtils;
46 
47 import com.android.launcher3.DeviceProfile;
48 import com.android.launcher3.Launcher;
49 import com.android.launcher3.LauncherSettings;
50 import com.android.launcher3.R;
51 import com.android.launcher3.anim.AnimatorListeners;
52 import com.android.launcher3.celllayout.CellLayoutLayoutParams;
53 import com.android.launcher3.celllayout.DelegatedCellDrawing;
54 import com.android.launcher3.icons.BitmapInfo;
55 import com.android.launcher3.icons.GraphicsUtils;
56 import com.android.launcher3.icons.IconNormalizer;
57 import com.android.launcher3.icons.LauncherIcons;
58 import com.android.launcher3.model.data.ItemInfoWithIcon;
59 import com.android.launcher3.model.data.WorkspaceItemInfo;
60 import com.android.launcher3.touch.ItemLongClickListener;
61 import com.android.launcher3.util.SafeCloseable;
62 import com.android.launcher3.views.ActivityContext;
63 import com.android.launcher3.views.DoubleShadowBubbleTextView;
64 
65 import java.util.ArrayList;
66 import java.util.Collections;
67 import java.util.List;
68 
69 /**
70  * A BubbleTextView with a ring around it's drawable
71  */
72 public class PredictedAppIcon extends DoubleShadowBubbleTextView {
73 
74     private static final int RING_SHADOW_COLOR = 0x99000000;
75     private static final float RING_EFFECT_RATIO = 0.095f;
76 
77     private static final long ICON_CHANGE_ANIM_DURATION = 360;
78     private static final long ICON_CHANGE_ANIM_STAGGER = 50;
79 
80     boolean mIsDrawingDot = false;
81     private final DeviceProfile mDeviceProfile;
82     private final Paint mIconRingPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
83     private final Path mRingPath = new Path();
84     private final int mNormalizedIconSize;
85     private final Path mShapePath;
86     private final Matrix mTmpMatrix = new Matrix();
87 
88     private final BlurMaskFilter mShadowFilter;
89 
90     private boolean mIsPinned = false;
91     private int mPlateColor;
92     boolean mDrawForDrag = false;
93 
94     // Used for the "slot-machine" education animation.
95     private List<Drawable> mSlotMachineIcons;
96     private Animator mSlotMachineAnim;
97     private float mSlotMachineIconTranslationY;
98 
99     private static final FloatProperty<PredictedAppIcon> SLOT_MACHINE_TRANSLATION_Y =
100             new FloatProperty<PredictedAppIcon>("slotMachineTranslationY") {
101         @Override
102         public void setValue(PredictedAppIcon predictedAppIcon, float transY) {
103             predictedAppIcon.mSlotMachineIconTranslationY = transY;
104             predictedAppIcon.invalidate();
105         }
106 
107         @Override
108         public Float get(PredictedAppIcon predictedAppIcon) {
109             return predictedAppIcon.mSlotMachineIconTranslationY;
110         }
111     };
112 
PredictedAppIcon(Context context)113     public PredictedAppIcon(Context context) {
114         this(context, null, 0);
115     }
116 
PredictedAppIcon(Context context, AttributeSet attrs)117     public PredictedAppIcon(Context context, AttributeSet attrs) {
118         this(context, attrs, 0);
119     }
120 
PredictedAppIcon(Context context, AttributeSet attrs, int defStyle)121     public PredictedAppIcon(Context context, AttributeSet attrs, int defStyle) {
122         super(context, attrs, defStyle);
123         mDeviceProfile = ActivityContext.lookupContext(context).getDeviceProfile();
124         mNormalizedIconSize = IconNormalizer.getNormalizedCircleSize(getIconSize());
125         int shadowSize = context.getResources().getDimensionPixelSize(
126                 R.dimen.blur_size_thin_outline);
127         mShadowFilter = new BlurMaskFilter(shadowSize, BlurMaskFilter.Blur.OUTER);
128         mShapePath = GraphicsUtils.getShapePath(context, mNormalizedIconSize);
129     }
130 
131     @Override
onDraw(Canvas canvas)132     public void onDraw(Canvas canvas) {
133         int count = canvas.save();
134         boolean isSlotMachineAnimRunning = mSlotMachineAnim != null;
135         if (!mIsPinned) {
136             drawEffect(canvas);
137             if (isSlotMachineAnimRunning) {
138                 // Clip to to outside of the ring during the slot machine animation.
139                 canvas.clipPath(mRingPath);
140             }
141             canvas.translate(getWidth() * RING_EFFECT_RATIO, getHeight() * RING_EFFECT_RATIO);
142             canvas.scale(1 - 2 * RING_EFFECT_RATIO, 1 - 2 * RING_EFFECT_RATIO);
143         }
144         if (isSlotMachineAnimRunning) {
145             drawSlotMachineIcons(canvas);
146         } else {
147             super.onDraw(canvas);
148         }
149         canvas.restoreToCount(count);
150     }
151 
drawSlotMachineIcons(Canvas canvas)152     private void drawSlotMachineIcons(Canvas canvas) {
153         canvas.translate((getWidth() - getIconSize()) / 2f,
154                 (getHeight() - getIconSize()) / 2f + mSlotMachineIconTranslationY);
155         for (Drawable icon : mSlotMachineIcons) {
156             icon.setBounds(0, 0, getIconSize(), getIconSize());
157             icon.draw(canvas);
158             canvas.translate(0, getSlotMachineIconPlusSpacingSize());
159         }
160     }
161 
getSlotMachineIconPlusSpacingSize()162     private float getSlotMachineIconPlusSpacingSize() {
163         return getIconSize() + getOutlineOffsetY();
164     }
165 
166     @Override
drawDotIfNecessary(Canvas canvas)167     protected void drawDotIfNecessary(Canvas canvas) {
168         mIsDrawingDot = true;
169         int count = canvas.save();
170         canvas.translate(-getWidth() * RING_EFFECT_RATIO, -getHeight() * RING_EFFECT_RATIO);
171         canvas.scale(1 + 2 * RING_EFFECT_RATIO, 1 + 2 * RING_EFFECT_RATIO);
172         super.drawDotIfNecessary(canvas);
173         canvas.restoreToCount(count);
174         mIsDrawingDot = false;
175     }
176 
177     @Override
applyFromWorkspaceItem(WorkspaceItemInfo info, boolean animate, int staggerIndex)178     public void applyFromWorkspaceItem(WorkspaceItemInfo info, boolean animate, int staggerIndex) {
179         // Create the slot machine animation first, since it uses the current icon to start.
180         Animator slotMachineAnim = animate
181                 ? createSlotMachineAnim(Collections.singletonList(info.bitmap), false)
182                 : null;
183         super.applyFromWorkspaceItem(info, animate, staggerIndex);
184         int oldPlateColor = mPlateColor;
185 
186         int newPlateColor;
187         if (getIcon().isThemed()) {
188             newPlateColor = getResources().getColor(android.R.color.system_accent1_300);
189         } else {
190             float[] hctPlateColor = new float[3];
191             ColorUtils.colorToM3HCT(mDotParams.appColor, hctPlateColor);
192             newPlateColor = ColorUtils.M3HCTToColor(hctPlateColor[0], 36, 85);
193         }
194 
195         if (!animate) {
196             mPlateColor = newPlateColor;
197         }
198         if (mIsPinned) {
199             setContentDescription(info.contentDescription);
200         } else {
201             setContentDescription(
202                     getContext().getString(R.string.hotseat_prediction_content_description,
203                             info.contentDescription));
204         }
205 
206         if (animate) {
207             ValueAnimator plateColorAnim = ValueAnimator.ofObject(new ArgbEvaluator(),
208                     oldPlateColor, newPlateColor);
209             plateColorAnim.addUpdateListener(valueAnimator -> {
210                 mPlateColor = (int) valueAnimator.getAnimatedValue();
211                 invalidate();
212             });
213             AnimatorSet changeIconAnim = new AnimatorSet();
214             if (slotMachineAnim != null) {
215                 changeIconAnim.play(slotMachineAnim);
216             }
217             changeIconAnim.play(plateColorAnim);
218             changeIconAnim.setStartDelay(staggerIndex * ICON_CHANGE_ANIM_STAGGER);
219             changeIconAnim.setDuration(ICON_CHANGE_ANIM_DURATION).start();
220         }
221     }
222 
223     /**
224      * Returns an Animator that translates the given icons in a "slot-machine" fashion, beginning
225      * and ending with the original icon.
226      */
createSlotMachineAnim(List<BitmapInfo> iconsToAnimate)227     public @Nullable Animator createSlotMachineAnim(List<BitmapInfo> iconsToAnimate) {
228         return createSlotMachineAnim(iconsToAnimate, true);
229     }
230 
231     /**
232      * Returns an Animator that translates the given icons in a "slot-machine" fashion, beginning
233      * with the original icon, then cycling through the given icons, optionally ending back with
234      * the original icon.
235      * @param endWithOriginalIcon Whether we should land back on the icon we started with, rather
236      *                            than the last item in iconsToAnimate.
237      */
createSlotMachineAnim(List<BitmapInfo> iconsToAnimate, boolean endWithOriginalIcon)238     public @Nullable Animator createSlotMachineAnim(List<BitmapInfo> iconsToAnimate,
239             boolean endWithOriginalIcon) {
240         if (mIsPinned || iconsToAnimate == null || iconsToAnimate.isEmpty()) {
241             return null;
242         }
243         if (mSlotMachineAnim != null) {
244             mSlotMachineAnim.end();
245         }
246 
247         // Bookend the other animating icons with the original icon on both ends.
248         mSlotMachineIcons = new ArrayList<>(iconsToAnimate.size() + 2);
249         mSlotMachineIcons.add(getIcon());
250         iconsToAnimate.stream()
251                 .map(iconInfo -> iconInfo.newIcon(mContext, FLAG_THEMED))
252                 .forEach(mSlotMachineIcons::add);
253         if (endWithOriginalIcon) {
254             mSlotMachineIcons.add(getIcon());
255         }
256 
257         float finalTrans = -getSlotMachineIconPlusSpacingSize() * (mSlotMachineIcons.size() - 1);
258         Keyframe[] keyframes = new Keyframe[] {
259                 Keyframe.ofFloat(0f, 0f),
260                 Keyframe.ofFloat(0.82f, finalTrans - getOutlineOffsetY() / 2f), // Overshoot
261                 Keyframe.ofFloat(1f, finalTrans) // Ease back into the final position
262         };
263         keyframes[1].setInterpolator(ACCELERATE_DECELERATE);
264         keyframes[2].setInterpolator(ACCELERATE_DECELERATE);
265 
266         mSlotMachineAnim = ObjectAnimator.ofPropertyValuesHolder(this,
267                 PropertyValuesHolder.ofKeyframe(SLOT_MACHINE_TRANSLATION_Y, keyframes));
268         mSlotMachineAnim.addListener(AnimatorListeners.forEndCallback(() -> {
269             mSlotMachineIcons = null;
270             mSlotMachineAnim = null;
271             mSlotMachineIconTranslationY = 0;
272             invalidate();
273         }));
274         return mSlotMachineAnim;
275     }
276 
277     /**
278      * Removes prediction ring from app icon
279      */
pin(WorkspaceItemInfo info)280     public void pin(WorkspaceItemInfo info) {
281         if (mIsPinned) return;
282         mIsPinned = true;
283         applyFromWorkspaceItem(info);
284         setOnLongClickListener(ItemLongClickListener.INSTANCE_WORKSPACE);
285         ((CellLayoutLayoutParams) getLayoutParams()).canReorder = true;
286         invalidate();
287     }
288 
289     /**
290      * prepares prediction icon for usage after bind
291      */
finishBinding(OnLongClickListener longClickListener)292     public void finishBinding(OnLongClickListener longClickListener) {
293         setOnLongClickListener(longClickListener);
294         ((CellLayoutLayoutParams) getLayoutParams()).canReorder = false;
295         setTextVisibility(false);
296         verifyHighRes();
297     }
298 
299     @Override
getIconBounds(Rect outBounds)300     public void getIconBounds(Rect outBounds) {
301         super.getIconBounds(outBounds);
302         if (!mIsPinned && !mIsDrawingDot) {
303             int predictionInset = (int) (getIconSize() * RING_EFFECT_RATIO);
304             outBounds.inset(predictionInset, predictionInset);
305         }
306     }
307 
isPinned()308     public boolean isPinned() {
309         return mIsPinned;
310     }
311 
getOutlineOffsetX()312     private int getOutlineOffsetX() {
313         return (getMeasuredWidth() - mNormalizedIconSize) / 2;
314     }
315 
getOutlineOffsetY()316     private int getOutlineOffsetY() {
317         if (mDisplay != DISPLAY_TASKBAR) {
318             return getPaddingTop() + mDeviceProfile.folderIconOffsetYPx;
319         }
320         return (getMeasuredHeight() - mNormalizedIconSize) / 2;
321     }
322 
323     @Override
onSizeChanged(int w, int h, int oldw, int oldh)324     protected void onSizeChanged(int w, int h, int oldw, int oldh) {
325         super.onSizeChanged(w, h, oldw, oldh);
326         updateRingPath();
327     }
328 
329     @Override
setTag(Object tag)330     public void setTag(Object tag) {
331         super.setTag(tag);
332         updateRingPath();
333     }
334 
updateRingPath()335     private void updateRingPath() {
336         boolean isBadged = false;
337         if (getTag() instanceof WorkspaceItemInfo) {
338             WorkspaceItemInfo info = (WorkspaceItemInfo) getTag();
339             isBadged = !Process.myUserHandle().equals(info.user)
340                     || info.itemType == LauncherSettings.Favorites.ITEM_TYPE_DEEP_SHORTCUT;
341         }
342 
343         mRingPath.reset();
344         mTmpMatrix.setTranslate(getOutlineOffsetX(), getOutlineOffsetY());
345 
346         mRingPath.addPath(mShapePath, mTmpMatrix);
347         if (isBadged) {
348             float outlineSize = mNormalizedIconSize * RING_EFFECT_RATIO;
349             float iconSize = getIconSize() * (1 - 2 * RING_EFFECT_RATIO);
350             float badgeSize = LauncherIcons.getBadgeSizeForIconSize((int) iconSize) + outlineSize;
351             float scale = badgeSize / mNormalizedIconSize;
352             mTmpMatrix.postTranslate(mNormalizedIconSize, mNormalizedIconSize);
353             mTmpMatrix.preScale(scale, scale);
354             mTmpMatrix.preTranslate(-mNormalizedIconSize, -mNormalizedIconSize);
355             mRingPath.addPath(mShapePath, mTmpMatrix);
356         }
357     }
358 
drawEffect(Canvas canvas)359     private void drawEffect(Canvas canvas) {
360         // Don't draw ring effect if item is about to be dragged.
361         if (mDrawForDrag) {
362             return;
363         }
364         mIconRingPaint.setColor(RING_SHADOW_COLOR);
365         mIconRingPaint.setMaskFilter(mShadowFilter);
366         canvas.drawPath(mRingPath, mIconRingPaint);
367         mIconRingPaint.setColor(mPlateColor);
368         mIconRingPaint.setMaskFilter(null);
369         canvas.drawPath(mRingPath, mIconRingPaint);
370     }
371 
372     @Override
setIconDisabled(boolean isDisabled)373     public void setIconDisabled(boolean isDisabled) {
374         super.setIconDisabled(isDisabled);
375         mIconRingPaint.setColorFilter(isDisabled ? getDisabledColorFilter() : null);
376         invalidate();
377     }
378 
379     @Override
setItemInfo(ItemInfoWithIcon itemInfo)380     protected void setItemInfo(ItemInfoWithIcon itemInfo) {
381         super.setItemInfo(itemInfo);
382         setIconDisabled(itemInfo.isDisabled());
383     }
384 
385     @Override
getSourceVisualDragBounds(Rect bounds)386     public void getSourceVisualDragBounds(Rect bounds) {
387         super.getSourceVisualDragBounds(bounds);
388         if (!mIsPinned) {
389             int internalSize = (int) (bounds.width() * RING_EFFECT_RATIO);
390             bounds.inset(internalSize, internalSize);
391         }
392     }
393 
394     @Override
prepareDrawDragView()395     public SafeCloseable prepareDrawDragView() {
396         mDrawForDrag = true;
397         invalidate();
398         SafeCloseable r = super.prepareDrawDragView();
399         return () -> {
400             r.close();
401             mDrawForDrag = false;
402         };
403     }
404 
405     /**
406      * Creates and returns a new instance of PredictedAppIcon from WorkspaceItemInfo
407      */
createIcon(ViewGroup parent, WorkspaceItemInfo info)408     public static PredictedAppIcon createIcon(ViewGroup parent, WorkspaceItemInfo info) {
409         PredictedAppIcon icon = (PredictedAppIcon) LayoutInflater.from(parent.getContext())
410                 .inflate(R.layout.predicted_app_icon, parent, false);
411         icon.applyFromWorkspaceItem(info);
412         Launcher launcher = Launcher.getLauncher(parent.getContext());
413         icon.setOnClickListener(launcher.getItemOnClickListener());
414         icon.setOnFocusChangeListener(launcher.getFocusHandler());
415         return icon;
416     }
417 
418     /**
419      * Draws Predicted Icon outline on cell layout
420      */
421     public static class PredictedIconOutlineDrawing extends DelegatedCellDrawing {
422 
423         private final PredictedAppIcon mIcon;
424         private final Paint mOutlinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
425 
PredictedIconOutlineDrawing(int cellX, int cellY, PredictedAppIcon icon)426         public PredictedIconOutlineDrawing(int cellX, int cellY, PredictedAppIcon icon) {
427             mDelegateCellX = cellX;
428             mDelegateCellY = cellY;
429             mIcon = icon;
430             mOutlinePaint.setStyle(Paint.Style.FILL);
431             mOutlinePaint.setColor(Color.argb(24, 245, 245, 245));
432         }
433 
434         /**
435          * Draws predicted app icon outline under CellLayout
436          */
437         @Override
drawUnderItem(Canvas canvas)438         public void drawUnderItem(Canvas canvas) {
439             canvas.save();
440             canvas.translate(mIcon.getOutlineOffsetX(), mIcon.getOutlineOffsetY());
441             canvas.drawPath(mIcon.mShapePath, mOutlinePaint);
442             canvas.restore();
443         }
444 
445         /**
446          * Draws PredictedAppIcon outline over CellLayout
447          */
448         @Override
drawOverItem(Canvas canvas)449         public void drawOverItem(Canvas canvas) {
450             // Does nothing
451         }
452     }
453 }
454