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