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