1 /**
2  * Copyright (c) 2011, Google Inc.
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.mail.ui;
17 
18 import android.animation.Animator;
19 import android.animation.AnimatorListenerAdapter;
20 import android.animation.TimeInterpolator;
21 import android.annotation.TargetApi;
22 import android.content.Context;
23 import android.os.Handler;
24 import android.support.annotation.StringRes;
25 import android.text.TextUtils;
26 import android.util.AttributeSet;
27 import android.view.MotionEvent;
28 import android.view.View;
29 import android.view.ViewGroup;
30 import android.view.animation.LinearInterpolator;
31 import android.view.animation.PathInterpolator;
32 import android.widget.FrameLayout;
33 import android.widget.TextView;
34 
35 import com.android.mail.R;
36 import com.android.mail.utils.Utils;
37 import com.android.mail.utils.ViewUtils;
38 
39 /**
40  * A custom {@link View} that exposes an action to the user.
41  */
42 public class ActionableToastBar extends FrameLayout {
43 
44     private boolean mHidden = true;
45     private final Runnable mHideToastBarRunnable;
46     private final Handler mHideToastBarHandler;
47 
48     /**
49      * The floating action button if it must be animated with the toast bar; <code>null</code>
50      * otherwise.
51      */
52     private View mFloatingActionButton;
53 
54     /**
55      * <tt>true</tt> while animation is occurring; false otherwise; It is used to block attempts to
56      * hide the toast bar while it is being animated
57      */
58     private boolean mAnimating = false;
59 
60     /** The interpolator that produces position values during animation. */
61     private TimeInterpolator mAnimationInterpolator;
62 
63     /** The length of time (in milliseconds) that the popup / push down animation run over */
64     private int mAnimationDuration;
65 
66     /**
67      * The time at which the toast popup completed. This is used to ensure the toast remains
68      * visible for a minimum duration before it is removed.
69      */
70     private long mAnimationCompleteTimestamp;
71 
72     /** The min time duration for which the toast must remain visible and cannot be dismissed. */
73     private long mMinToastDuration;
74 
75     /** The max time duration for which the toast can remain visible and must be dismissed. */
76     private long mMaxToastDuration;
77 
78     /** The view that contains the description when laid out as a single line. */
79     private TextView mSingleLineDescriptionView;
80 
81     /** The view that contains the text for the action button when laid out as a single line. */
82     private TextView mSingleLineActionView;
83 
84     /** The view that contains the description when laid out as a multiple lines;
85      * always <tt>null</tt> in two-pane layouts. */
86     private TextView mMultiLineDescriptionView;
87 
88     /** The view that contains the text for the action button when laid out as a multiple lines;
89      * always <tt>null</tt> in two-pane layouts. */
90     private TextView mMultiLineActionView;
91 
92     /** The minimum width of this view; applicable when description text is very short. */
93     private int mMinWidth;
94 
95     /** The maximum width of this view; applicable when description text is long enough to wrap. */
96     private int mMaxWidth;
97 
98     private ToastBarOperation mOperation;
99 
ActionableToastBar(Context context)100     public ActionableToastBar(Context context) {
101         this(context, null);
102     }
103 
ActionableToastBar(Context context, AttributeSet attrs)104     public ActionableToastBar(Context context, AttributeSet attrs) {
105         this(context, attrs, 0);
106     }
107 
ActionableToastBar(Context context, AttributeSet attrs, int defStyle)108     public ActionableToastBar(Context context, AttributeSet attrs, int defStyle) {
109         super(context, attrs, defStyle);
110         mAnimationInterpolator = createTimeInterpolator();
111         mAnimationDuration = getResources().getInteger(R.integer.toast_bar_animation_duration_ms);
112         mMinToastDuration = getResources().getInteger(R.integer.toast_bar_min_duration_ms);
113         mMaxToastDuration = getResources().getInteger(R.integer.toast_bar_max_duration_ms);
114         mMinWidth = getResources().getDimensionPixelOffset(R.dimen.snack_bar_min_width);
115         mMaxWidth = getResources().getDimensionPixelOffset(R.dimen.snack_bar_max_width);
116         mHideToastBarHandler = new Handler();
117         mHideToastBarRunnable = new Runnable() {
118             @Override
119             public void run() {
120                 if (!mHidden) {
121                     hide(true, false /* actionClicked */);
122                 }
123             }
124         };
125     }
126 
createTimeInterpolator()127     private TimeInterpolator createTimeInterpolator() {
128         // L and beyond we can use the new PathInterpolator
129         if (Utils.isRunningLOrLater()) {
130             return createPathInterpolator();
131         }
132 
133         // fall back to basic LinearInterpolator
134         return new LinearInterpolator();
135     }
136 
137     @TargetApi(21)
createPathInterpolator()138     private TimeInterpolator createPathInterpolator() {
139         return new PathInterpolator(0.4f, 0f, 0.2f, 1f);
140     }
141 
142     @Override
onFinishInflate()143     protected void onFinishInflate() {
144         super.onFinishInflate();
145 
146         mSingleLineDescriptionView = (TextView) findViewById(R.id.description_text);
147         mSingleLineActionView = (TextView) findViewById(R.id.action_text);
148         mMultiLineDescriptionView = (TextView) findViewById(R.id.multiline_description_text);
149         mMultiLineActionView = (TextView) findViewById(R.id.multiline_action_text);
150     }
151 
152     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)153     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
154         final boolean showAction = !TextUtils.isEmpty(mSingleLineActionView.getText());
155 
156         // configure the UI assuming the description fits on a single line
157         setVisibility(false /* multiLine */, showAction);
158 
159         // measure the view and its content
160         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
161 
162         // if specific views exist to handle the multiline case
163         if (mMultiLineDescriptionView != null) {
164             // if the description does not fit on a single line
165             if (mSingleLineDescriptionView.getLineCount() > 1) {
166                 //switch to multi line display views
167                 setVisibility(true /* multiLine */, showAction);
168 
169                 super.onMeasure(widthMeasureSpec, heightMeasureSpec);
170             }
171         // if width constraints were given explicitly, honor them; otherwise use the natural width
172         } else if (mMinWidth >= 0 && mMaxWidth >= 0) {
173             // otherwise, adjust the the single line view so wrapping occurs at the desired width
174             // (the total width of the toast bar must always fall between the given min and max
175             // width; if max width cannot accommodate all of the description text, it wraps)
176             if (getMeasuredWidth() < mMinWidth) {
177                 widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(mMinWidth, MeasureSpec.EXACTLY);
178                 super.onMeasure(widthMeasureSpec, heightMeasureSpec);
179             } else if (getMeasuredWidth() > mMaxWidth) {
180                 widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(mMaxWidth, MeasureSpec.EXACTLY);
181                 super.onMeasure(widthMeasureSpec, heightMeasureSpec);
182             }
183         }
184     }
185 
186     /**
187      * Displays the toast bar and makes it visible. Allows the setting of
188      * parameters to customize the display.
189      * @param listener Performs some action when the action button is clicked.
190      *                 If the {@link ToastBarOperation} overrides
191      *                 {@link ToastBarOperation#shouldTakeOnActionClickedPrecedence()}
192      *                 to return <code>true</code>, the
193      *                 {@link ToastBarOperation#onActionClicked(android.content.Context)}
194      *                 will override this listener and be called instead.
195      * @param descriptionText a description text to show in the toast bar
196      * @param actionTextResourceId resource ID for the text to show in the action button
197      * @param replaceVisibleToast if true, this toast should replace any currently visible toast.
198      *                            Otherwise, skip showing this toast.
199      * @param autohide <tt>true</tt> indicates the toast will be automatically hidden after a time
200      *                 delay; <tt>false</tt> indicate the toast will remain visible until the user
201      *                 dismisses it
202      * @param op the operation that corresponds to the specific toast being shown
203      */
show(final ActionClickedListener listener, final CharSequence descriptionText, @StringRes final int actionTextResourceId, final boolean replaceVisibleToast, final boolean autohide, final ToastBarOperation op)204     public void show(final ActionClickedListener listener, final CharSequence descriptionText,
205                      @StringRes final int actionTextResourceId, final boolean replaceVisibleToast,
206                      final boolean autohide, final ToastBarOperation op) {
207         if (!mHidden && !replaceVisibleToast) {
208             return;
209         }
210 
211         // Remove any running delayed animations first
212         mHideToastBarHandler.removeCallbacks(mHideToastBarRunnable);
213 
214         mOperation = op;
215 
216         setActionClickListener(new OnClickListener() {
217             @Override
218             public void onClick(View widget) {
219                 if (op != null && op.shouldTakeOnActionClickedPrecedence()) {
220                     op.onActionClicked(getContext());
221                 } else {
222                     listener.onActionClicked(getContext());
223                 }
224                 hide(true /* animate */, true /* actionClicked */);
225             }
226         });
227 
228         setDescriptionText(descriptionText);
229         ViewUtils.announceForAccessibility(this, descriptionText);
230         setActionText(actionTextResourceId);
231 
232         // if this toast bar is not yet hidden, animate it in place; otherwise we just update the
233         // text that it displays
234         if (mHidden) {
235             mHidden = false;
236             popupToast();
237         }
238 
239         if (autohide) {
240             // Set up runnable to execute hide toast once delay is completed
241             mHideToastBarHandler.postDelayed(mHideToastBarRunnable, mMaxToastDuration);
242         }
243     }
244 
getOperation()245     public ToastBarOperation getOperation() {
246         return mOperation;
247     }
248 
249     /**
250      * Hides the view and resets the state.
251      */
hide(boolean animate, boolean actionClicked)252     public void hide(boolean animate, boolean actionClicked) {
253         mHidden = true;
254         mAnimationCompleteTimestamp = 0;
255         mHideToastBarHandler.removeCallbacks(mHideToastBarRunnable);
256         if (getVisibility() == View.VISIBLE) {
257             setActionClickListener(null);
258             // Hide view once it's clicked.
259             if (animate) {
260                 pushDownToast();
261             } else {
262                 // immediate hiding implies no position adjustment of the FAB and hide the toast bar
263                 if (mFloatingActionButton != null) {
264                     mFloatingActionButton.setTranslationY(0);
265                 }
266                 setVisibility(View.GONE);
267             }
268 
269             if (!actionClicked && mOperation != null) {
270                 mOperation.onToastBarTimeout(getContext());
271             }
272         }
273     }
274 
275     /**
276      * @return <tt>true</tt> while the toast bar animation is popping up or pushing down the toast;
277      *      <tt>false</tt> otherwise
278      */
isAnimating()279     public boolean isAnimating() {
280         return mAnimating;
281     }
282 
283     /**
284      * @return <tt>true</tt> if this toast bar has not yet been displayed for a long enough period
285      *      of time to be dismissed; <tt>false</tt> otherwise
286      */
cannotBeHidden()287     public boolean cannotBeHidden() {
288         return System.currentTimeMillis() - mAnimationCompleteTimestamp < mMinToastDuration;
289     }
290 
291     @Override
onDetachedFromWindow()292     public void onDetachedFromWindow() {
293         mHideToastBarHandler.removeCallbacks(mHideToastBarRunnable);
294         super.onDetachedFromWindow();
295     }
296 
isEventInToastBar(MotionEvent event)297     public boolean isEventInToastBar(MotionEvent event) {
298         if (!isShown()) {
299             return false;
300         }
301         int[] xy = new int[2];
302         float x = event.getX();
303         float y = event.getY();
304         getLocationOnScreen(xy);
305         return (x > xy[0] && x < (xy[0] + getWidth()) && y > xy[1] && y < xy[1] + getHeight());
306     }
307 
308     /**
309      * Indicates that the given view should be animated with this toast bar as it pops up and pushes
310      * down. In some layouts, the floating action button appears above the toast bar and thus must
311      * be pushed up as the toast pops up and fall down as the toast is pushed down.
312      *
313      * @param floatingActionButton a the floating action button to be animated with the toast bar as
314      *                             it pops up and pushes down
315      */
setFloatingActionButton(View floatingActionButton)316     public void setFloatingActionButton(View floatingActionButton) {
317         mFloatingActionButton = floatingActionButton;
318     }
319 
320     /**
321      * If the View requires multiple lines to fully display the toast description then make the
322      * multi-line view visible and hide the single line view; otherwise vice versa. If the action
323      * text is present, display it, otherwise hide it.
324      *
325      * @param multiLine <tt>true</tt> if the View requires multiple lines to display the toast
326      * @param showAction <tt>true</tt> if the action text is present and should be shown
327      */
setVisibility(boolean multiLine, boolean showAction)328     private void setVisibility(boolean multiLine, boolean showAction) {
329         mSingleLineDescriptionView.setVisibility(!multiLine ? View.VISIBLE : View.GONE);
330         mSingleLineActionView.setVisibility(!multiLine && showAction ? View.VISIBLE : View.GONE);
331         if (mMultiLineDescriptionView != null) {
332             mMultiLineDescriptionView.setVisibility(multiLine ? View.VISIBLE : View.GONE);
333         }
334         if (mMultiLineActionView != null) {
335             mMultiLineActionView.setVisibility(multiLine && showAction ? View.VISIBLE : View.GONE);
336         }
337     }
338 
setDescriptionText(CharSequence description)339     private void setDescriptionText(CharSequence description) {
340         mSingleLineDescriptionView.setText(description);
341         if (mMultiLineDescriptionView != null) {
342             mMultiLineDescriptionView.setText(description);
343         }
344     }
345 
setActionText(@tringRes int actionTextResourceId)346     private void setActionText(@StringRes int actionTextResourceId) {
347         if (actionTextResourceId == 0) {
348             mSingleLineActionView.setText("");
349             if (mMultiLineActionView != null) {
350                 mMultiLineActionView.setText("");
351             }
352         } else {
353             mSingleLineActionView.setText(actionTextResourceId);
354             if (mMultiLineActionView != null) {
355                 mMultiLineActionView.setText(actionTextResourceId);
356             }
357         }
358     }
359 
setActionClickListener(OnClickListener listener)360     private void setActionClickListener(OnClickListener listener) {
361         mSingleLineActionView.setOnClickListener(listener);
362 
363         if (mMultiLineActionView != null) {
364             mMultiLineActionView.setOnClickListener(listener);
365         }
366     }
367 
368     /**
369      * Pops up the toast (and optionally the floating action button) into view via an animation.
370      */
popupToast()371     private void popupToast() {
372         final float animationDistance = getAnimationDistance();
373 
374         setVisibility(View.VISIBLE);
375         setTranslationY(animationDistance);
376         animate()
377                 .setDuration(mAnimationDuration)
378                 .setInterpolator(mAnimationInterpolator)
379                 .translationYBy(-animationDistance)
380                 .setListener(new AnimatorListenerAdapter() {
381                     @Override
382                     public void onAnimationStart(Animator animation) {
383                         mAnimating = true;
384                     }
385                     @Override
386                     public void onAnimationEnd(Animator animation) {
387                         mAnimating = false;
388                         mAnimationCompleteTimestamp = System.currentTimeMillis();
389                     }
390                 });
391 
392         if (mFloatingActionButton != null) {
393             mFloatingActionButton.setTranslationY(animationDistance);
394             mFloatingActionButton.animate()
395                     .setDuration(mAnimationDuration)
396                     .setInterpolator(mAnimationInterpolator)
397                     .translationYBy(-animationDistance);
398         }
399     }
400 
401     /**
402      * Pushes down the toast (and optionally the floating action button) out of view via an
403      * animation.
404      */
pushDownToast()405     private void pushDownToast() {
406         final float animationDistance = getAnimationDistance();
407 
408         setTranslationY(0);
409         animate()
410                 .setDuration(mAnimationDuration)
411                 .setInterpolator(mAnimationInterpolator)
412                 .translationYBy(animationDistance)
413                 .setListener(new AnimatorListenerAdapter() {
414                     @Override
415                     public void onAnimationStart(Animator animation) {
416                         mAnimating = true;
417                     }
418                     @Override
419                     public void onAnimationEnd(Animator animation) {
420                         mAnimating = false;
421                         // on push down animation completion the toast bar is no longer present
422                         setVisibility(View.GONE);
423                     }
424                 });
425 
426         if (mFloatingActionButton != null) {
427             mFloatingActionButton.setTranslationY(0);
428             mFloatingActionButton.animate()
429                     .setDuration(mAnimationDuration)
430                     .setInterpolator(mAnimationInterpolator)
431                     .translationYBy(animationDistance)
432                     .setListener(new AnimatorListenerAdapter() {
433                         @Override
434                         public void onAnimationEnd(Animator animation) {
435                             // on push down animation completion the FAB no longer needs translation
436                             mFloatingActionButton.setTranslationY(0);
437                         }
438                     });
439         }
440     }
441 
442     /**
443      * The toast bar is assumed to be positioned at the bottom of the display, so the distance over
444      * which to animate is the height of the toast bar + any margin beneath the toast bar.
445      *
446      * @return the distance to move the toast bar to make it appear to pop up / push down from the
447      *      bottom of the display
448      */
getAnimationDistance()449     private int getAnimationDistance() {
450         // total height over which the animation takes place is the toast bar height + bottom margin
451         final ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) getLayoutParams();
452         return getHeight() + params.bottomMargin;
453     }
454 
455     /**
456      * Classes that wish to perform some action when the action button is clicked
457      * should implement this interface.
458      */
459     public interface ActionClickedListener {
onActionClicked(Context context)460         public void onActionClicked(Context context);
461     }
462 }