1/*
2 * Copyright (C) 2015 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 */
16package com.android.messaging.ui;
17
18import android.content.Context;
19import android.graphics.Point;
20import android.graphics.Rect;
21import android.os.Handler;
22import android.text.TextUtils;
23import android.util.DisplayMetrics;
24import android.view.Gravity;
25import android.view.MotionEvent;
26import android.view.View;
27import android.view.View.MeasureSpec;
28import android.view.View.OnTouchListener;
29import android.view.ViewGroup;
30import android.view.ViewGroup.LayoutParams;
31import android.view.ViewPropertyAnimator;
32import android.view.ViewTreeObserver.OnGlobalLayoutListener;
33import android.view.WindowManager;
34import android.widget.PopupWindow;
35import android.widget.PopupWindow.OnDismissListener;
36
37import com.android.messaging.Factory;
38import com.android.messaging.R;
39import com.android.messaging.ui.SnackBar.Placement;
40import com.android.messaging.ui.SnackBar.SnackBarListener;
41import com.android.messaging.util.AccessibilityUtil;
42import com.android.messaging.util.Assert;
43import com.android.messaging.util.LogUtil;
44import com.android.messaging.util.OsUtil;
45import com.android.messaging.util.TextUtil;
46import com.android.messaging.util.UiUtils;
47import com.google.common.base.Joiner;
48
49import java.util.List;
50
51public class SnackBarManager {
52
53    private static SnackBarManager sInstance;
54
55    public static SnackBarManager get() {
56        if (sInstance == null) {
57            synchronized (SnackBarManager.class) {
58                if (sInstance == null) {
59                    sInstance = new SnackBarManager();
60                }
61            }
62        }
63        return sInstance;
64    }
65
66    private final Runnable mDismissRunnable = new Runnable() {
67        @Override
68        public void run() {
69            dismiss();
70        }
71    };
72
73    private final OnTouchListener mDismissOnTouchListener = new OnTouchListener() {
74        @Override
75        public boolean onTouch(final View view, final MotionEvent event) {
76            // Dismiss the {@link SnackBar} but don't consume the event.
77            dismiss();
78            return false;
79        }
80    };
81
82    private final SnackBarListener mDismissOnUserTapListener = new SnackBarListener() {
83        @Override
84        public void onActionClick() {
85            dismiss();
86        }
87    };
88
89    private final int mTranslationDurationMs;
90    private final Handler mHideHandler;
91
92    private SnackBar mCurrentSnackBar;
93    private SnackBar mLatestSnackBar;
94    private SnackBar mNextSnackBar;
95    private boolean mIsCurrentlyDismissing;
96    private PopupWindow mPopupWindow;
97
98    private SnackBarManager() {
99        mTranslationDurationMs = Factory.get().getApplicationContext().getResources().getInteger(
100                R.integer.snackbar_translation_duration_ms);
101        mHideHandler = new Handler();
102    }
103
104    public SnackBar getLatestSnackBar() {
105        return mLatestSnackBar;
106    }
107
108    public SnackBar.Builder newBuilder(final View parentView) {
109        return new SnackBar.Builder(this, parentView);
110    }
111
112    /**
113     * The given snackBar is not guaranteed to be shown. If the previous snackBar is animating away,
114     * and another snackBar is requested to show after this one, this snackBar will be skipped.
115     */
116    public void show(final SnackBar snackBar) {
117        Assert.notNull(snackBar);
118
119        if (mCurrentSnackBar != null) {
120            LogUtil.d(LogUtil.BUGLE_TAG, "Showing snack bar, but currentSnackBar was not null.");
121
122            // Dismiss the current snack bar. That will cause the next snack bar to be shown on
123            // completion.
124            mNextSnackBar = snackBar;
125            mLatestSnackBar = snackBar;
126            dismiss();
127            return;
128        }
129
130        mCurrentSnackBar = snackBar;
131        mLatestSnackBar = snackBar;
132
133        // We want to know when either button was tapped so we can dismiss.
134        snackBar.setListener(mDismissOnUserTapListener);
135
136        // Cancel previous dismisses & set dismiss for the delay time.
137        mHideHandler.removeCallbacks(mDismissRunnable);
138        mHideHandler.postDelayed(mDismissRunnable, snackBar.getDuration());
139
140        snackBar.setEnabled(false);
141
142        // For some reason, the addView function does not respect layoutParams.
143        // We need to explicitly set it first here.
144        final View rootView = snackBar.getRootView();
145
146        if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) {
147            LogUtil.d(LogUtil.BUGLE_TAG, "Showing snack bar: " + snackBar);
148        }
149        // Measure the snack bar root view so we know how much to translate by.
150        measureSnackBar(snackBar);
151        mPopupWindow = new PopupWindow(snackBar.getContext());
152        mPopupWindow.setWidth(LayoutParams.MATCH_PARENT);
153        mPopupWindow.setHeight(LayoutParams.WRAP_CONTENT);
154        mPopupWindow.setBackgroundDrawable(null);
155        mPopupWindow.setContentView(rootView);
156        final Placement placement = snackBar.getPlacement();
157        if (placement == null) {
158            mPopupWindow.showAtLocation(
159                    snackBar.getParentView(), Gravity.BOTTOM | Gravity.START,
160                    0, getScreenBottomOffset(snackBar));
161        } else {
162            final View anchorView = placement.getAnchorView();
163
164            // You'd expect PopupWindow.showAsDropDown to ensure the popup moves with the anchor
165            // view, which it does for scrolling, but not layout changes, so we have to manually
166            // update while the snackbar is showing
167            final OnGlobalLayoutListener listener = new OnGlobalLayoutListener() {
168                @Override
169                public void onGlobalLayout() {
170                    mPopupWindow.update(anchorView, 0, getRelativeOffset(snackBar),
171                            anchorView.getWidth(), LayoutParams.WRAP_CONTENT);
172                }
173            };
174            anchorView.getViewTreeObserver().addOnGlobalLayoutListener(listener);
175            mPopupWindow.setOnDismissListener(new OnDismissListener() {
176                @Override
177                public void onDismiss() {
178                    anchorView.getViewTreeObserver().removeOnGlobalLayoutListener(listener);
179                }
180            });
181            mPopupWindow.showAsDropDown(anchorView, 0, getRelativeOffset(snackBar));
182        }
183
184
185        // Animate the toast bar into view.
186        placeSnackBarOffScreen(snackBar);
187        animateSnackBarOnScreen(snackBar).withEndAction(new Runnable() {
188            @Override
189            public void run() {
190                mCurrentSnackBar.setEnabled(true);
191                makeCurrentSnackBarDismissibleOnTouch();
192                // Fire an accessibility event as needed
193                String snackBarText = snackBar.getMessageText();
194                if (!TextUtils.isEmpty(snackBarText) &&
195                        TextUtils.getTrimmedLength(snackBarText) > 0) {
196                    snackBarText = snackBarText.trim();
197                    final String snackBarActionText = snackBar.getActionLabel();
198                    if (!TextUtil.isAllWhitespace(snackBarActionText)) {
199                        snackBarText = Joiner.on(", ").join(snackBarText, snackBarActionText);
200                    }
201                    AccessibilityUtil.announceForAccessibilityCompat(snackBar.getSnackBarView(),
202                            null /*accessibilityManager*/, snackBarText);
203                }
204            }
205        });
206
207        // Animate any interaction views out of the way.
208        animateInteractionsOnShow(snackBar);
209    }
210
211    /**
212     * Dismisses the current toast that is showing. If there is a toast waiting to be shown, that
213     * toast will be shown when the current one has been dismissed.
214     */
215    public void dismiss() {
216        mHideHandler.removeCallbacks(mDismissRunnable);
217
218        if (mCurrentSnackBar == null || mIsCurrentlyDismissing) {
219            return;
220        }
221
222        final SnackBar snackBar = mCurrentSnackBar;
223
224        LogUtil.d(LogUtil.BUGLE_TAG, "Dismissing snack bar.");
225        mIsCurrentlyDismissing = true;
226
227        snackBar.setEnabled(false);
228
229        // Animate the toast bar down.
230        final View rootView = snackBar.getRootView();
231        animateSnackBarOffScreen(snackBar).withEndAction(new Runnable() {
232            @Override
233            public void run() {
234                rootView.setVisibility(View.GONE);
235                try {
236                    mPopupWindow.dismiss();
237                } catch (IllegalArgumentException e) {
238                    // PopupWindow.dismiss() will fire an IllegalArgumentException if the activity
239                    // has already ended while we were animating
240                }
241
242                mCurrentSnackBar = null;
243                mIsCurrentlyDismissing = false;
244
245                // Show the next toast if one is waiting.
246                if (mNextSnackBar != null) {
247                    final SnackBar localNextSnackBar = mNextSnackBar;
248                    mNextSnackBar = null;
249                    show(localNextSnackBar);
250                }
251            }
252        });
253
254        // Animate any interaction views back.
255        animateInteractionsOnDismiss(snackBar);
256    }
257
258    private void makeCurrentSnackBarDismissibleOnTouch() {
259        // Set touching on the entire view, the {@link SnackBar} itself, as
260        // well as the button's dismiss the toast.
261        mCurrentSnackBar.getRootView().setOnTouchListener(mDismissOnTouchListener);
262        mCurrentSnackBar.getSnackBarView().setOnTouchListener(mDismissOnTouchListener);
263    }
264
265    private void measureSnackBar(final SnackBar snackBar) {
266        final View rootView = snackBar.getRootView();
267        final Point displaySize = new Point();
268        getWindowManager(snackBar.getContext()).getDefaultDisplay().getSize(displaySize);
269        final int widthSpec = ViewGroup.getChildMeasureSpec(
270                MeasureSpec.makeMeasureSpec(displaySize.x, MeasureSpec.EXACTLY),
271                0, LayoutParams.MATCH_PARENT);
272        final int heightSpec = ViewGroup.getChildMeasureSpec(
273                MeasureSpec.makeMeasureSpec(displaySize.y, MeasureSpec.EXACTLY),
274                0, LayoutParams.WRAP_CONTENT);
275        rootView.measure(widthSpec, heightSpec);
276    }
277
278    private void placeSnackBarOffScreen(final SnackBar snackBar) {
279        final View rootView = snackBar.getRootView();
280        final View snackBarView = snackBar.getSnackBarView();
281        snackBarView.setTranslationY(rootView.getMeasuredHeight());
282    }
283
284    private ViewPropertyAnimator animateSnackBarOnScreen(final SnackBar snackBar) {
285        final View snackBarView = snackBar.getSnackBarView();
286        return normalizeAnimator(snackBarView.animate()).translationX(0).translationY(0);
287    }
288
289    private ViewPropertyAnimator animateSnackBarOffScreen(final SnackBar snackBar) {
290        final View rootView = snackBar.getRootView();
291        final View snackBarView = snackBar.getSnackBarView();
292        return normalizeAnimator(snackBarView.animate()).translationY(rootView.getHeight());
293    }
294
295    private void animateInteractionsOnShow(final SnackBar snackBar) {
296        final List<SnackBarInteraction> interactions = snackBar.getInteractions();
297        for (final SnackBarInteraction interaction : interactions) {
298            if (interaction != null) {
299                final ViewPropertyAnimator animator = interaction.animateOnSnackBarShow(snackBar);
300                if (animator != null) {
301                    normalizeAnimator(animator);
302                }
303            }
304        }
305    }
306
307    private void animateInteractionsOnDismiss(final SnackBar snackBar) {
308        final List<SnackBarInteraction> interactions = snackBar.getInteractions();
309        for (final SnackBarInteraction interaction : interactions) {
310            if (interaction != null) {
311                final ViewPropertyAnimator animator =
312                        interaction.animateOnSnackBarDismiss(snackBar);
313                if (animator != null) {
314                    normalizeAnimator(animator);
315                }
316            }
317        }
318    }
319
320    private ViewPropertyAnimator normalizeAnimator(final ViewPropertyAnimator animator) {
321        return animator
322                .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
323                .setDuration(mTranslationDurationMs);
324    }
325
326    private WindowManager getWindowManager(final Context context) {
327        return (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
328    }
329
330    /**
331     * Get the offset from the bottom of the screen where the snack bar should be placed.
332     */
333    private int getScreenBottomOffset(final SnackBar snackBar) {
334        final WindowManager windowManager = getWindowManager(snackBar.getContext());
335        final DisplayMetrics displayMetrics = new DisplayMetrics();
336        if (OsUtil.isAtLeastL()) {
337            windowManager.getDefaultDisplay().getRealMetrics(displayMetrics);
338        } else {
339            windowManager.getDefaultDisplay().getMetrics(displayMetrics);
340        }
341        final int screenHeight = displayMetrics.heightPixels;
342
343        if (OsUtil.isAtLeastL()) {
344            // In L, the navigation bar is included in the space for the popup window, so we have to
345            // offset by the size of the navigation bar
346            final Rect displayRect = new Rect();
347            snackBar.getParentView().getRootView().getWindowVisibleDisplayFrame(displayRect);
348            return screenHeight - displayRect.bottom;
349        }
350
351        return 0;
352    }
353
354    private int getRelativeOffset(final SnackBar snackBar) {
355        final Placement placement = snackBar.getPlacement();
356        Assert.notNull(placement);
357        final View anchorView = placement.getAnchorView();
358        if (placement.getAnchorAbove()) {
359            return -snackBar.getRootView().getMeasuredHeight() - anchorView.getHeight();
360        } else {
361            // Use the default dropdown positioning
362            return 0;
363        }
364    }
365}
366