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