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