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 */
16package com.android.mail.ui;
17
18import android.animation.Animator;
19import android.animation.AnimatorListenerAdapter;
20import android.animation.TimeInterpolator;
21import android.annotation.TargetApi;
22import android.content.Context;
23import android.os.Handler;
24import android.support.annotation.StringRes;
25import android.text.TextUtils;
26import android.util.AttributeSet;
27import android.view.MotionEvent;
28import android.view.View;
29import android.view.ViewGroup;
30import android.view.animation.LinearInterpolator;
31import android.view.animation.PathInterpolator;
32import android.widget.FrameLayout;
33import android.widget.TextView;
34
35import com.android.mail.R;
36import com.android.mail.utils.Utils;
37import com.android.mail.utils.ViewUtils;
38
39/**
40 * A custom {@link View} that exposes an action to the user.
41 */
42public 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
100    public ActionableToastBar(Context context) {
101        this(context, null);
102    }
103
104    public ActionableToastBar(Context context, AttributeSet attrs) {
105        this(context, attrs, 0);
106    }
107
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
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)
138    private TimeInterpolator createPathInterpolator() {
139        return new PathInterpolator(0.4f, 0f, 0.2f, 1f);
140    }
141
142    @Override
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
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     */
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
245    public ToastBarOperation getOperation() {
246        return mOperation;
247    }
248
249    /**
250     * Hides the view and resets the state.
251     */
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     */
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     */
287    public boolean cannotBeHidden() {
288        return System.currentTimeMillis() - mAnimationCompleteTimestamp < mMinToastDuration;
289    }
290
291    @Override
292    public void onDetachedFromWindow() {
293        mHideToastBarHandler.removeCallbacks(mHideToastBarRunnable);
294        super.onDetachedFromWindow();
295    }
296
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     */
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     */
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
339    private void setDescriptionText(CharSequence description) {
340        mSingleLineDescriptionView.setText(description);
341        if (mMultiLineDescriptionView != null) {
342            mMultiLineDescriptionView.setText(description);
343        }
344    }
345
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
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     */
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     */
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     */
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 {
460        public void onActionClicked(Context context);
461    }
462}
463