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  */
16 package com.android.messaging.ui;
17 
18 import android.content.Context;
19 import android.graphics.Point;
20 import android.graphics.Rect;
21 import android.os.Handler;
22 import android.text.TextUtils;
23 import android.util.DisplayMetrics;
24 import android.view.Gravity;
25 import android.view.MotionEvent;
26 import android.view.View;
27 import android.view.View.MeasureSpec;
28 import android.view.View.OnTouchListener;
29 import android.view.ViewGroup;
30 import android.view.ViewGroup.LayoutParams;
31 import android.view.ViewPropertyAnimator;
32 import android.view.ViewTreeObserver.OnGlobalLayoutListener;
33 import android.view.WindowManager;
34 import android.widget.PopupWindow;
35 import android.widget.PopupWindow.OnDismissListener;
36 
37 import com.android.messaging.Factory;
38 import com.android.messaging.R;
39 import com.android.messaging.ui.SnackBar.Placement;
40 import com.android.messaging.ui.SnackBar.SnackBarListener;
41 import com.android.messaging.util.AccessibilityUtil;
42 import com.android.messaging.util.Assert;
43 import com.android.messaging.util.LogUtil;
44 import com.android.messaging.util.OsUtil;
45 import com.android.messaging.util.TextUtil;
46 import com.android.messaging.util.UiUtils;
47 import com.google.common.base.Joiner;
48 
49 import java.util.List;
50 
51 public class SnackBarManager {
52 
53     private static SnackBarManager sInstance;
54 
get()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 
SnackBarManager()98     private SnackBarManager() {
99         mTranslationDurationMs = Factory.get().getApplicationContext().getResources().getInteger(
100                 R.integer.snackbar_translation_duration_ms);
101         mHideHandler = new Handler();
102     }
103 
getLatestSnackBar()104     public SnackBar getLatestSnackBar() {
105         return mLatestSnackBar;
106     }
107 
newBuilder(final View parentView)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      */
show(final SnackBar snackBar)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      */
dismiss()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 
makeCurrentSnackBarDismissibleOnTouch()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 
measureSnackBar(final SnackBar snackBar)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 
placeSnackBarOffScreen(final SnackBar snackBar)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 
animateSnackBarOnScreen(final SnackBar snackBar)284     private ViewPropertyAnimator animateSnackBarOnScreen(final SnackBar snackBar) {
285         final View snackBarView = snackBar.getSnackBarView();
286         return normalizeAnimator(snackBarView.animate()).translationX(0).translationY(0);
287     }
288 
animateSnackBarOffScreen(final SnackBar snackBar)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 
animateInteractionsOnShow(final SnackBar snackBar)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 
animateInteractionsOnDismiss(final SnackBar snackBar)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 
normalizeAnimator(final ViewPropertyAnimator animator)320     private ViewPropertyAnimator normalizeAnimator(final ViewPropertyAnimator animator) {
321         return animator
322                 .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR)
323                 .setDuration(mTranslationDurationMs);
324     }
325 
getWindowManager(final Context context)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      */
getScreenBottomOffset(final SnackBar snackBar)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 
getRelativeOffset(final SnackBar snackBar)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