1 // Copyright 2015 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.chrome.browser.ui.messages.snackbar;
6 
7 import android.app.Activity;
8 import android.os.Handler;
9 import android.view.View;
10 import android.view.View.OnClickListener;
11 import android.view.ViewGroup;
12 
13 import androidx.annotation.Nullable;
14 import androidx.annotation.VisibleForTesting;
15 
16 import org.chromium.base.ActivityState;
17 import org.chromium.base.ApplicationStatus;
18 import org.chromium.base.ApplicationStatus.ActivityStateListener;
19 import org.chromium.base.UnownedUserData;
20 import org.chromium.base.metrics.RecordHistogram;
21 import org.chromium.chrome.browser.util.ChromeAccessibilityUtil;
22 import org.chromium.ui.base.WindowAndroid;
23 
24 /**
25  * Manager for the snackbar showing at the bottom of activity. There should be only one
26  * SnackbarManager and one snackbar in the activity.
27  * <p/>
28  * When action button is clicked, this manager will call {@link SnackbarController#onAction(Object)}
29  * in corresponding listener, and show the next entry. Otherwise if no action is taken by user
30  * during {@link #DEFAULT_SNACKBAR_DURATION_MS} milliseconds, it will call
31  * {@link SnackbarController#onDismissNoAction(Object)}. Note, snackbars of
32  * {@link Snackbar#TYPE_PERSISTENT} do not get automatically dismissed after a timeout.
33  */
34 public class SnackbarManager implements OnClickListener, ActivityStateListener, UnownedUserData {
35     /**
36      * Interface that shows the ability to provide a snackbar manager.
37      */
38     public interface SnackbarManageable {
39         /**
40          * @return The snackbar manager that has a proper anchor view.
41          */
getSnackbarManager()42         SnackbarManager getSnackbarManager();
43     }
44 
45     /**
46      * Controller that post entries to snackbar manager and interact with snackbar manager during
47      * dismissal and action click event.
48      */
49     public interface SnackbarController {
50         /**
51          * Called when the user clicks the action button on the snackbar.
52          * @param actionData Data object passed when showing this specific snackbar.
53          */
onAction(Object actionData)54         default void onAction(Object actionData) {}
55 
56         /**
57          * Called when the snackbar is dismissed by timeout or UI environment change.
58          * @param actionData Data object associated with the dismissed snackbar entry.
59          */
onDismissNoAction(Object actionData)60         default void onDismissNoAction(Object actionData) {}
61     }
62 
63     public static final int DEFAULT_SNACKBAR_DURATION_MS = 3000;
64     private static final int ACCESSIBILITY_MODE_SNACKBAR_DURATION_MS = 10000;
65 
66     // Used instead of the constant so tests can override the value.
67     private static int sSnackbarDurationMs = DEFAULT_SNACKBAR_DURATION_MS;
68     private static int sAccessibilitySnackbarDurationMs = ACCESSIBILITY_MODE_SNACKBAR_DURATION_MS;
69 
70     private Activity mActivity;
71     private SnackbarView mView;
72     private final Handler mUIThreadHandler;
73     private SnackbarCollection mSnackbars = new SnackbarCollection();
74     private boolean mActivityInForeground;
75     private boolean mIsDisabledForTesting;
76     private ViewGroup mSnackbarParentView;
77     private final WindowAndroid mWindowAndroid;
78     private final Runnable mHideRunnable = new Runnable() {
79         @Override
80         public void run() {
81             mSnackbars.removeCurrentDueToTimeout();
82             updateView();
83         }
84     };
85 
86     /**
87      * Constructs a SnackbarManager to show snackbars in the given window.
88      * @param activity The embedding activity.
89      * @param snackbarParentView The ViewGroup used to display this snackbar.
90      * @param windowAndroid The WindowAndroid used for starting animation. If it is null,
91      *                      Animator#start is called instead.
92      */
SnackbarManager(Activity activity, ViewGroup snackbarParentView, @Nullable WindowAndroid windowAndroid)93     public SnackbarManager(Activity activity, ViewGroup snackbarParentView,
94             @Nullable WindowAndroid windowAndroid) {
95         mActivity = activity;
96         mUIThreadHandler = new Handler();
97         mSnackbarParentView = snackbarParentView;
98         mWindowAndroid = windowAndroid;
99 
100         ApplicationStatus.registerStateListenerForActivity(this, mActivity);
101         if (ApplicationStatus.getStateForActivity(mActivity) == ActivityState.STARTED
102                 || ApplicationStatus.getStateForActivity(mActivity) == ActivityState.RESUMED) {
103             onStart();
104         }
105     }
106 
107     @Override
onActivityStateChange(Activity activity, @ActivityState int newState)108     public void onActivityStateChange(Activity activity, @ActivityState int newState) {
109         assert activity == mActivity;
110         if (newState == ActivityState.STARTED) {
111             onStart();
112         } else if (newState == ActivityState.STOPPED) {
113             onStop();
114         }
115     }
116 
117     /**
118      * Notifies the snackbar manager that the activity is running in foreground now.
119      */
onStart()120     private void onStart() {
121         mActivityInForeground = true;
122     }
123 
124     /**
125      * Notifies the snackbar manager that the activity has been pushed to background.
126      */
onStop()127     private void onStop() {
128         mSnackbars.clear();
129         updateView();
130         mActivityInForeground = false;
131     }
132 
133     /**
134      * Shows a snackbar at the bottom of the screen, or above the keyboard if the keyboard is
135      * visible.
136      */
showSnackbar(Snackbar snackbar)137     public void showSnackbar(Snackbar snackbar) {
138         if (!mActivityInForeground || mIsDisabledForTesting) return;
139         RecordHistogram.recordSparseHistogram("Snackbar.Shown", snackbar.getIdentifier());
140 
141         mSnackbars.add(snackbar);
142         updateView();
143         mView.announceforAccessibility();
144     }
145 
146     /** Dismisses all snackbars. */
dismissAllSnackbars()147     public void dismissAllSnackbars() {
148         if (mSnackbars.isEmpty()) return;
149 
150         mSnackbars.clear();
151         updateView();
152     }
153 
154     /**
155      * Dismisses snackbars that are associated with the given {@link SnackbarController}.
156      *
157      * @param controller Only snackbars with this controller will be removed.
158      */
dismissSnackbars(SnackbarController controller)159     public void dismissSnackbars(SnackbarController controller) {
160         if (mSnackbars.removeMatchingSnackbars(controller)) {
161             updateView();
162         }
163     }
164 
165     /**
166      * Dismisses snackbars that have a certain controller and action data.
167      *
168      * @param controller Only snackbars with this controller will be removed.
169      * @param actionData Only snackbars whose action data is equal to actionData will be removed.
170      */
dismissSnackbars(SnackbarController controller, Object actionData)171     public void dismissSnackbars(SnackbarController controller, Object actionData) {
172         if (mSnackbars.removeMatchingSnackbars(controller, actionData)) {
173             updateView();
174         }
175     }
176 
177     /**
178      * Handles click event for action button at end of snackbar.
179      */
180     @Override
onClick(View v)181     public void onClick(View v) {
182         mView.announceActionForAccessibility();
183         mSnackbars.removeCurrentDueToAction();
184         updateView();
185     }
186 
187     /**
188      * After an infobar is added, brings snackbar view above it.
189      * TODO(crbug/1028382): Currently SnackbarManager doesn't observe InfobarContainer events.
190      * Restore this functionality, only without references to Infobar classes.
191      */
onAddInfoBar()192     public void onAddInfoBar() {
193         // Bring Snackbars to the foreground so that it's not blocked by infobars.
194         if (isShowing()) {
195             mView.bringToFront();
196         }
197     }
198 
199     /**
200      * Temporarily changes the parent {@link ViewGroup} of the snackbar. If a snackbar is currently
201      * showing, this method removes the snackbar from its original parent, and attaches it to the
202      * given parent. If <code>null</code> is given, the snackbar will be reattached to its original
203      * parent.
204      *
205      * @param overridingParent The temporary parent of the snackbar. If null, previous calls of this
206      *                         method will be reverted.
207      */
overrideParent(ViewGroup overridingParent)208     public void overrideParent(ViewGroup overridingParent) {
209         if (mView != null) mView.overrideParent(overridingParent);
210     }
211 
212     /**
213      * @return Whether there is a snackbar on screen.
214      */
isShowing()215     public boolean isShowing() {
216         return mView != null && mView.isShowing();
217     }
218 
219     /**
220      * Updates the {@link SnackbarView} to reflect the value of mSnackbars.currentSnackbar(), which
221      * may be null. This might show, change, or hide the view.
222      */
updateView()223     private void updateView() {
224         if (!mActivityInForeground) return;
225         Snackbar currentSnackbar = mSnackbars.getCurrent();
226         if (currentSnackbar == null) {
227             mUIThreadHandler.removeCallbacks(mHideRunnable);
228             if (mView != null) {
229                 mView.dismiss();
230                 mView = null;
231             }
232         } else {
233             boolean viewChanged = true;
234             if (mView == null) {
235                 mView = new SnackbarView(
236                         mActivity, this, currentSnackbar, mSnackbarParentView, mWindowAndroid);
237                 mView.show();
238             } else {
239                 viewChanged = mView.update(currentSnackbar);
240             }
241 
242             if (viewChanged) {
243                 mUIThreadHandler.removeCallbacks(mHideRunnable);
244                 if (!currentSnackbar.isTypePersistent()) {
245                     int durationMs = getDuration(currentSnackbar);
246                     mUIThreadHandler.postDelayed(mHideRunnable, durationMs);
247                 }
248                 mView.announceforAccessibility();
249             }
250         }
251     }
252 
getDuration(Snackbar snackbar)253     private int getDuration(Snackbar snackbar) {
254         int durationMs = snackbar.getDuration();
255         if (durationMs == 0) durationMs = sSnackbarDurationMs;
256 
257         if (ChromeAccessibilityUtil.get().isAccessibilityEnabled()) {
258             durationMs *= 2;
259             if (durationMs < sAccessibilitySnackbarDurationMs) {
260                 durationMs = sAccessibilitySnackbarDurationMs;
261             }
262         }
263 
264         return durationMs;
265     }
266 
267     /**
268      * Disables the snackbar manager. This is only intended for testing purposes.
269      */
270     @VisibleForTesting
disableForTesting()271     public void disableForTesting() {
272         mIsDisabledForTesting = true;
273     }
274 
275     /**
276      * Overrides the default snackbar duration with a custom value for testing.
277      * @param durationMs The duration to use in ms.
278      */
279     @VisibleForTesting
setDurationForTesting(int durationMs)280     public static void setDurationForTesting(int durationMs) {
281         sSnackbarDurationMs = durationMs;
282         sAccessibilitySnackbarDurationMs = durationMs;
283     }
284 
285     /**
286      * @return The currently showing snackbar. For testing only.
287      */
288     @VisibleForTesting
getCurrentSnackbarForTesting()289     public Snackbar getCurrentSnackbarForTesting() {
290         return mSnackbars.getCurrent();
291     }
292 }
293