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