1 /*
2  * Copyright (C) 2008 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.views;
18 
19 import android.animation.Animator;
20 import android.animation.AnimatorListenerAdapter;
21 import android.animation.AnimatorSet;
22 import android.animation.ObjectAnimator;
23 import android.content.Context;
24 import android.content.res.Configuration;
25 import android.content.res.TypedArray;
26 import android.graphics.CornerPathEffect;
27 import android.graphics.Paint;
28 import android.graphics.Rect;
29 import android.graphics.drawable.ShapeDrawable;
30 import android.os.Handler;
31 import android.util.IntProperty;
32 import android.util.Log;
33 import android.view.ContextThemeWrapper;
34 import android.view.Gravity;
35 import android.view.MotionEvent;
36 import android.view.View;
37 import android.view.ViewGroup;
38 import android.widget.LinearLayout;
39 import android.widget.TextView;
40 
41 import androidx.annotation.Nullable;
42 import androidx.annotation.Px;
43 
44 import com.android.app.animation.Interpolators;
45 import com.android.launcher3.AbstractFloatingView;
46 import com.android.launcher3.DeviceProfile;
47 import com.android.launcher3.R;
48 import com.android.launcher3.anim.AnimatorListeners;
49 import com.android.launcher3.dragndrop.DragLayer;
50 import com.android.launcher3.graphics.TriangleShape;
51 
52 /**
53  * A base class for arrow tip view in launcher.
54  */
55 public class ArrowTipView extends AbstractFloatingView {
56 
57     private static final String TAG = "ArrowTipView";
58     private static final long AUTO_CLOSE_TIMEOUT_MILLIS = 10 * 1000;
59     private static final long SHOW_DELAY_MS = 200;
60     private static final long SHOW_DURATION_MS = 300;
61     private static final long HIDE_DURATION_MS = 100;
62 
63     public static final IntProperty<ArrowTipView> TEXT_ALPHA =
64             new IntProperty<>("textAlpha") {
65                 @Override
66                 public void setValue(ArrowTipView view, int v) {
67                     view.setTextAlpha(v);
68                 }
69 
70                 @Override
71                 public Integer get(ArrowTipView view) {
72                     return view.getTextAlpha();
73                 }
74             };
75 
76     private final ActivityContext mActivityContext;
77     private final Handler mHandler = new Handler();
78     private boolean mIsPointingUp;
79     private Runnable mOnClosed;
80     private View mArrowView;
81     private final int mArrowWidth;
82     private final int mArrowMinOffset;
83     private final int mArrowViewPaintColor;
84 
85     private AnimatorSet mOpenAnimator = new AnimatorSet();
86     private AnimatorSet mCloseAnimator = new AnimatorSet();
87 
88     private int mTextAlpha;
89 
ArrowTipView(Context context)90     public ArrowTipView(Context context) {
91         this(context, false);
92     }
93 
ArrowTipView(Context context, boolean isPointingUp)94     public ArrowTipView(Context context, boolean isPointingUp) {
95         this(context, isPointingUp, R.layout.arrow_toast);
96     }
97 
ArrowTipView(Context context, boolean isPointingUp, int layoutId)98     public ArrowTipView(Context context, boolean isPointingUp, int layoutId) {
99         super(context, null, 0);
100         mActivityContext = ActivityContext.lookupContext(context);
101         mIsPointingUp = isPointingUp;
102         mArrowWidth = context.getResources().getDimensionPixelSize(
103                 R.dimen.arrow_toast_arrow_width);
104         mArrowMinOffset = context.getResources().getDimensionPixelSize(
105                 R.dimen.dynamic_grid_cell_border_spacing);
106         TypedArray ta = context.obtainStyledAttributes(R.styleable.ArrowTipView);
107         // Set style to default to avoid inflation issues with missing attributes.
108         if (!ta.hasValue(R.styleable.ArrowTipView_arrowTipBackground)
109                 || !ta.hasValue(R.styleable.ArrowTipView_arrowTipTextColor)) {
110             context = new ContextThemeWrapper(context, R.style.ArrowTipStyle);
111         }
112         mArrowViewPaintColor = ta.getColor(R.styleable.ArrowTipView_arrowTipBackground,
113                 context.getColor(R.color.arrow_tip_view_bg));
114         ta.recycle();
115         init(context, layoutId);
116     }
117 
118     @Override
onControllerInterceptTouchEvent(MotionEvent ev)119     public boolean onControllerInterceptTouchEvent(MotionEvent ev) {
120         if (ev.getAction() == MotionEvent.ACTION_DOWN) {
121             close(true);
122             if (mActivityContext.getDragLayer().isEventOverView(this, ev)) {
123                 return true;
124             }
125         }
126         return false;
127     }
128 
129     @Override
handleClose(boolean animate)130     protected void handleClose(boolean animate) {
131         if (mOpenAnimator.isStarted()) {
132             mOpenAnimator.cancel();
133         }
134         if (mIsOpen) {
135             if (animate) {
136                 mCloseAnimator.addListener(AnimatorListeners.forSuccessCallback(
137                         () -> mActivityContext.getDragLayer().removeView(this)));
138                 mCloseAnimator.start();
139             } else {
140                 mCloseAnimator.cancel();
141                 mActivityContext.getDragLayer().removeView(this);
142             }
143             if (mOnClosed != null) mOnClosed.run();
144             mIsOpen = false;
145         }
146     }
147 
148     @Override
isOfType(int type)149     protected boolean isOfType(int type) {
150         return (type & TYPE_ON_BOARD_POPUP) != 0;
151     }
152 
init(Context context, int layoutId)153     private void init(Context context, int layoutId) {
154         inflate(context, layoutId, this);
155         setOrientation(LinearLayout.VERTICAL);
156 
157         mArrowView = findViewById(R.id.arrow);
158         updateArrowTipInView(mIsPointingUp);
159         setAlpha(0);
160 
161         // Create default open animator.
162         mOpenAnimator.play(ObjectAnimator.ofFloat(this, ALPHA, 1f));
163         mOpenAnimator.setStartDelay(SHOW_DELAY_MS);
164         mOpenAnimator.setDuration(SHOW_DURATION_MS);
165         mOpenAnimator.setInterpolator(Interpolators.DECELERATE);
166 
167         // Create default close animator.
168         mCloseAnimator.play(ObjectAnimator.ofFloat(this, ALPHA, 0));
169         mCloseAnimator.setStartDelay(0);
170         mCloseAnimator.setDuration(HIDE_DURATION_MS);
171         mCloseAnimator.setInterpolator(Interpolators.ACCELERATE);
172         mCloseAnimator.addListener(new AnimatorListenerAdapter() {
173             @Override
174             public void onAnimationEnd(Animator animation) {
175                 mActivityContext.getDragLayer().removeView(ArrowTipView.this);
176             }
177         });
178     }
179 
180     /**
181      * Show Tip with specified string and Y location
182      */
show(String text, int top)183     public ArrowTipView show(String text, int top) {
184         return show(text, Gravity.CENTER_HORIZONTAL, 0, top);
185     }
186 
187     /**
188      * Show the ArrowTipView (tooltip) center, start, or end aligned.
189      *
190      * @param text             The text to be shown in the tooltip.
191      * @param gravity          The gravity aligns the tooltip center, start, or end.
192      * @param arrowMarginStart The margin from start to place arrow (ignored if center)
193      * @param top              The Y coordinate of the bottom of tooltip.
194      * @return The tooltip.
195      */
show(String text, int gravity, int arrowMarginStart, int top)196     public ArrowTipView show(String text, int gravity, int arrowMarginStart, int top) {
197         return show(text, gravity, arrowMarginStart, top, true);
198     }
199 
200     /**
201      * Show the ArrowTipView (tooltip) center, start, or end aligned.
202      *
203      * @param text The text to be shown in the tooltip.
204      * @param gravity The gravity aligns the tooltip center, start, or end.
205      * @param arrowMarginStart The margin from start to place arrow (ignored if center)
206      * @param top  The Y coordinate of the bottom of tooltip.
207      * @param shouldAutoClose If Tooltip should be auto close.
208      * @return The tooltip.
209      */
show( String text, int gravity, int arrowMarginStart, int top, boolean shouldAutoClose)210     public ArrowTipView show(
211             String text, int gravity, int arrowMarginStart, int top, boolean shouldAutoClose) {
212         ((TextView) findViewById(R.id.text)).setText(text);
213         ViewGroup parent = mActivityContext.getDragLayer();
214         parent.addView(this);
215 
216         DeviceProfile grid = mActivityContext.getDeviceProfile();
217 
218         DragLayer.LayoutParams params = (DragLayer.LayoutParams) getLayoutParams();
219         params.gravity = gravity;
220         params.leftMargin = mArrowMinOffset + grid.getInsets().left;
221         params.rightMargin = mArrowMinOffset + grid.getInsets().right;
222         params.width = LayoutParams.MATCH_PARENT;
223         LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) mArrowView.getLayoutParams();
224 
225         lp.gravity = gravity;
226 
227         if (parent.getLayoutDirection() == LAYOUT_DIRECTION_RTL) {
228             arrowMarginStart = parent.getMeasuredWidth() - arrowMarginStart;
229         }
230         if (gravity == Gravity.END) {
231             lp.setMarginEnd(Math.max(mArrowMinOffset,
232                     parent.getMeasuredWidth() - params.rightMargin - arrowMarginStart
233                             - mArrowWidth / 2));
234         } else if (gravity == Gravity.START) {
235             lp.setMarginStart(Math.max(mArrowMinOffset,
236                     arrowMarginStart - params.leftMargin - mArrowWidth / 2));
237         }
238         requestLayout();
239         post(() -> setY(top - (mIsPointingUp ? 0 : getHeight())));
240 
241         mIsOpen = true;
242         if (shouldAutoClose) {
243             mHandler.postDelayed(() -> handleClose(true), AUTO_CLOSE_TIMEOUT_MILLIS);
244         }
245 
246         mOpenAnimator.start();
247         return this;
248     }
249 
250     /**
251      * Show the ArrowTipView (tooltip) custom aligned. The tooltip is vertically flipped if it
252      * cannot fit on screen in the requested orientation.
253      *
254      * @param text The text to be shown in the tooltip.
255      * @param arrowXCoord The X coordinate for the arrow on the tooltip. The arrow is usually in the
256      *                    center of tooltip unless the tooltip goes beyond screen margin.
257      * @param yCoord The Y coordinate of the pointed tip end of the tooltip.
258      * @return The tool tip view. {@code null} if the tip can not be shown.
259      */
showAtLocation(String text, @Px int arrowXCoord, @Px int yCoord)260     @Nullable public ArrowTipView showAtLocation(String text, @Px int arrowXCoord, @Px int yCoord) {
261         return showAtLocation(
262             text,
263             arrowXCoord,
264             /* yCoordDownPointingTip= */ yCoord,
265             /* yCoordUpPointingTip= */ yCoord,
266             /* shouldAutoClose= */ true);
267     }
268 
269     /**
270      * Show the ArrowTipView (tooltip) custom aligned. The tooltip is vertically flipped if it
271      * cannot fit on screen in the requested orientation.
272      *
273      * @param text The text to be shown in the tooltip.
274      * @param arrowXCoord The X coordinate for the arrow on the tooltip. The arrow is usually in the
275      *                    center of tooltip unless the tooltip goes beyond screen margin.
276      * @param yCoord The Y coordinate of the pointed tip end of the tooltip.
277      * @param shouldAutoClose If Tooltip should be auto close.
278      * @return The tool tip view. {@code null} if the tip can not be shown.
279      */
showAtLocation( String text, @Px int arrowXCoord, @Px int yCoord, boolean shouldAutoClose)280     @Nullable public ArrowTipView showAtLocation(
281             String text, @Px int arrowXCoord, @Px int yCoord, boolean shouldAutoClose) {
282         return showAtLocation(
283                 text,
284                 arrowXCoord,
285                 /* yCoordDownPointingTip= */ yCoord,
286                 /* yCoordUpPointingTip= */ yCoord,
287                 /* shouldAutoClose= */ shouldAutoClose);
288     }
289 
290     /**
291      * Show the ArrowTipView (tooltip) custom aligned. The tooltip is vertically flipped if it
292      * cannot fit on screen in the requested orientation.
293      *
294      * @param text The text to be shown in the tooltip.
295      * @param arrowXCoord The X coordinate for the arrow on the tooltip. The arrow is usually in the
296      *                    center of tooltip unless the tooltip goes beyond screen margin.
297      * @param rect The coordinates of the view which requests the tooltip to be shown.
298      * @param margin The margin between {@param rect} and the tooltip.
299      * @return The tool tip view. {@code null} if the tip can not be shown.
300      */
showAroundRect( String text, @Px int arrowXCoord, Rect rect, @Px int margin)301     @Nullable public ArrowTipView showAroundRect(
302             String text, @Px int arrowXCoord, Rect rect, @Px int margin) {
303         return showAtLocation(
304                 text,
305                 arrowXCoord,
306                 /* yCoordDownPointingTip= */ rect.top - margin,
307                 /* yCoordUpPointingTip= */ rect.bottom + margin,
308                 /* shouldAutoClose= */ true);
309     }
310 
311     /**
312      * Show the ArrowTipView (tooltip) custom aligned. The tooltip is vertically flipped if it
313      * cannot fit on screen in the requested orientation.
314      *
315      * @param text The text to be shown in the tooltip.
316      * @param arrowXCoord The X coordinate for the arrow on the tooltip. The arrow is usually in the
317      *                    center of tooltip unless the tooltip goes beyond screen margin.
318      * @param yCoordDownPointingTip The Y coordinate of the pointed tip end of the tooltip when the
319      *                              tooltip is placed pointing downwards.
320      * @param yCoordUpPointingTip The Y coordinate of the pointed tip end of the tooltip when the
321      *                            tooltip is placed pointing upwards.
322      * @param shouldAutoClose If Tooltip should be auto close.
323      * @return The tool tip view. {@code null} if the tip can not be shown.
324      */
showAtLocation(String text, @Px int arrowXCoord, @Px int yCoordDownPointingTip, @Px int yCoordUpPointingTip, boolean shouldAutoClose)325     @Nullable private ArrowTipView showAtLocation(String text, @Px int arrowXCoord,
326             @Px int yCoordDownPointingTip, @Px int yCoordUpPointingTip, boolean shouldAutoClose) {
327         ViewGroup parent = mActivityContext.getDragLayer();
328         @Px int parentViewWidth = parent.getWidth();
329         @Px int parentViewHeight = parent.getHeight();
330         @Px int maxTextViewWidth = getContext().getResources()
331                 .getDimensionPixelSize(R.dimen.widget_picker_education_tip_max_width);
332         @Px int minViewMargin = getContext().getResources()
333                 .getDimensionPixelSize(R.dimen.widget_picker_education_tip_min_margin);
334         if (parentViewWidth < maxTextViewWidth + 2 * minViewMargin) {
335             Log.w(TAG, "Cannot display tip on a small screen of size: " + parentViewWidth);
336             return null;
337         }
338 
339         TextView textView = findViewById(R.id.text);
340         textView.setText(text);
341         textView.setMaxWidth(maxTextViewWidth);
342         if (parent.indexOfChild(this) < 0) {
343             parent.addView(this);
344             requestLayout();
345         }
346 
347         post(() -> {
348             // Adjust the tooltip horizontally.
349             float halfWidth = getWidth() / 2f;
350             float xCoord;
351             if (arrowXCoord - halfWidth < minViewMargin) {
352                 // If the tooltip is estimated to go beyond the left margin, place its start just at
353                 // the left margin.
354                 xCoord = minViewMargin;
355             } else if (arrowXCoord + halfWidth > parentViewWidth - minViewMargin) {
356                 // If the tooltip is estimated to go beyond the right margin, place it such that its
357                 // end is just at the right margin.
358                 xCoord = parentViewWidth - minViewMargin - getWidth();
359             } else {
360                 // Place the tooltip such that its center is at arrowXCoord.
361                 xCoord = arrowXCoord - halfWidth;
362             }
363             setX(xCoord);
364 
365             // Adjust the tooltip vertically.
366             @Px int viewHeight = getHeight();
367             boolean isPointingUp = mIsPointingUp;
368             if (mIsPointingUp
369                     ? (yCoordUpPointingTip + viewHeight > parentViewHeight)
370                     : (yCoordDownPointingTip - viewHeight < 0)) {
371                 // Flip the view if it exceeds the vertical bounds of screen.
372                 isPointingUp = !mIsPointingUp;
373             }
374             updateArrowTipInView(isPointingUp);
375             // Place the tooltip such that its top is at yCoordUpPointingTip if arrow is displayed
376             // pointing upwards, otherwise place it such that its bottom is at
377             // yCoordDownPointingTip.
378             setY(isPointingUp ? yCoordUpPointingTip : yCoordDownPointingTip - viewHeight);
379 
380             // Adjust the arrow's relative position on tooltip to make sure the actual position of
381             // arrow's pointed tip is always at arrowXCoord.
382             mArrowView.setX(arrowXCoord - xCoord - mArrowView.getWidth() / 2f);
383             requestLayout();
384         });
385 
386         mIsOpen = true;
387         if (shouldAutoClose) {
388             mHandler.postDelayed(() -> handleClose(true), AUTO_CLOSE_TIMEOUT_MILLIS);
389         }
390 
391         mOpenAnimator.start();
392         return this;
393     }
394 
updateArrowTipInView(boolean isPointingUp)395     private void updateArrowTipInView(boolean isPointingUp) {
396         ViewGroup.LayoutParams arrowLp = mArrowView.getLayoutParams();
397         ShapeDrawable arrowDrawable = new ShapeDrawable(TriangleShape.create(
398                 arrowLp.width, arrowLp.height, isPointingUp));
399         Paint arrowPaint = arrowDrawable.getPaint();
400         @Px int arrowTipRadius = getContext().getResources()
401                 .getDimensionPixelSize(R.dimen.arrow_toast_corner_radius);
402         arrowPaint.setColor(mArrowViewPaintColor);
403         arrowPaint.setPathEffect(new CornerPathEffect(arrowTipRadius));
404         mArrowView.setBackground(arrowDrawable);
405         // Add negative margin so that the rounded corners on base of arrow are not visible.
406         removeView(mArrowView);
407         if (isPointingUp) {
408             addView(mArrowView, 0);
409             ((ViewGroup.MarginLayoutParams) arrowLp).setMargins(0, 0, 0, -1 * arrowTipRadius);
410         } else {
411             addView(mArrowView, 1);
412             ((ViewGroup.MarginLayoutParams) arrowLp).setMargins(0, -1 * arrowTipRadius, 0, 0);
413         }
414     }
415 
416     /**
417      * Register a callback fired when toast is hidden
418      */
setOnClosedCallback(Runnable runnable)419     public ArrowTipView setOnClosedCallback(Runnable runnable) {
420         mOnClosed = runnable;
421         return this;
422     }
423 
424     @Override
onConfigurationChanged(Configuration newConfig)425     protected void onConfigurationChanged(Configuration newConfig) {
426         super.onConfigurationChanged(newConfig);
427         close(/* animate= */ false);
428     }
429 
430     /**
431      * Sets a custom animation to run on open of the ArrowTipView.
432      */
setCustomOpenAnimation(AnimatorSet animator)433     public void setCustomOpenAnimation(AnimatorSet animator) {
434         mOpenAnimator = animator;
435     }
436 
437     /**
438      * Sets a custom animation to run on close of the ArrowTipView.
439      */
setCustomCloseAnimation(AnimatorSet animator)440     public void setCustomCloseAnimation(AnimatorSet animator) {
441         mCloseAnimator = animator;
442     }
443 
setTextAlpha(int textAlpha)444     private void setTextAlpha(int textAlpha) {
445         if (mTextAlpha != textAlpha) {
446             mTextAlpha = textAlpha;
447             TextView textView = findViewById(R.id.text);
448             textView.setTextColor(textView.getTextColors().withAlpha(mTextAlpha));
449         }
450     }
451 
getTextAlpha()452     private int getTextAlpha() {
453         return mTextAlpha;
454     }
455 }
456