NotificationMenuRow.java revision aa94a61c56b8f493fe77fe52b09d0439e7889bc7
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 && !mParent.isDark() 243 && (mCheckForDrag == null || !mHandler.hasCallbacks(mCheckForDrag))) { 244 // Only show the menu if we're not a heads up view and guts aren't exposed. 245 mCheckForDrag = new CheckForDrag(); 246 mHandler.postDelayed(mCheckForDrag, SHOW_MENU_DELAY); 247 } 248 break; 249 250 case MotionEvent.ACTION_UP: 251 return handleUpEvent(ev, view, velocity); 252 } 253 return false; 254 } 255 256 private boolean handleUpEvent(MotionEvent ev, View animView, float velocity) { 257 // If the menu should not be shown, then there is no need to check if the a swipe 258 // should result in a snapping to the menu. As a result, just check if the swipe 259 // was enough to dismiss the notification. 260 if (!mShouldShowMenu) { 261 if (mSwipeHelper.isDismissGesture(ev)) { 262 dismiss(animView, velocity); 263 } else { 264 snapBack(animView, velocity); 265 } 266 return true; 267 } 268 269 final boolean gestureTowardsMenu = isTowardsMenu(velocity); 270 final boolean gestureFastEnough = 271 mSwipeHelper.getMinDismissVelocity() <= Math.abs(velocity); 272 final boolean gestureFarEnough = 273 mSwipeHelper.swipedFarEnough(mTranslation, mParent.getWidth()); 274 final double timeForGesture = ev.getEventTime() - ev.getDownTime(); 275 final boolean showMenuForSlowOnGoing = !mParent.canViewBeDismissed() 276 && timeForGesture >= SWIPE_MENU_TIMING; 277 final float menuSnapTarget = mOnLeft ? getSpaceForMenu() : -getSpaceForMenu(); 278 279 if (DEBUG) { 280 Log.d(TAG, "mTranslation= " + mTranslation 281 + " mAlpha= " + mAlpha 282 + " velocity= " + velocity 283 + " mMenuSnappedTo= " + mMenuSnappedTo 284 + " mMenuSnappedOnLeft= " + mMenuSnappedOnLeft 285 + " mOnLeft= " + mOnLeft 286 + " minDismissVel= " + mSwipeHelper.getMinDismissVelocity() 287 + " isDismissGesture= " + mSwipeHelper.isDismissGesture(ev) 288 + " gestureTowardsMenu= " + gestureTowardsMenu 289 + " gestureFastEnough= " + gestureFastEnough 290 + " gestureFarEnough= " + gestureFarEnough); 291 } 292 293 if (mMenuSnappedTo && isMenuVisible() && mMenuSnappedOnLeft == mOnLeft) { 294 // Menu was snapped to previously and we're on the same side, figure out if 295 // we should stick to the menu, snap back into place, or dismiss 296 final float maximumSwipeDistance = mHorizSpaceForIcon 297 * SWIPED_BACK_ENOUGH_TO_COVER_FRACTION; 298 final float targetLeft = getSpaceForMenu() - maximumSwipeDistance; 299 final float targetRight = mParent.getWidth() * SWIPED_FAR_ENOUGH_SIZE_FRACTION; 300 boolean withinSnapMenuThreshold = mOnLeft 301 ? mTranslation > targetLeft && mTranslation < targetRight 302 : mTranslation < -targetLeft && mTranslation > -targetRight; 303 boolean shouldSnapTo = mOnLeft ? mTranslation < targetLeft : mTranslation > -targetLeft; 304 if (DEBUG) { 305 Log.d(TAG, " withinSnapMenuThreshold= " + withinSnapMenuThreshold 306 + " shouldSnapTo= " + shouldSnapTo 307 + " targetLeft= " + targetLeft 308 + " targetRight= " + targetRight); 309 } 310 if (withinSnapMenuThreshold && !mSwipeHelper.isDismissGesture(ev)) { 311 // Haven't moved enough to unsnap from the menu 312 showMenu(animView, menuSnapTarget, velocity); 313 } else if (mSwipeHelper.isDismissGesture(ev) && !shouldSnapTo) { 314 // Only dismiss if we're not moving towards the menu 315 dismiss(animView, velocity); 316 } else { 317 snapBack(animView, velocity); 318 } 319 } else if ((swipedEnoughToShowMenu() && (!gestureFastEnough || showMenuForSlowOnGoing)) 320 || (gestureTowardsMenu && !mSwipeHelper.isDismissGesture(ev))) { 321 // Menu has not been snapped to previously and this is menu revealing gesture 322 showMenu(animView, menuSnapTarget, velocity); 323 } else if (mSwipeHelper.isDismissGesture(ev) && !gestureTowardsMenu) { 324 dismiss(animView, velocity); 325 } else { 326 snapBack(animView, velocity); 327 } 328 return true; 329 } 330 331 private void showMenu(View animView, float targetLeft, float velocity) { 332 mMenuSnappedTo = true; 333 mMenuSnappedOnLeft = mOnLeft; 334 mMenuListener.onMenuShown(animView); 335 mSwipeHelper.snap(animView, targetLeft, velocity); 336 } 337 338 private void snapBack(View animView, float velocity) { 339 if (mFadeAnimator != null) { 340 mFadeAnimator.cancel(); 341 } 342 mHandler.removeCallbacks(mCheckForDrag); 343 mMenuSnappedTo = false; 344 mSnapping = true; 345 mSwipeHelper.snap(animView, 0 /* leftTarget */, velocity); 346 } 347 348 private void dismiss(View animView, float velocity) { 349 if (mFadeAnimator != null) { 350 mFadeAnimator.cancel(); 351 } 352 mHandler.removeCallbacks(mCheckForDrag); 353 mMenuSnappedTo = false; 354 mDismissing = true; 355 mSwipeHelper.dismiss(animView, velocity); 356 } 357 358 /** 359 * @return whether the notification has been translated enough to show the menu and not enough 360 * to be dismissed. 361 */ 362 private boolean swipedEnoughToShowMenu() { 363 final float multiplier = mParent.canViewBeDismissed() 364 ? SWIPED_FAR_ENOUGH_MENU_FRACTION 365 : SWIPED_FAR_ENOUGH_MENU_UNCLEARABLE_FRACTION; 366 final float minimumSwipeDistance = mHorizSpaceForIcon * multiplier; 367 return !mSwipeHelper.swipedFarEnough(0, 0) && isMenuVisible() 368 && (mOnLeft ? mTranslation > minimumSwipeDistance 369 : mTranslation < -minimumSwipeDistance); 370 } 371 372 /** 373 * Returns whether the gesture is towards the menu location or not. 374 */ 375 private boolean isTowardsMenu(float movement) { 376 return isMenuVisible() 377 && ((mOnLeft && movement <= 0) 378 || (!mOnLeft && movement >= 0)); 379 } 380 381 @Override 382 public void setAppName(String appName) { 383 if (appName == null) { 384 return; 385 } 386 Resources res = mContext.getResources(); 387 final int count = mMenuItems.size(); 388 for (int i = 0; i < count; i++) { 389 MenuItem item = mMenuItems.get(i); 390 String description = String.format( 391 res.getString(R.string.notification_menu_accessibility), 392 appName, item.getContentDescription()); 393 View menuView = item.getMenuView(); 394 if (menuView != null) { 395 menuView.setContentDescription(description); 396 } 397 } 398 } 399 400 @Override 401 public void onHeightUpdate() { 402 if (mParent == null || mMenuItems.size() == 0) { 403 return; 404 } 405 int parentHeight = mParent.getCollapsedHeight(); 406 float translationY; 407 if (parentHeight < mVertSpaceForIcons) { 408 translationY = (parentHeight / 2) - (mHorizSpaceForIcon / 2); 409 } else { 410 translationY = (mVertSpaceForIcons - mHorizSpaceForIcon) / 2; 411 } 412 mMenuContainer.setTranslationY(translationY); 413 } 414 415 @Override 416 public void onTranslationUpdate(float translation) { 417 mTranslation = translation; 418 if (mAnimating || !mMenuFadedIn) { 419 // Don't adjust when animating, or if the menu hasn't been shown yet. 420 return; 421 } 422 final float fadeThreshold = mParent.getWidth() * 0.3f; 423 final float absTrans = Math.abs(translation); 424 float desiredAlpha = 0; 425 if (absTrans == 0) { 426 desiredAlpha = 0; 427 } else if (absTrans <= fadeThreshold) { 428 desiredAlpha = 1; 429 } else { 430 desiredAlpha = 1 - ((absTrans - fadeThreshold) / (mParent.getWidth() - fadeThreshold)); 431 } 432 setMenuAlpha(desiredAlpha); 433 } 434 435 @Override 436 public void onClick(View v) { 437 if (mMenuListener == null) { 438 // Nothing to do 439 return; 440 } 441 v.getLocationOnScreen(mIconLocation); 442 mParent.getLocationOnScreen(mParentLocation); 443 final int centerX = (int) (mHorizSpaceForIcon / 2); 444 final int centerY = v.getHeight() / 2; 445 final int x = mIconLocation[0] - mParentLocation[0] + centerX; 446 final int y = mIconLocation[1] - mParentLocation[1] + centerY; 447 final int index = mMenuContainer.indexOfChild(v); 448 mMenuListener.onMenuClicked(mParent, x, y, mMenuItems.get(index)); 449 } 450 451 private boolean isMenuLocationChange() { 452 boolean onLeft = mTranslation > mIconPadding; 453 boolean onRight = mTranslation < -mIconPadding; 454 if ((mOnLeft && onRight) || (!mOnLeft && onLeft)) { 455 return true; 456 } 457 return false; 458 } 459 460 private void setMenuLocation() { 461 boolean showOnLeft = mTranslation > 0; 462 if ((mIconsPlaced && showOnLeft == mOnLeft) || mSnapping || mParent == null) { 463 // Do nothing 464 return; 465 } 466 final boolean isRtl = mParent.isLayoutRtl(); 467 final int count = mMenuContainer.getChildCount(); 468 final int width = mParent.getWidth(); 469 for (int i = 0; i < count; i++) { 470 final View v = mMenuContainer.getChildAt(i); 471 final float left = isRtl 472 ? -(width - mHorizSpaceForIcon * (i + 1)) 473 : i * mHorizSpaceForIcon; 474 final float right = isRtl 475 ? -i * mHorizSpaceForIcon 476 : width - (mHorizSpaceForIcon * (i + 1)); 477 v.setTranslationX(showOnLeft ? left : right); 478 } 479 mOnLeft = showOnLeft; 480 mIconsPlaced = true; 481 } 482 483 private void setMenuAlpha(float alpha) { 484 mAlpha = alpha; 485 if (mMenuContainer == null) { 486 return; 487 } 488 if (alpha == 0) { 489 mMenuFadedIn = false; // Can fade in again once it's gone. 490 mMenuContainer.setVisibility(View.INVISIBLE); 491 } else { 492 mMenuContainer.setVisibility(View.VISIBLE); 493 } 494 final int count = mMenuContainer.getChildCount(); 495 for (int i = 0; i < count; i++) { 496 mMenuContainer.getChildAt(i).setAlpha(mAlpha); 497 } 498 } 499 500 /** 501 * Returns the horizontal space in pixels required to display the menu. 502 */ 503 private float getSpaceForMenu() { 504 return mHorizSpaceForIcon * mMenuContainer.getChildCount(); 505 } 506 507 private final class CheckForDrag implements Runnable { 508 @Override 509 public void run() { 510 final float absTransX = Math.abs(mTranslation); 511 final float bounceBackToMenuWidth = getSpaceForMenu(); 512 final float notiThreshold = mParent.getWidth() * 0.4f; 513 if ((!isMenuVisible() || isMenuLocationChange()) 514 && absTransX >= bounceBackToMenuWidth * 0.4 515 && absTransX < notiThreshold) { 516 fadeInMenu(notiThreshold); 517 } 518 } 519 } 520 521 private void fadeInMenu(final float notiThreshold) { 522 if (mDismissing || mAnimating) { 523 return; 524 } 525 if (isMenuLocationChange()) { 526 setMenuAlpha(0f); 527 } 528 final float transX = mTranslation; 529 final boolean fromLeft = mTranslation > 0; 530 setMenuLocation(); 531 mFadeAnimator = ValueAnimator.ofFloat(mAlpha, 1); 532 mFadeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 533 @Override 534 public void onAnimationUpdate(ValueAnimator animation) { 535 final float absTrans = Math.abs(transX); 536 537 boolean pastMenu = (fromLeft && transX <= notiThreshold) 538 || (!fromLeft && absTrans <= notiThreshold); 539 if (pastMenu && !mMenuFadedIn) { 540 setMenuAlpha((float) animation.getAnimatedValue()); 541 } 542 } 543 }); 544 mFadeAnimator.addListener(new AnimatorListenerAdapter() { 545 @Override 546 public void onAnimationStart(Animator animation) { 547 mAnimating = true; 548 } 549 550 @Override 551 public void onAnimationCancel(Animator animation) { 552 // TODO should animate back to 0f from current alpha 553 setMenuAlpha(0f); 554 } 555 556 @Override 557 public void onAnimationEnd(Animator animation) { 558 mAnimating = false; 559 mMenuFadedIn = mAlpha == 1; 560 } 561 }); 562 mFadeAnimator.setInterpolator(Interpolators.ALPHA_IN); 563 mFadeAnimator.setDuration(ICON_ALPHA_ANIM_DURATION); 564 mFadeAnimator.start(); 565 } 566 567 @Override 568 public void setMenuItems(ArrayList<MenuItem> items) { 569 // Do nothing we use our own for now. 570 // TODO -- handle / allow custom menu items! 571 } 572 573 public static MenuItem createSnoozeItem(Context context) { 574 Resources res = context.getResources(); 575 NotificationSnooze content = (NotificationSnooze) LayoutInflater.from(context) 576 .inflate(R.layout.notification_snooze, null, false); 577 String snoozeDescription = res.getString(R.string.notification_menu_snooze_description); 578 MenuItem snooze = new NotificationMenuItem(context, snoozeDescription, content, 579 R.drawable.ic_snooze); 580 return snooze; 581 } 582 583 public static MenuItem createInfoItem(Context context) { 584 Resources res = context.getResources(); 585 String infoDescription = res.getString(R.string.notification_menu_gear_description); 586 NotificationInfo infoContent = (NotificationInfo) LayoutInflater.from(context).inflate( 587 R.layout.notification_info, null, false); 588 MenuItem info = new NotificationMenuItem(context, infoDescription, infoContent, 589 R.drawable.ic_settings); 590 return info; 591 } 592 593 private void addMenuView(MenuItem item, ViewGroup parent) { 594 View menuView = item.getMenuView(); 595 if (menuView != null) { 596 parent.addView(menuView); 597 menuView.setOnClickListener(this); 598 FrameLayout.LayoutParams lp = (LayoutParams) menuView.getLayoutParams(); 599 lp.width = (int) mHorizSpaceForIcon; 600 lp.height = (int) mHorizSpaceForIcon; 601 menuView.setLayoutParams(lp); 602 } 603 } 604 605 public static class NotificationMenuItem implements MenuItem { 606 View mMenuView; 607 GutsContent mGutsContent; 608 String mContentDescription; 609 610 public NotificationMenuItem(Context context, String s, GutsContent content, int iconResId) { 611 Resources res = context.getResources(); 612 int padding = res.getDimensionPixelSize(R.dimen.notification_menu_icon_padding); 613 int tint = res.getColor(R.color.notification_gear_color); 614 AlphaOptimizedImageView iv = new AlphaOptimizedImageView(context); 615 iv.setPadding(padding, padding, padding, padding); 616 Drawable icon = context.getResources().getDrawable(iconResId); 617 iv.setImageDrawable(icon); 618 iv.setColorFilter(tint); 619 iv.setAlpha(1f); 620 mMenuView = iv; 621 mContentDescription = s; 622 mGutsContent = content; 623 } 624 625 @Override 626 public View getMenuView() { 627 return mMenuView; 628 } 629 630 @Override 631 public View getGutsView() { 632 return mGutsContent.getContentView(); 633 } 634 635 @Override 636 public String getContentDescription() { 637 return mContentDescription; 638 } 639 } 640} 641