1 /*
2  * Copyright (C) 2017 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 
17 package com.android.launcher3.folder;
18 
19 import static com.android.launcher3.BubbleTextView.DISPLAY_FOLDER;
20 import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.ENTER_INDEX;
21 import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.EXIT_INDEX;
22 import static com.android.launcher3.folder.ClippedFolderIconLayoutRule.MAX_NUM_ITEMS_IN_PREVIEW;
23 import static com.android.launcher3.folder.FolderIcon.DROP_IN_ANIMATION_DURATION;
24 import static com.android.launcher3.graphics.PreloadIconDrawable.newPendingIcon;
25 import static com.android.launcher3.icons.BitmapInfo.FLAG_THEMED;
26 
27 import android.animation.Animator;
28 import android.animation.AnimatorListenerAdapter;
29 import android.animation.ObjectAnimator;
30 import android.animation.ValueAnimator;
31 import android.content.Context;
32 import android.graphics.Canvas;
33 import android.graphics.Path;
34 import android.graphics.PointF;
35 import android.graphics.Rect;
36 import android.graphics.drawable.Drawable;
37 import android.util.FloatProperty;
38 import android.view.View;
39 
40 import androidx.annotation.NonNull;
41 import androidx.annotation.VisibleForTesting;
42 
43 import com.android.launcher3.BubbleTextView;
44 import com.android.launcher3.Utilities;
45 import com.android.launcher3.apppairs.AppPairIcon;
46 import com.android.launcher3.apppairs.AppPairIconDrawingParams;
47 import com.android.launcher3.apppairs.AppPairIconGraphic;
48 import com.android.launcher3.graphics.PreloadIconDrawable;
49 import com.android.launcher3.model.data.AppPairInfo;
50 import com.android.launcher3.model.data.ItemInfo;
51 import com.android.launcher3.model.data.ItemInfoWithIcon;
52 import com.android.launcher3.model.data.WorkspaceItemInfo;
53 import com.android.launcher3.util.Themes;
54 import com.android.launcher3.views.ActivityContext;
55 
56 import java.util.ArrayList;
57 import java.util.List;
58 import java.util.function.Predicate;
59 
60 /**
61  * Manages the drawing and animations of {@link PreviewItemDrawingParams} for a {@link FolderIcon}.
62  */
63 public class PreviewItemManager {
64 
65     private static final FloatProperty<PreviewItemManager> CURRENT_PAGE_ITEMS_TRANS_X =
66             new FloatProperty<PreviewItemManager>("currentPageItemsTransX") {
67                 @Override
68                 public void setValue(PreviewItemManager manager, float v) {
69                     manager.mCurrentPageItemsTransX = v;
70                     manager.onParamsChanged();
71                 }
72 
73                 @Override
74                 public Float get(PreviewItemManager manager) {
75                     return manager.mCurrentPageItemsTransX;
76                 }
77             };
78 
79     private final Context mContext;
80     private final FolderIcon mIcon;
81     @VisibleForTesting
82     public final int mIconSize;
83 
84     // These variables are all associated with the drawing of the preview; they are stored
85     // as member variables for shared usage and to avoid computation on each frame
86     private float mIntrinsicIconSize = -1;
87     private int mTotalWidth = -1;
88     private int mPrevTopPadding = -1;
89     private Drawable mReferenceDrawable = null;
90 
91     private int mNumOfPrevItems = 0;
92 
93     // These hold the first page preview items
94     private ArrayList<PreviewItemDrawingParams> mFirstPageParams = new ArrayList<>();
95     // These hold the current page preview items. It is empty if the current page is the first page.
96     private ArrayList<PreviewItemDrawingParams> mCurrentPageParams = new ArrayList<>();
97 
98     // We clip the preview items during the middle of the animation, so that it does not go outside
99     // of the visual shape. We stop clipping at this threshold, since the preview items ultimately
100     // do not get cropped in their resting state.
101     private final float mClipThreshold;
102     private float mCurrentPageItemsTransX = 0;
103     private boolean mShouldSlideInFirstPage;
104 
105     static final int INITIAL_ITEM_ANIMATION_DURATION = 350;
106     private static final int FINAL_ITEM_ANIMATION_DURATION = 200;
107 
108     private static final int SLIDE_IN_FIRST_PAGE_ANIMATION_DURATION_DELAY = 100;
109     private static final int SLIDE_IN_FIRST_PAGE_ANIMATION_DURATION = 300;
110     private static final int ITEM_SLIDE_IN_OUT_DISTANCE_PX = 200;
111 
PreviewItemManager(FolderIcon icon)112     public PreviewItemManager(FolderIcon icon) {
113         mContext = icon.getContext();
114         mIcon = icon;
115         mIconSize = ActivityContext.lookupContext(
116                 mContext).getDeviceProfile().folderChildIconSizePx;
117         mClipThreshold = Utilities.dpToPx(1f);
118     }
119 
120     /**
121      * @param reverse If true, animates the final item in the preview to be full size. If false,
122      *                animates the first item to its position in the preview.
123      */
createFirstItemAnimation(final boolean reverse, final Runnable onCompleteRunnable)124     public FolderPreviewItemAnim createFirstItemAnimation(final boolean reverse,
125             final Runnable onCompleteRunnable) {
126         return reverse
127                 ? new FolderPreviewItemAnim(this, mFirstPageParams.get(0), 0, 2, -1, -1,
128                 FINAL_ITEM_ANIMATION_DURATION, onCompleteRunnable)
129                 : new FolderPreviewItemAnim(this, mFirstPageParams.get(0), -1, -1, 0, 2,
130                         INITIAL_ITEM_ANIMATION_DURATION, onCompleteRunnable);
131     }
132 
prepareCreateAnimation(final View destView)133     Drawable prepareCreateAnimation(final View destView) {
134         Drawable animateDrawable = destView instanceof AppPairIcon
135                 ? ((AppPairIcon) destView).getIconDrawableArea().getDrawable()
136                 : ((BubbleTextView) destView).getIcon();
137         computePreviewDrawingParams(animateDrawable.getIntrinsicWidth(),
138                 destView.getMeasuredWidth());
139         mReferenceDrawable = animateDrawable;
140         return animateDrawable;
141     }
142 
recomputePreviewDrawingParams()143     public void recomputePreviewDrawingParams() {
144         if (mReferenceDrawable != null) {
145             computePreviewDrawingParams(mReferenceDrawable.getIntrinsicWidth(),
146                     mIcon.getMeasuredWidth());
147         }
148     }
149 
computePreviewDrawingParams(int drawableSize, int totalSize)150     private void computePreviewDrawingParams(int drawableSize, int totalSize) {
151         if (mIntrinsicIconSize != drawableSize || mTotalWidth != totalSize ||
152                 mPrevTopPadding != mIcon.getPaddingTop()) {
153             mIntrinsicIconSize = drawableSize;
154             mTotalWidth = totalSize;
155             mPrevTopPadding = mIcon.getPaddingTop();
156 
157             mIcon.mBackground.setup(mIcon.getContext(), mIcon.mActivity, mIcon, mTotalWidth,
158                     mIcon.getPaddingTop());
159             mIcon.mPreviewLayoutRule.init(mIcon.mBackground.previewSize, mIntrinsicIconSize,
160                     Utilities.isRtl(mIcon.getResources()));
161 
162             updatePreviewItems(false);
163         }
164     }
165 
computePreviewItemDrawingParams(int index, int curNumItems, PreviewItemDrawingParams params)166     PreviewItemDrawingParams computePreviewItemDrawingParams(int index, int curNumItems,
167             PreviewItemDrawingParams params) {
168         // We use an index of -1 to represent an icon on the workspace for the destroy and
169         // create animations
170         if (index == -1) {
171             return getFinalIconParams(params);
172         }
173         return mIcon.mPreviewLayoutRule.computePreviewItemDrawingParams(index, curNumItems, params);
174     }
175 
getFinalIconParams(PreviewItemDrawingParams params)176     private PreviewItemDrawingParams getFinalIconParams(PreviewItemDrawingParams params) {
177         float iconSize = mIcon.mActivity.getDeviceProfile().iconSizePx;
178 
179         final float scale = iconSize / mReferenceDrawable.getIntrinsicWidth();
180         final float trans = (mIcon.mBackground.previewSize - iconSize) / 2;
181 
182         params.update(trans, trans, scale);
183         return params;
184     }
185 
drawParams(Canvas canvas, ArrayList<PreviewItemDrawingParams> params, PointF offset, boolean shouldClipPath, Path clipPath)186     public void drawParams(Canvas canvas, ArrayList<PreviewItemDrawingParams> params,
187             PointF offset, boolean shouldClipPath, Path clipPath) {
188         // The first item should be drawn last (ie. on top of later items)
189         for (int i = params.size() - 1; i >= 0; i--) {
190             PreviewItemDrawingParams p = params.get(i);
191             if (!p.hidden) {
192                 // Exiting param should always be clipped.
193                 boolean isExiting = p.index == EXIT_INDEX;
194                 drawPreviewItem(canvas, p, offset, isExiting | shouldClipPath, clipPath);
195             }
196         }
197     }
198 
199     /**
200      * Draws the preview items on {@param canvas}.
201      */
draw(Canvas canvas)202     public void draw(Canvas canvas) {
203         int saveCount = canvas.getSaveCount();
204         // The items are drawn in coordinates relative to the preview offset
205         PreviewBackground bg = mIcon.getFolderBackground();
206         Path clipPath = bg.getClipPath();
207         float firstPageItemsTransX = 0;
208         if (mShouldSlideInFirstPage) {
209             PointF firstPageOffset = new PointF(bg.basePreviewOffsetX + mCurrentPageItemsTransX,
210                     bg.basePreviewOffsetY);
211             boolean shouldClip = mCurrentPageItemsTransX > mClipThreshold;
212             drawParams(canvas, mCurrentPageParams, firstPageOffset, shouldClip, clipPath);
213             firstPageItemsTransX = -ITEM_SLIDE_IN_OUT_DISTANCE_PX + mCurrentPageItemsTransX;
214         }
215 
216         PointF firstPageOffset = new PointF(bg.basePreviewOffsetX + firstPageItemsTransX,
217                 bg.basePreviewOffsetY);
218         boolean shouldClipFirstPage = firstPageItemsTransX < -mClipThreshold;
219         drawParams(canvas, mFirstPageParams, firstPageOffset, shouldClipFirstPage, clipPath);
220         canvas.restoreToCount(saveCount);
221     }
222 
223     public void onParamsChanged() {
224         mIcon.invalidate();
225     }
226 
227     /**
228      * Draws each preview item.
229      *
230      * @param offset         The offset needed to draw the preview items.
231      * @param shouldClipPath Iff true, clip path using {@param clipPath}.
232      * @param clipPath       The clip path of the folder icon.
233      */
234     private void drawPreviewItem(Canvas canvas, PreviewItemDrawingParams params, PointF offset,
235             boolean shouldClipPath, Path clipPath) {
236         canvas.save();
237         if (shouldClipPath) {
238             canvas.clipPath(clipPath);
239         }
240         canvas.translate(offset.x + params.transX, offset.y + params.transY);
241         canvas.scale(params.scale, params.scale);
242         Drawable d = params.drawable;
243 
244         if (d != null) {
245             Rect bounds = d.getBounds();
246             canvas.save();
247             canvas.translate(-bounds.left, -bounds.top);
248             canvas.scale(mIntrinsicIconSize / bounds.width(), mIntrinsicIconSize / bounds.height());
249             d.draw(canvas);
250             canvas.restore();
251         }
252         canvas.restore();
253     }
254 
255     public void hidePreviewItem(int index, boolean hidden) {
256         // If there are more params than visible in the preview, they are used for enter/exit
257         // animation purposes and they were added to the front of the list.
258         // To index the params properly, we need to skip these params.
259         index = index + Math.max(mFirstPageParams.size() - MAX_NUM_ITEMS_IN_PREVIEW, 0);
260 
261         PreviewItemDrawingParams params = index < mFirstPageParams.size() ?
262                 mFirstPageParams.get(index) : null;
263         if (params != null) {
264             params.hidden = hidden;
265         }
266     }
267 
268     void buildParamsForPage(int page, ArrayList<PreviewItemDrawingParams> params, boolean animate) {
269         List<ItemInfo> items = mIcon.getPreviewItemsOnPage(page);
270 
271         // We adjust the size of the list to match the number of items in the preview.
272         while (items.size() < params.size()) {
273             params.remove(params.size() - 1);
274         }
275         while (items.size() > params.size()) {
276             params.add(new PreviewItemDrawingParams(0, 0, 0));
277         }
278 
279         int numItemsInFirstPagePreview = page == 0 ? items.size() : MAX_NUM_ITEMS_IN_PREVIEW;
280         for (int i = 0; i < params.size(); i++) {
281             PreviewItemDrawingParams p = params.get(i);
282             setDrawable(p, items.get(i));
283 
284             if (!animate) {
285                 if (p.anim != null) {
286                     p.anim.cancel();
287                 }
288                 computePreviewItemDrawingParams(i, numItemsInFirstPagePreview, p);
289                 if (mReferenceDrawable == null) {
290                     mReferenceDrawable = p.drawable;
291                 }
292             } else {
293                 FolderPreviewItemAnim anim = new FolderPreviewItemAnim(this, p, i,
294                         mNumOfPrevItems, i, numItemsInFirstPagePreview, DROP_IN_ANIMATION_DURATION,
295                         null);
296 
297                 if (p.anim != null) {
298                     if (p.anim.hasEqualFinalState(anim)) {
299                         // do nothing, let the current animation finish
300                         continue;
301                     }
302                     p.anim.cancel();
303                 }
304                 p.anim = anim;
305                 p.anim.start();
306             }
307         }
308     }
309 
310     void onFolderClose(int currentPage) {
311         // If we are not closing on the first page, we animate the current page preview items
312         // out, and animate the first page preview items in.
313         mShouldSlideInFirstPage = currentPage != 0;
314         if (mShouldSlideInFirstPage) {
315             mCurrentPageItemsTransX = 0;
316             buildParamsForPage(currentPage, mCurrentPageParams, false);
317             onParamsChanged();
318 
319             ValueAnimator slideAnimator = ObjectAnimator
320                     .ofFloat(this, CURRENT_PAGE_ITEMS_TRANS_X, 0, ITEM_SLIDE_IN_OUT_DISTANCE_PX);
321             slideAnimator.addListener(new AnimatorListenerAdapter() {
322                 @Override
323                 public void onAnimationEnd(Animator animation) {
324                     mCurrentPageParams.clear();
325                 }
326             });
327             slideAnimator.setStartDelay(SLIDE_IN_FIRST_PAGE_ANIMATION_DURATION_DELAY);
328             slideAnimator.setDuration(SLIDE_IN_FIRST_PAGE_ANIMATION_DURATION);
329             slideAnimator.start();
330         }
331     }
332 
333     void updatePreviewItems(boolean animate) {
334         int numOfPrevItemsAux = mFirstPageParams.size();
335         buildParamsForPage(0, mFirstPageParams, animate);
336         mNumOfPrevItems = numOfPrevItemsAux;
337     }
338 
339     void updatePreviewItems(Predicate<ItemInfo> itemCheck) {
340         boolean modified = false;
341         for (PreviewItemDrawingParams param : mFirstPageParams) {
342             if (itemCheck.test(param.item)
343                     || (param.item instanceof AppPairInfo api && api.anyMatch(itemCheck))) {
344                 setDrawable(param, param.item);
345                 modified = true;
346             }
347         }
348         for (PreviewItemDrawingParams param : mCurrentPageParams) {
349             if (itemCheck.test(param.item)
350                     || (param.item instanceof AppPairInfo api && api.anyMatch(itemCheck))) {
351                 setDrawable(param, param.item);
352                 modified = true;
353             }
354         }
355         if (modified) {
356             mIcon.invalidate();
357         }
358     }
359 
360     boolean verifyDrawable(@NonNull Drawable who) {
361         for (int i = 0; i < mFirstPageParams.size(); i++) {
362             if (mFirstPageParams.get(i).drawable == who) {
363                 return true;
364             }
365         }
366         return false;
367     }
368 
369     float getIntrinsicIconSize() {
370         return mIntrinsicIconSize;
371     }
372 
373     /**
374      * Handles the case where items in the preview are either:
375      * - Moving into the preview
376      * - Moving into a new position
377      * - Moving out of the preview
378      *
379      * @param oldItems The list of items in the old preview.
380      * @param newItems The list of items in the new preview.
381      * @param dropped  The item that was dropped onto the FolderIcon.
382      */
383     public void onDrop(List<ItemInfo> oldItems, List<ItemInfo> newItems, ItemInfo dropped) {
384         int numItems = newItems.size();
385         final ArrayList<PreviewItemDrawingParams> params = mFirstPageParams;
386         buildParamsForPage(0, params, false);
387 
388         // New preview items for items that are moving in (except for the dropped item).
389         List<ItemInfo> moveIn = new ArrayList<>();
390         for (ItemInfo newItem : newItems) {
391             if (!oldItems.contains(newItem) && !newItem.equals(dropped)) {
392                 moveIn.add(newItem);
393             }
394         }
395         for (int i = 0; i < moveIn.size(); ++i) {
396             int prevIndex = newItems.indexOf(moveIn.get(i));
397             PreviewItemDrawingParams p = params.get(prevIndex);
398             computePreviewItemDrawingParams(prevIndex, numItems, p);
399             updateTransitionParam(p, moveIn.get(i), ENTER_INDEX, newItems.indexOf(moveIn.get(i)),
400                     numItems);
401         }
402 
403         // Items that are moving into new positions within the preview.
404         for (int newIndex = 0; newIndex < newItems.size(); ++newIndex) {
405             int oldIndex = oldItems.indexOf(newItems.get(newIndex));
406             if (oldIndex >= 0 && newIndex != oldIndex) {
407                 PreviewItemDrawingParams p = params.get(newIndex);
408                 updateTransitionParam(p, newItems.get(newIndex), oldIndex, newIndex, numItems);
409             }
410         }
411 
412         // Old preview items that need to be moved out.
413         List<ItemInfo> moveOut = new ArrayList<>(oldItems);
414         moveOut.removeAll(newItems);
415         for (int i = 0; i < moveOut.size(); ++i) {
416             ItemInfo item = moveOut.get(i);
417             int oldIndex = oldItems.indexOf(item);
418             PreviewItemDrawingParams p = computePreviewItemDrawingParams(oldIndex, numItems, null);
419             updateTransitionParam(p, item, oldIndex, EXIT_INDEX, numItems);
420             params.add(0, p); // We want these items first so that they are on drawn last.
421         }
422 
423         for (int i = 0; i < params.size(); ++i) {
424             if (params.get(i).anim != null) {
425                 params.get(i).anim.start();
426             }
427         }
428     }
429 
430     private void updateTransitionParam(final PreviewItemDrawingParams p, ItemInfo item,
431             int prevIndex, int newIndex, int numItems) {
432         setDrawable(p, item);
433 
434         FolderPreviewItemAnim anim = new FolderPreviewItemAnim(this, p, prevIndex, numItems,
435                 newIndex, numItems, DROP_IN_ANIMATION_DURATION, null);
436         if (p.anim != null && !p.anim.hasEqualFinalState(anim)) {
437             p.anim.cancel();
438         }
439         p.anim = anim;
440     }
441 
442     @VisibleForTesting
443     public void setDrawable(PreviewItemDrawingParams p, ItemInfo item) {
444         if (item instanceof WorkspaceItemInfo wii) {
445             if (wii.hasPromiseIconUi() || (wii.runtimeStatusFlags
446                     & ItemInfoWithIcon.FLAG_SHOW_DOWNLOAD_PROGRESS_MASK) != 0) {
447                 PreloadIconDrawable drawable = newPendingIcon(mContext, wii);
448                 p.drawable = drawable;
449             } else {
450                 p.drawable = wii.newIcon(mContext,
451                         Themes.isThemedIconEnabled(mContext) ? FLAG_THEMED : 0);
452             }
453             p.drawable.setBounds(0, 0, mIconSize, mIconSize);
454         } else if (item instanceof AppPairInfo api) {
455             AppPairIconDrawingParams appPairParams =
456                     new AppPairIconDrawingParams(mContext, DISPLAY_FOLDER);
457             p.drawable = AppPairIconGraphic.composeDrawable(api, appPairParams);
458             p.drawable.setBounds(0, 0, mIconSize, mIconSize);
459         }
460 
461         p.item = item;
462         // Set the callback to FolderIcon as it is responsible to drawing the icon. The
463         // callback will be released when the folder is opened.
464         p.drawable.setCallback(mIcon);
465     }
466 }
467