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