1 /*
2  * Copyright (C) 2018 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.popup;
18 
19 import static com.android.launcher3.anim.Interpolators.ACCEL_DEACCEL;
20 
21 import android.animation.Animator;
22 import android.animation.AnimatorListenerAdapter;
23 import android.animation.AnimatorSet;
24 import android.animation.ObjectAnimator;
25 import android.animation.TimeInterpolator;
26 import android.animation.ValueAnimator;
27 import android.content.Context;
28 import android.content.res.Resources;
29 import android.graphics.CornerPathEffect;
30 import android.graphics.Outline;
31 import android.graphics.Paint;
32 import android.graphics.Rect;
33 import android.graphics.drawable.ShapeDrawable;
34 import android.util.AttributeSet;
35 import android.util.Pair;
36 import android.view.Gravity;
37 import android.view.LayoutInflater;
38 import android.view.View;
39 import android.view.ViewGroup;
40 import android.view.ViewOutlineProvider;
41 import android.widget.FrameLayout;
42 
43 import com.android.launcher3.AbstractFloatingView;
44 import com.android.launcher3.BaseDraggingActivity;
45 import com.android.launcher3.InsettableFrameLayout;
46 import com.android.launcher3.LauncherAnimUtils;
47 import com.android.launcher3.R;
48 import com.android.launcher3.Utilities;
49 import com.android.launcher3.anim.RevealOutlineAnimation;
50 import com.android.launcher3.anim.RoundedRectRevealOutlineProvider;
51 import com.android.launcher3.dragndrop.DragLayer;
52 import com.android.launcher3.graphics.TriangleShape;
53 import com.android.launcher3.util.Themes;
54 import com.android.launcher3.views.BaseDragLayer;
55 
56 import java.util.ArrayList;
57 import java.util.Collections;
58 
59 /**
60  * A container for shortcuts to deep links and notifications associated with an app.
61  *
62  * @param <T> The activity on with the popup shows
63  */
64 public abstract class ArrowPopup<T extends BaseDraggingActivity> extends AbstractFloatingView {
65 
66     private final Rect mTempRect = new Rect();
67 
68     protected final LayoutInflater mInflater;
69     private final float mOutlineRadius;
70     protected final T mLauncher;
71     protected final boolean mIsRtl;
72 
73     private final int mArrowOffset;
74     private final View mArrow;
75 
76     protected boolean mIsLeftAligned;
77     protected boolean mIsAboveIcon;
78     private int mGravity;
79 
80     protected Animator mOpenCloseAnimator;
81     protected boolean mDeferContainerRemoval;
82     private final Rect mStartRect = new Rect();
83     private final Rect mEndRect = new Rect();
84 
ArrowPopup(Context context, AttributeSet attrs, int defStyleAttr)85     public ArrowPopup(Context context, AttributeSet attrs, int defStyleAttr) {
86         super(context, attrs, defStyleAttr);
87         mInflater = LayoutInflater.from(context);
88         mOutlineRadius = Themes.getDialogCornerRadius(context);
89         mLauncher = BaseDraggingActivity.fromContext(context);
90         mIsRtl = Utilities.isRtl(getResources());
91 
92         setClipToOutline(true);
93         setOutlineProvider(new ViewOutlineProvider() {
94             @Override
95             public void getOutline(View view, Outline outline) {
96                 outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mOutlineRadius);
97             }
98         });
99 
100         // Initialize arrow view
101         final Resources resources = getResources();
102         final int arrowWidth = resources.getDimensionPixelSize(R.dimen.popup_arrow_width);
103         final int arrowHeight = resources.getDimensionPixelSize(R.dimen.popup_arrow_height);
104         mArrow = new View(context);
105         mArrow.setLayoutParams(new DragLayer.LayoutParams(arrowWidth, arrowHeight));
106         mArrowOffset = resources.getDimensionPixelSize(R.dimen.popup_arrow_vertical_offset);
107     }
108 
ArrowPopup(Context context, AttributeSet attrs)109     public ArrowPopup(Context context, AttributeSet attrs) {
110         this(context, attrs, 0);
111     }
112 
ArrowPopup(Context context)113     public ArrowPopup(Context context) {
114         this(context, null, 0);
115     }
116 
117     @Override
handleClose(boolean animate)118     protected void handleClose(boolean animate) {
119         if (animate) {
120             animateClose();
121         } else {
122             closeComplete();
123         }
124     }
125 
126     /**
127      * Utility method for inflating and adding a view
128      */
inflateAndAdd(int resId, ViewGroup container)129     public <R extends View> R inflateAndAdd(int resId, ViewGroup container) {
130         View view = mInflater.inflate(resId, container, false);
131         container.addView(view);
132         return (R) view;
133     }
134 
135     /**
136      * Utility method for inflating and adding a view
137      */
inflateAndAdd(int resId, ViewGroup container, int index)138     public <R extends View> R inflateAndAdd(int resId, ViewGroup container, int index) {
139         View view = mInflater.inflate(resId, container, false);
140         container.addView(view, index);
141         return (R) view;
142     }
143 
144     /**
145      * Called when all view inflation and reordering in complete.
146      */
onInflationComplete(boolean isReversed)147     protected void onInflationComplete(boolean isReversed) { }
148 
149     /**
150      * Shows the popup at the desired location, optionally reversing the children.
151      * @param viewsToFlip number of views from the top to to flip in case of reverse order
152      */
reorderAndShow(int viewsToFlip)153     protected void reorderAndShow(int viewsToFlip) {
154         setVisibility(View.INVISIBLE);
155         mIsOpen = true;
156         getPopupContainer().addView(this);
157         orientAboutObject();
158 
159         boolean reverseOrder = mIsAboveIcon;
160         if (reverseOrder) {
161             int count = getChildCount();
162             ArrayList<View> allViews = new ArrayList<>(count);
163             for (int i = 0; i < count; i++) {
164                 if (i == viewsToFlip) {
165                     Collections.reverse(allViews);
166                 }
167                 allViews.add(getChildAt(i));
168             }
169             Collections.reverse(allViews);
170             removeAllViews();
171             for (int i = 0; i < count; i++) {
172                 addView(allViews.get(i));
173             }
174 
175             orientAboutObject();
176         }
177         onInflationComplete(reverseOrder);
178 
179         // Add the arrow.
180         final Resources res = getResources();
181         final int arrowCenterOffset = res.getDimensionPixelSize(isAlignedWithStart()
182                 ? R.dimen.popup_arrow_horizontal_center_start
183                 : R.dimen.popup_arrow_horizontal_center_end);
184         final int halfArrowWidth = res.getDimensionPixelSize(R.dimen.popup_arrow_width) / 2;
185         getPopupContainer().addView(mArrow);
186         DragLayer.LayoutParams arrowLp = (DragLayer.LayoutParams) mArrow.getLayoutParams();
187         if (mIsLeftAligned) {
188             mArrow.setX(getX() + arrowCenterOffset - halfArrowWidth);
189         } else {
190             mArrow.setX(getX() + getMeasuredWidth() - arrowCenterOffset - halfArrowWidth);
191         }
192 
193         if (Gravity.isVertical(mGravity)) {
194             // This is only true if there wasn't room for the container next to the icon,
195             // so we centered it instead. In that case we don't want to showDefaultOptions the arrow.
196             mArrow.setVisibility(INVISIBLE);
197         } else {
198             ShapeDrawable arrowDrawable = new ShapeDrawable(TriangleShape.create(
199                     arrowLp.width, arrowLp.height, !mIsAboveIcon));
200             Paint arrowPaint = arrowDrawable.getPaint();
201             arrowPaint.setColor(Themes.getAttrColor(getContext(), R.attr.popupColorPrimary));
202             // The corner path effect won't be reflected in the shadow, but shouldn't be noticeable.
203             int radius = getResources().getDimensionPixelSize(R.dimen.popup_arrow_corner_radius);
204             arrowPaint.setPathEffect(new CornerPathEffect(radius));
205             mArrow.setBackground(arrowDrawable);
206             // Clip off the part of the arrow that is underneath the popup.
207             if (mIsAboveIcon) {
208                 mArrow.setClipBounds(new Rect(0, -mArrowOffset, arrowLp.width, arrowLp.height));
209             } else {
210                 mArrow.setClipBounds(new Rect(0, 0, arrowLp.width, arrowLp.height + mArrowOffset));
211             }
212             mArrow.setElevation(getElevation());
213         }
214 
215         mArrow.setPivotX(arrowLp.width / 2);
216         mArrow.setPivotY(mIsAboveIcon ? arrowLp.height : 0);
217 
218         animateOpen();
219     }
220 
isAlignedWithStart()221     protected boolean isAlignedWithStart() {
222         return mIsLeftAligned && !mIsRtl || !mIsLeftAligned && mIsRtl;
223     }
224 
225     /**
226      * Provide the location of the target object relative to the dragLayer.
227      */
getTargetObjectLocation(Rect outPos)228     protected abstract void getTargetObjectLocation(Rect outPos);
229 
230     /**
231      * Orients this container above or below the given icon, aligning with the left or right.
232      *
233      * These are the preferred orientations, in order (RTL prefers right-aligned over left):
234      * - Above and left-aligned
235      * - Above and right-aligned
236      * - Below and left-aligned
237      * - Below and right-aligned
238      *
239      * So we always align left if there is enough horizontal space
240      * and align above if there is enough vertical space.
241      */
orientAboutObject()242     protected void orientAboutObject() {
243         orientAboutObject(true /* allowAlignLeft */, true /* allowAlignRight */);
244     }
245 
246     /**
247      * @see #orientAboutObject()
248      *
249      * @param allowAlignLeft Set to false if we already tried aligning left and didn't have room.
250      * @param allowAlignRight Set to false if we already tried aligning right and didn't have room.
251      * TODO: Can we test this with all permutations of widths/heights and icon locations + RTL?
252      */
orientAboutObject(boolean allowAlignLeft, boolean allowAlignRight)253     private void orientAboutObject(boolean allowAlignLeft, boolean allowAlignRight) {
254         measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED);
255         int width = getMeasuredWidth();
256         int extraVerticalSpace = mArrow.getLayoutParams().height + mArrowOffset
257                 + getResources().getDimensionPixelSize(R.dimen.popup_vertical_padding);
258         int height = getMeasuredHeight() + extraVerticalSpace;
259 
260         getTargetObjectLocation(mTempRect);
261         InsettableFrameLayout dragLayer = getPopupContainer();
262         Rect insets = dragLayer.getInsets();
263 
264         // Align left (right in RTL) if there is room.
265         int leftAlignedX = mTempRect.left;
266         int rightAlignedX = mTempRect.right - width;
267         mIsLeftAligned = !mIsRtl ? allowAlignLeft : !allowAlignRight;
268         int x = mIsLeftAligned ? leftAlignedX : rightAlignedX;
269 
270         // Offset x so that the arrow and shortcut icons are center-aligned with the original icon.
271         int iconWidth = mTempRect.width();
272         Resources resources = getResources();
273         int xOffset;
274         if (isAlignedWithStart()) {
275             // Aligning with the shortcut icon.
276             int shortcutIconWidth = resources.getDimensionPixelSize(R.dimen.deep_shortcut_icon_size);
277             int shortcutPaddingStart = resources.getDimensionPixelSize(
278                     R.dimen.popup_padding_start);
279             xOffset = iconWidth / 2 - shortcutIconWidth / 2 - shortcutPaddingStart;
280         } else {
281             // Aligning with the drag handle.
282             int shortcutDragHandleWidth = resources.getDimensionPixelSize(
283                     R.dimen.deep_shortcut_drag_handle_size);
284             int shortcutPaddingEnd = resources.getDimensionPixelSize(
285                     R.dimen.popup_padding_end);
286             xOffset = iconWidth / 2 - shortcutDragHandleWidth / 2 - shortcutPaddingEnd;
287         }
288         x += mIsLeftAligned ? xOffset : -xOffset;
289 
290         // Check whether we can still align as we originally wanted, now that we've calculated x.
291         if (!allowAlignLeft && !allowAlignRight) {
292             // We've already tried both ways and couldn't make it fit. onLayout() will set the
293             // gravity to CENTER_HORIZONTAL, but continue below to update y.
294         } else {
295             boolean canBeLeftAligned = x + width + insets.left
296                     < dragLayer.getWidth() - insets.right;
297             boolean canBeRightAligned = x > insets.left;
298             boolean alignmentStillValid = mIsLeftAligned && canBeLeftAligned
299                     || !mIsLeftAligned && canBeRightAligned;
300             if (!alignmentStillValid) {
301                 // Try again, but don't allow this alignment we already know won't work.
302                 orientAboutObject(allowAlignLeft && !mIsLeftAligned /* allowAlignLeft */,
303                         allowAlignRight && mIsLeftAligned /* allowAlignRight */);
304                 return;
305             }
306         }
307 
308         // Open above icon if there is room.
309         int iconHeight = mTempRect.height();
310         int y = mTempRect.top - height;
311         mIsAboveIcon = y > dragLayer.getTop() + insets.top;
312         if (!mIsAboveIcon) {
313             y = mTempRect.top + iconHeight + extraVerticalSpace;
314         }
315 
316         // Insets are added later, so subtract them now.
317         x -= insets.left;
318         y -= insets.top;
319 
320         mGravity = 0;
321         if (y + height > dragLayer.getBottom() - insets.bottom) {
322             // The container is opening off the screen, so just center it in the drag layer instead.
323             mGravity = Gravity.CENTER_VERTICAL;
324             // Put the container next to the icon, preferring the right side in ltr (left in rtl).
325             int rightSide = leftAlignedX + iconWidth - insets.left;
326             int leftSide = rightAlignedX - iconWidth - insets.left;
327             if (!mIsRtl) {
328                 if (rightSide + width < dragLayer.getRight()) {
329                     x = rightSide;
330                     mIsLeftAligned = true;
331                 } else {
332                     x = leftSide;
333                     mIsLeftAligned = false;
334                 }
335             } else {
336                 if (leftSide > dragLayer.getLeft()) {
337                     x = leftSide;
338                     mIsLeftAligned = false;
339                 } else {
340                     x = rightSide;
341                     mIsLeftAligned = true;
342                 }
343             }
344             mIsAboveIcon = true;
345         }
346 
347         setX(x);
348         if (Gravity.isVertical(mGravity)) {
349             return;
350         }
351 
352         FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams();
353         FrameLayout.LayoutParams arrowLp = (FrameLayout.LayoutParams) mArrow.getLayoutParams();
354         if (mIsAboveIcon) {
355             arrowLp.gravity = lp.gravity = Gravity.BOTTOM;
356             lp.bottomMargin = getPopupContainer().getHeight() - y - getMeasuredHeight() - insets.top;
357             arrowLp.bottomMargin = lp.bottomMargin - arrowLp.height - mArrowOffset - insets.bottom;
358         } else {
359             arrowLp.gravity = lp.gravity = Gravity.TOP;
360             lp.topMargin = y + insets.top;
361             arrowLp.topMargin = lp.topMargin - insets.top - arrowLp.height - mArrowOffset;
362         }
363     }
364 
365     @Override
onLayout(boolean changed, int l, int t, int r, int b)366     protected void onLayout(boolean changed, int l, int t, int r, int b) {
367         super.onLayout(changed, l, t, r, b);
368 
369         // enforce contained is within screen
370         BaseDragLayer dragLayer = getPopupContainer();
371         Rect insets = dragLayer.getInsets();
372         if (getTranslationX() + l < insets.left
373                 || getTranslationX() + r > dragLayer.getWidth() - insets.right) {
374             // If we are still off screen, center horizontally too.
375             mGravity |= Gravity.CENTER_HORIZONTAL;
376         }
377 
378         if (Gravity.isHorizontal(mGravity)) {
379             setX(dragLayer.getWidth() / 2 - getMeasuredWidth() / 2);
380             mArrow.setVisibility(INVISIBLE);
381         }
382         if (Gravity.isVertical(mGravity)) {
383             setY(dragLayer.getHeight() / 2 - getMeasuredHeight() / 2);
384         }
385     }
386 
387     @Override
getAccessibilityTarget()388     protected Pair<View, String> getAccessibilityTarget() {
389         return Pair.create(this, "");
390     }
391 
392     @Override
getAccessibilityInitialFocusView()393     protected View getAccessibilityInitialFocusView() {
394         return getChildCount() > 0 ? getChildAt(0) : this;
395     }
396 
animateOpen()397     private void animateOpen() {
398         setVisibility(View.VISIBLE);
399 
400         final AnimatorSet openAnim = new AnimatorSet();
401         final Resources res = getResources();
402         final long revealDuration = (long) res.getInteger(R.integer.config_popupOpenCloseDuration);
403         final long arrowDuration = res.getInteger(R.integer.config_popupArrowOpenCloseDuration);
404         final TimeInterpolator revealInterpolator = ACCEL_DEACCEL;
405 
406         // Rectangular reveal.
407         mEndRect.set(0, 0, getMeasuredWidth(), getMeasuredHeight());
408         final ValueAnimator revealAnim = createOpenCloseOutlineProvider()
409                 .createRevealAnimator(this, false);
410         revealAnim.setDuration(revealDuration);
411         revealAnim.setInterpolator(revealInterpolator);
412         // Clip the popup to the initial outline while the notification dot and arrow animate.
413         revealAnim.start();
414         revealAnim.pause();
415 
416         ValueAnimator fadeIn = ValueAnimator.ofFloat(0, 1);
417         fadeIn.setDuration(revealDuration + arrowDuration);
418         fadeIn.setInterpolator(revealInterpolator);
419         fadeIn.addUpdateListener(anim -> {
420             float alpha = (float) anim.getAnimatedValue();
421             mArrow.setAlpha(alpha);
422             setAlpha(revealAnim.isStarted() ? alpha : 0);
423         });
424         openAnim.play(fadeIn);
425 
426         // Animate the arrow.
427         mArrow.setScaleX(0);
428         mArrow.setScaleY(0);
429         Animator arrowScale = ObjectAnimator.ofFloat(mArrow, LauncherAnimUtils.SCALE_PROPERTY, 1)
430                 .setDuration(arrowDuration);
431 
432         openAnim.addListener(new AnimatorListenerAdapter() {
433             @Override
434             public void onAnimationEnd(Animator animation) {
435                 setAlpha(1f);
436                 announceAccessibilityChanges();
437                 mOpenCloseAnimator = null;
438             }
439         });
440 
441         mOpenCloseAnimator = openAnim;
442         openAnim.playSequentially(arrowScale, revealAnim);
443         openAnim.start();
444     }
445 
animateClose()446     protected void animateClose() {
447         if (!mIsOpen) {
448             return;
449         }
450         if (getOutlineProvider() instanceof RevealOutlineAnimation) {
451             ((RevealOutlineAnimation) getOutlineProvider()).getOutline(mEndRect);
452         } else {
453             mEndRect.set(0, 0, getMeasuredWidth(), getMeasuredHeight());
454         }
455         if (mOpenCloseAnimator != null) {
456             mOpenCloseAnimator.cancel();
457         }
458         mIsOpen = false;
459 
460 
461         final AnimatorSet closeAnim = new AnimatorSet();
462         final Resources res = getResources();
463         final TimeInterpolator revealInterpolator = ACCEL_DEACCEL;
464         final long revealDuration = res.getInteger(R.integer.config_popupOpenCloseDuration);
465         final long arrowDuration = res.getInteger(R.integer.config_popupArrowOpenCloseDuration);
466 
467         // Hide the arrow
468         Animator scaleArrow = ObjectAnimator.ofFloat(mArrow, LauncherAnimUtils.SCALE_PROPERTY, 0)
469                 .setDuration(arrowDuration);
470 
471         // Rectangular reveal (reversed).
472         final ValueAnimator revealAnim = createOpenCloseOutlineProvider()
473                 .createRevealAnimator(this, true);
474         revealAnim.setDuration(revealDuration);
475         revealAnim.setInterpolator(revealInterpolator);
476         closeAnim.playSequentially(revealAnim, scaleArrow);
477 
478         ValueAnimator fadeOut = ValueAnimator.ofFloat(getAlpha(), 0);
479         fadeOut.setDuration(revealDuration + arrowDuration);
480         fadeOut.setInterpolator(revealInterpolator);
481         fadeOut.addUpdateListener(anim -> {
482             float alpha = (float) anim.getAnimatedValue();
483             mArrow.setAlpha(alpha);
484             setAlpha(scaleArrow.isStarted() ? 0 : alpha);
485         });
486         closeAnim.play(fadeOut);
487 
488         onCreateCloseAnimation(closeAnim);
489         closeAnim.addListener(new AnimatorListenerAdapter() {
490             @Override
491             public void onAnimationEnd(Animator animation) {
492                 mOpenCloseAnimator = null;
493                 if (mDeferContainerRemoval) {
494                     setVisibility(INVISIBLE);
495                 } else {
496                     closeComplete();
497                 }
498             }
499         });
500         mOpenCloseAnimator = closeAnim;
501         closeAnim.start();
502     }
503 
504     /**
505      * Called when creating the close transition allowing subclass can add additional animations.
506      */
onCreateCloseAnimation(AnimatorSet anim)507     protected void onCreateCloseAnimation(AnimatorSet anim) { }
508 
createOpenCloseOutlineProvider()509     private RoundedRectRevealOutlineProvider createOpenCloseOutlineProvider() {
510         Resources res = getResources();
511         int arrowCenterX = res.getDimensionPixelSize(mIsLeftAligned ^ mIsRtl ?
512                 R.dimen.popup_arrow_horizontal_center_start:
513                 R.dimen.popup_arrow_horizontal_center_end);
514         int halfArrowWidth = res.getDimensionPixelSize(R.dimen.popup_arrow_width) / 2;
515         float arrowCornerRadius = res.getDimension(R.dimen.popup_arrow_corner_radius);
516         if (!mIsLeftAligned) {
517             arrowCenterX = getMeasuredWidth() - arrowCenterX;
518         }
519         int arrowCenterY = mIsAboveIcon ? getMeasuredHeight() : 0;
520 
521         mStartRect.set(arrowCenterX - halfArrowWidth, arrowCenterY, arrowCenterX + halfArrowWidth,
522                 arrowCenterY);
523 
524         return new RoundedRectRevealOutlineProvider
525                 (arrowCornerRadius, mOutlineRadius, mStartRect, mEndRect);
526     }
527 
528     /**
529      * Closes the popup without animation.
530      */
closeComplete()531     protected void closeComplete() {
532         if (mOpenCloseAnimator != null) {
533             mOpenCloseAnimator.cancel();
534             mOpenCloseAnimator = null;
535         }
536         mIsOpen = false;
537         mDeferContainerRemoval = false;
538         getPopupContainer().removeView(this);
539         getPopupContainer().removeView(mArrow);
540     }
541 
getPopupContainer()542     protected BaseDragLayer getPopupContainer() {
543         return mLauncher.getDragLayer();
544     }
545 }
546