NotificationMenuRow.java revision 5574425c32c75825004471434e0cd2fccdd4e5c0
1/* 2 * Copyright (C) 2016 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 17package com.android.systemui.statusbar; 18 19import static com.android.systemui.SwipeHelper.SWIPED_FAR_ENOUGH_SIZE_FRACTION; 20 21import java.util.ArrayList; 22 23import com.android.systemui.Interpolators; 24import com.android.systemui.R; 25import com.android.systemui.plugins.statusbar.NotificationMenuRowPlugin; 26import com.android.systemui.plugins.statusbar.NotificationSwipeActionHelper; 27import com.android.systemui.statusbar.NotificationGuts.GutsContent; 28import com.android.systemui.statusbar.stack.NotificationStackScrollLayout; 29 30import android.animation.Animator; 31import android.animation.AnimatorListenerAdapter; 32import android.animation.ValueAnimator; 33import android.app.Notification; 34import android.content.Context; 35import android.content.res.Resources; 36import android.graphics.drawable.Drawable; 37import android.os.Handler; 38import android.util.Log; 39import android.service.notification.StatusBarNotification; 40import android.view.LayoutInflater; 41import android.view.MotionEvent; 42import android.view.View; 43import android.view.ViewGroup; 44import android.widget.FrameLayout; 45import android.widget.FrameLayout.LayoutParams; 46 47public class NotificationMenuRow implements NotificationMenuRowPlugin, View.OnClickListener { 48 49 private static final boolean DEBUG = false; 50 private static final String TAG = "swipe"; 51 52 private static final int ICON_ALPHA_ANIM_DURATION = 200; 53 private static final long SHOW_MENU_DELAY = 60; 54 private static final long SWIPE_MENU_TIMING = 200; 55 56 // Notification must be swiped at least this fraction of a single menu item to show menu 57 private static final float SWIPED_FAR_ENOUGH_MENU_FRACTION = 0.25f; 58 private static final float SWIPED_FAR_ENOUGH_MENU_UNCLEARABLE_FRACTION = 0.15f; 59 60 // When the menu is displayed, the notification must be swiped within this fraction of a single 61 // menu item to snap back to menu (else it will cover the menu or it'll be dismissed) 62 private static final float SWIPED_BACK_ENOUGH_TO_COVER_FRACTION = 0.2f; 63 64 private ExpandableNotificationRow mParent; 65 66 private Context mContext; 67 private FrameLayout mMenuContainer; 68 private MenuItem mSnoozeItem; 69 private MenuItem mInfoItem; 70 private ArrayList<MenuItem> mMenuItems; 71 private OnMenuEventListener mMenuListener; 72 73 private ValueAnimator mFadeAnimator; 74 private boolean mAnimating; 75 private boolean mMenuFadedIn; 76 77 private boolean mOnLeft; 78 private boolean mIconsPlaced; 79 80 private boolean mDismissing; 81 private boolean mSnapping; 82 private float mTranslation; 83 84 private int[] mIconLocation = new int[2]; 85 private int[] mParentLocation = new int[2]; 86 87 private float mHorizSpaceForIcon; 88 private int mVertSpaceForIcons; 89 private int mIconPadding; 90 91 private float mAlpha = 0f; 92 private float mPrevX; 93 94 private CheckForDrag mCheckForDrag; 95 private Handler mHandler; 96 97 private boolean mMenuSnappedTo; 98 private boolean mMenuSnappedOnLeft; 99 private boolean mShouldShowMenu; 100 101 private NotificationSwipeActionHelper mSwipeHelper; 102 103 public NotificationMenuRow(Context context) { 104 mContext = context; 105 final Resources res = context.getResources(); 106 mShouldShowMenu = res.getBoolean(R.bool.config_showNotificationGear); 107 mHorizSpaceForIcon = res.getDimensionPixelSize(R.dimen.notification_menu_icon_size); 108 mVertSpaceForIcons = res.getDimensionPixelSize(R.dimen.notification_min_height); 109 mIconPadding = res.getDimensionPixelSize(R.dimen.notification_menu_icon_padding); 110 mHandler = new Handler(); 111 mMenuItems = new ArrayList<>(); 112 mSnoozeItem = createSnoozeItem(context); 113 mInfoItem = createInfoItem(context); 114 mMenuItems.add(mSnoozeItem); 115 mMenuItems.add(mInfoItem); 116 } 117 118 @Override 119 public ArrayList<MenuItem> getMenuItems(Context context) { 120 return mMenuItems; 121 } 122 123 @Override 124 public MenuItem getLongpressMenuItem(Context context) { 125 return mInfoItem; 126 } 127 128 @Override 129 public void setSwipeActionHelper(NotificationSwipeActionHelper helper) { 130 mSwipeHelper = helper; 131 } 132 133 @Override 134 public void setMenuClickListener(OnMenuEventListener listener) { 135 mMenuListener = listener; 136 } 137 138 @Override 139 public void createMenu(ViewGroup parent) { 140 mParent = (ExpandableNotificationRow) parent; 141 createMenuViews(); 142 } 143 144 @Override 145 public boolean isMenuVisible() { 146 return mAlpha > 0; 147 } 148 149 @Override 150 public View getMenuView() { 151 return mMenuContainer; 152 } 153 154 @Override 155 public void resetMenu() { 156 resetState(true); 157 } 158 159 @Override 160 public void onNotificationUpdated() { 161 if (mMenuContainer == null) { 162 // Menu hasn't been created yet, no need to do anything. 163 return; 164 } 165 createMenuViews(); 166 } 167 168 private void createMenuViews() { 169 // Filter the menu items based on the notification 170 if (mParent != null && mParent.getStatusBarNotification() != null) { 171 int flags = mParent.getStatusBarNotification().getNotification().flags; 172 boolean isForeground = (flags & Notification.FLAG_FOREGROUND_SERVICE) != 0; 173 if (isForeground) { 174 // Don't show snooze for foreground services 175 mMenuItems.remove(mSnoozeItem); 176 } else if (!mMenuItems.contains(mSnoozeItem)) { 177 // Was a foreground service but is no longer, add snooze back 178 mMenuItems.add(mSnoozeItem); 179 } 180 } 181 // Recreate the menu 182 if (mMenuContainer != null) { 183 mMenuContainer.removeAllViews(); 184 } else { 185 mMenuContainer = new FrameLayout(mContext); 186 } 187 for (int i = 0; i < mMenuItems.size(); i++) { 188 addMenuView(mMenuItems.get(i), mMenuContainer); 189 } 190 resetState(false /* notify */); 191 } 192 193 private void resetState(boolean notify) { 194 setMenuAlpha(0f); 195 mIconsPlaced = false; 196 mMenuFadedIn = false; 197 mAnimating = false; 198 mSnapping = false; 199 mDismissing = false; 200 mMenuSnappedTo = false; 201 setMenuLocation(); 202 if (mMenuListener != null && notify) { 203 mMenuListener.onMenuReset(mParent); 204 } 205 } 206 207 @Override 208 public boolean onTouchEvent(View view, MotionEvent ev, float velocity) { 209 final int action = ev.getActionMasked(); 210 switch (action) { 211 case MotionEvent.ACTION_DOWN: 212 mSnapping = false; 213 if (mFadeAnimator != null) { 214 mFadeAnimator.cancel(); 215 } 216 mHandler.removeCallbacks(mCheckForDrag); 217 mCheckForDrag = null; 218 mPrevX = ev.getRawX(); 219 break; 220 221 case MotionEvent.ACTION_MOVE: 222 mSnapping = false; 223 float diffX = ev.getRawX() - mPrevX; 224 mPrevX = ev.getRawX(); 225 if (!isTowardsMenu(diffX) && isMenuLocationChange()) { 226 // Don't consider it "snapped" if location has changed. 227 mMenuSnappedTo = false; 228 229 // Changed directions, make sure we check to fade in icon again. 230 if (!mHandler.hasCallbacks(mCheckForDrag)) { 231 // No check scheduled, set null to schedule a new one. 232 mCheckForDrag = null; 233 } else { 234 // Check scheduled, reset alpha and update location; check will fade it in 235 setMenuAlpha(0f); 236 setMenuLocation(); 237 } 238 } 239 if (mShouldShowMenu 240 && !NotificationStackScrollLayout.isPinnedHeadsUp(view) 241 && !mParent.areGutsExposed() 242 && (mCheckForDrag == null || !mHandler.hasCallbacks(mCheckForDrag))) { 243 // Only show the menu if we're not a heads up view and guts aren't exposed. 244 mCheckForDrag = new CheckForDrag(); 245 mHandler.postDelayed(mCheckForDrag, SHOW_MENU_DELAY); 246 } 247 break; 248 249 case MotionEvent.ACTION_UP: 250 return handleUpEvent(ev, view, velocity); 251 } 252 return false; 253 } 254 255 private boolean handleUpEvent(MotionEvent ev, View animView, float velocity) { 256 // If the menu should not be shown, then there is no need to check if the a swipe 257 // should result in a snapping to the menu. As a result, just check if the swipe 258 // was enough to dismiss the notification. 259 if (!mShouldShowMenu) { 260 if (mSwipeHelper.isDismissGesture(ev)) { 261 dismiss(animView, velocity); 262 } else { 263 snapBack(animView, velocity); 264 } 265 return true; 266 } 267 268 final boolean gestureTowardsMenu = isTowardsMenu(velocity); 269 final boolean gestureFastEnough = 270 mSwipeHelper.getMinDismissVelocity() <= Math.abs(velocity); 271 final boolean gestureFarEnough = 272 mSwipeHelper.swipedFarEnough(mTranslation, mParent.getWidth()); 273 final double timeForGesture = ev.getEventTime() - ev.getDownTime(); 274 final boolean showMenuForSlowOnGoing = !mParent.canViewBeDismissed() 275 && timeForGesture >= SWIPE_MENU_TIMING; 276 final float menuSnapTarget = mOnLeft ? getSpaceForMenu() : -getSpaceForMenu(); 277 278 if (DEBUG) { 279 Log.d(TAG, "mTranslation= " + mTranslation 280 + " mAlpha= " + mAlpha 281 + " velocity= " + velocity 282 + " mMenuSnappedTo= " + mMenuSnappedTo 283 + " mMenuSnappedOnLeft= " + mMenuSnappedOnLeft 284 + " mOnLeft= " + mOnLeft 285 + " minDismissVel= " + mSwipeHelper.getMinDismissVelocity() 286 + " isDismissGesture= " + mSwipeHelper.isDismissGesture(ev) 287 + " gestureTowardsMenu= " + gestureTowardsMenu 288 + " gestureFastEnough= " + gestureFastEnough 289 + " gestureFarEnough= " + gestureFarEnough); 290 } 291 292 if (mMenuSnappedTo && isMenuVisible() && mMenuSnappedOnLeft == mOnLeft) { 293 // Menu was snapped to previously and we're on the same side, figure out if 294 // we should stick to the menu, snap back into place, or dismiss 295 final float maximumSwipeDistance = mHorizSpaceForIcon 296 * SWIPED_BACK_ENOUGH_TO_COVER_FRACTION; 297 final float targetLeft = getSpaceForMenu() - maximumSwipeDistance; 298 final float targetRight = mParent.getWidth() * SWIPED_FAR_ENOUGH_SIZE_FRACTION; 299 boolean withinSnapMenuThreshold = mOnLeft 300 ? mTranslation > targetLeft && mTranslation < targetRight 301 : mTranslation < -targetLeft && mTranslation > -targetRight; 302 boolean shouldSnapTo = mOnLeft ? mTranslation < targetLeft : mTranslation > -targetLeft; 303 if (DEBUG) { 304 Log.d(TAG, " withinSnapMenuThreshold= " + withinSnapMenuThreshold 305 + " shouldSnapTo= " + shouldSnapTo 306 + " targetLeft= " + targetLeft 307 + " targetRight= " + targetRight); 308 } 309 if (withinSnapMenuThreshold && !mSwipeHelper.isDismissGesture(ev)) { 310 // Haven't moved enough to unsnap from the menu 311 showMenu(animView, menuSnapTarget, velocity); 312 } else if (mSwipeHelper.isDismissGesture(ev) && !shouldSnapTo) { 313 // Only dismiss if we're not moving towards the menu 314 dismiss(animView, velocity); 315 } else { 316 snapBack(animView, velocity); 317 } 318 } else if ((swipedEnoughToShowMenu() && (!gestureFastEnough || showMenuForSlowOnGoing)) 319 || (gestureTowardsMenu && !mSwipeHelper.isDismissGesture(ev))) { 320 // Menu has not been snapped to previously and this is menu revealing gesture 321 showMenu(animView, menuSnapTarget, velocity); 322 } else if (mSwipeHelper.isDismissGesture(ev) && !gestureTowardsMenu) { 323 dismiss(animView, velocity); 324 } else { 325 snapBack(animView, velocity); 326 } 327 return true; 328 } 329 330 private void showMenu(View animView, float targetLeft, float velocity) { 331 mMenuSnappedTo = true; 332 mMenuSnappedOnLeft = mOnLeft; 333 mMenuListener.onMenuShown(animView); 334 mSwipeHelper.snap(animView, targetLeft, velocity); 335 } 336 337 private void snapBack(View animView, float velocity) { 338 mMenuSnappedTo = false; 339 mSnapping = true; 340 mSwipeHelper.snap(animView, 0 /* leftTarget */, velocity); 341 } 342 343 private void dismiss(View animView, float velocity) { 344 mMenuSnappedTo = false; 345 mDismissing = true; 346 mSwipeHelper.dismiss(animView, velocity); 347 } 348 349 /** 350 * @return whether the notification has been translated enough to show the menu and not enough 351 * to be dismissed. 352 */ 353 private boolean swipedEnoughToShowMenu() { 354 final float multiplier = mParent.canViewBeDismissed() 355 ? SWIPED_FAR_ENOUGH_MENU_FRACTION 356 : SWIPED_FAR_ENOUGH_MENU_UNCLEARABLE_FRACTION; 357 final float minimumSwipeDistance = mHorizSpaceForIcon * multiplier; 358 return !mSwipeHelper.swipedFarEnough(0, 0) && isMenuVisible() 359 && (mOnLeft ? mTranslation > minimumSwipeDistance 360 : mTranslation < -minimumSwipeDistance); 361 } 362 363 /** 364 * Returns whether the gesture is towards the menu location or not. 365 */ 366 private boolean isTowardsMenu(float movement) { 367 return isMenuVisible() 368 && ((mOnLeft && movement <= 0) 369 || (!mOnLeft && movement >= 0)); 370 } 371 372 @Override 373 public void setAppName(String appName) { 374 if (appName == null) { 375 return; 376 } 377 Resources res = mContext.getResources(); 378 final int count = mMenuItems.size(); 379 for (int i = 0; i < count; i++) { 380 MenuItem item = mMenuItems.get(i); 381 String description = String.format( 382 res.getString(R.string.notification_menu_accessibility), 383 appName, item.getContentDescription()); 384 View menuView = item.getMenuView(); 385 if (menuView != null) { 386 menuView.setContentDescription(description); 387 } 388 } 389 } 390 391 @Override 392 public void onHeightUpdate() { 393 if (mParent == null || mMenuItems.size() == 0) { 394 return; 395 } 396 int parentHeight = mParent.getCollapsedHeight(); 397 float translationY; 398 if (parentHeight < mVertSpaceForIcons) { 399 translationY = (parentHeight / 2) - (mHorizSpaceForIcon / 2); 400 } else { 401 translationY = (mVertSpaceForIcons - mHorizSpaceForIcon) / 2; 402 } 403 mMenuContainer.setTranslationY(translationY); 404 } 405 406 @Override 407 public void onTranslationUpdate(float translation) { 408 mTranslation = translation; 409 if (mAnimating || !mMenuFadedIn) { 410 // Don't adjust when animating, or if the menu hasn't been shown yet. 411 return; 412 } 413 final float fadeThreshold = mParent.getWidth() * 0.3f; 414 final float absTrans = Math.abs(translation); 415 float desiredAlpha = 0; 416 if (absTrans == 0) { 417 desiredAlpha = 0; 418 } else if (absTrans <= fadeThreshold) { 419 desiredAlpha = 1; 420 } else { 421 desiredAlpha = 1 - ((absTrans - fadeThreshold) / (mParent.getWidth() - fadeThreshold)); 422 } 423 setMenuAlpha(desiredAlpha); 424 } 425 426 @Override 427 public void onClick(View v) { 428 if (mMenuListener == null) { 429 // Nothing to do 430 return; 431 } 432 v.getLocationOnScreen(mIconLocation); 433 mParent.getLocationOnScreen(mParentLocation); 434 final int centerX = (int) (mHorizSpaceForIcon / 2); 435 final int centerY = v.getHeight() / 2; 436 final int x = mIconLocation[0] - mParentLocation[0] + centerX; 437 final int y = mIconLocation[1] - mParentLocation[1] + centerY; 438 final int index = mMenuContainer.indexOfChild(v); 439 mMenuListener.onMenuClicked(mParent, x, y, mMenuItems.get(index)); 440 } 441 442 private boolean isMenuLocationChange() { 443 boolean onLeft = mTranslation > mIconPadding; 444 boolean onRight = mTranslation < -mIconPadding; 445 if ((mOnLeft && onRight) || (!mOnLeft && onLeft)) { 446 return true; 447 } 448 return false; 449 } 450 451 private void setMenuLocation() { 452 boolean showOnLeft = mTranslation > 0; 453 if ((mIconsPlaced && showOnLeft == mOnLeft) || mSnapping || mParent == null) { 454 // Do nothing 455 return; 456 } 457 final boolean isRtl = mParent.isLayoutRtl(); 458 final int count = mMenuContainer.getChildCount(); 459 final int width = mParent.getWidth(); 460 for (int i = 0; i < count; i++) { 461 final View v = mMenuContainer.getChildAt(i); 462 final float left = isRtl 463 ? -(width - mHorizSpaceForIcon * (i + 1)) 464 : i * mHorizSpaceForIcon; 465 final float right = isRtl 466 ? -i * mHorizSpaceForIcon 467 : width - (mHorizSpaceForIcon * (i + 1)); 468 v.setTranslationX(showOnLeft ? left : right); 469 } 470 mOnLeft = showOnLeft; 471 mIconsPlaced = true; 472 } 473 474 private void setMenuAlpha(float alpha) { 475 mAlpha = alpha; 476 if (mMenuContainer == null) { 477 return; 478 } 479 if (alpha == 0) { 480 mMenuFadedIn = false; // Can fade in again once it's gone. 481 mMenuContainer.setVisibility(View.INVISIBLE); 482 } else { 483 mMenuContainer.setVisibility(View.VISIBLE); 484 } 485 final int count = mMenuContainer.getChildCount(); 486 for (int i = 0; i < count; i++) { 487 mMenuContainer.getChildAt(i).setAlpha(mAlpha); 488 } 489 } 490 491 /** 492 * Returns the horizontal space in pixels required to display the menu. 493 */ 494 private float getSpaceForMenu() { 495 return mHorizSpaceForIcon * mMenuContainer.getChildCount(); 496 } 497 498 private final class CheckForDrag implements Runnable { 499 @Override 500 public void run() { 501 final float absTransX = Math.abs(mTranslation); 502 final float bounceBackToMenuWidth = getSpaceForMenu(); 503 final float notiThreshold = mParent.getWidth() * 0.4f; 504 if ((!isMenuVisible() || isMenuLocationChange()) 505 && absTransX >= bounceBackToMenuWidth * 0.4 506 && absTransX < notiThreshold) { 507 fadeInMenu(notiThreshold); 508 } 509 } 510 } 511 512 private void fadeInMenu(final float notiThreshold) { 513 if (mDismissing || mAnimating) { 514 return; 515 } 516 if (isMenuLocationChange()) { 517 setMenuAlpha(0f); 518 } 519 final float transX = mTranslation; 520 final boolean fromLeft = mTranslation > 0; 521 setMenuLocation(); 522 mFadeAnimator = ValueAnimator.ofFloat(mAlpha, 1); 523 mFadeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 524 @Override 525 public void onAnimationUpdate(ValueAnimator animation) { 526 final float absTrans = Math.abs(transX); 527 528 boolean pastMenu = (fromLeft && transX <= notiThreshold) 529 || (!fromLeft && absTrans <= notiThreshold); 530 if (pastMenu && !mMenuFadedIn) { 531 setMenuAlpha((float) animation.getAnimatedValue()); 532 } 533 } 534 }); 535 mFadeAnimator.addListener(new AnimatorListenerAdapter() { 536 @Override 537 public void onAnimationStart(Animator animation) { 538 mAnimating = true; 539 } 540 541 @Override 542 public void onAnimationCancel(Animator animation) { 543 // TODO should animate back to 0f from current alpha 544 setMenuAlpha(0f); 545 } 546 547 @Override 548 public void onAnimationEnd(Animator animation) { 549 mAnimating = false; 550 mMenuFadedIn = mAlpha == 1; 551 } 552 }); 553 mFadeAnimator.setInterpolator(Interpolators.ALPHA_IN); 554 mFadeAnimator.setDuration(ICON_ALPHA_ANIM_DURATION); 555 mFadeAnimator.start(); 556 } 557 558 @Override 559 public void setMenuItems(ArrayList<MenuItem> items) { 560 // Do nothing we use our own for now. 561 // TODO -- handle / allow custom menu items! 562 } 563 564 public static MenuItem createSnoozeItem(Context context) { 565 Resources res = context.getResources(); 566 NotificationSnooze content = (NotificationSnooze) LayoutInflater.from(context) 567 .inflate(R.layout.notification_snooze, null, false); 568 String snoozeDescription = res.getString(R.string.notification_menu_snooze_description); 569 MenuItem snooze = new NotificationMenuItem(context, snoozeDescription, content, 570 R.drawable.ic_snooze); 571 return snooze; 572 } 573 574 public static MenuItem createInfoItem(Context context) { 575 Resources res = context.getResources(); 576 String infoDescription = res.getString(R.string.notification_menu_gear_description); 577 NotificationInfo infoContent = (NotificationInfo) LayoutInflater.from(context).inflate( 578 R.layout.notification_info, null, false); 579 MenuItem info = new NotificationMenuItem(context, infoDescription, infoContent, 580 R.drawable.ic_settings); 581 return info; 582 } 583 584 private void addMenuView(MenuItem item, ViewGroup parent) { 585 View menuView = item.getMenuView(); 586 if (menuView != null) { 587 parent.addView(menuView); 588 menuView.setOnClickListener(this); 589 FrameLayout.LayoutParams lp = (LayoutParams) menuView.getLayoutParams(); 590 lp.width = (int) mHorizSpaceForIcon; 591 lp.height = (int) mHorizSpaceForIcon; 592 menuView.setLayoutParams(lp); 593 } 594 } 595 596 public static class NotificationMenuItem implements MenuItem { 597 View mMenuView; 598 GutsContent mGutsContent; 599 String mContentDescription; 600 601 public NotificationMenuItem(Context context, String s, GutsContent content, int iconResId) { 602 Resources res = context.getResources(); 603 int padding = res.getDimensionPixelSize(R.dimen.notification_menu_icon_padding); 604 int tint = res.getColor(R.color.notification_gear_color); 605 AlphaOptimizedImageView iv = new AlphaOptimizedImageView(context); 606 iv.setPadding(padding, padding, padding, padding); 607 Drawable icon = context.getResources().getDrawable(iconResId); 608 iv.setImageDrawable(icon); 609 iv.setColorFilter(tint); 610 iv.setAlpha(1f); 611 mMenuView = iv; 612 mContentDescription = s; 613 mGutsContent = content; 614 } 615 616 @Override 617 public View getMenuView() { 618 return mMenuView; 619 } 620 621 @Override 622 public View getGutsView() { 623 return mGutsContent.getContentView(); 624 } 625 626 @Override 627 public String getContentDescription() { 628 return mContentDescription; 629 } 630 } 631} 632