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