NotificationMenuRow.java revision 4c19760b9131a67b379f9b7060c5830cb5beaad7
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 mMenuSnappedTo = false; 310 mSnapping = true; 311 mSwipeHelper.snap(animView, 0 /* leftTarget */, velocity); 312 } 313 314 private void dismiss(View animView, float velocity) { 315 mMenuSnappedTo = false; 316 mDismissing = true; 317 mSwipeHelper.dismiss(animView, velocity); 318 } 319 320 private boolean swipedEnoughToShowMenu() { 321 // If the notification can't be dismissed then how far it can move is 322 // restricted -- reduce the distance it needs to move in this case. 323 final float multiplier = mParent.canViewBeDismissed() ? 0.4f : 0.2f; 324 final float snapBackThreshold = getSpaceForMenu() * multiplier; 325 return !mSwipeHelper.swipedFarEnough(0, 0) && isMenuVisible() && (mOnLeft 326 ? mTranslation > snapBackThreshold 327 : mTranslation < -snapBackThreshold); 328 } 329 330 /** 331 * Returns whether the gesture is towards the menu location or not. 332 */ 333 private boolean isTowardsMenu(float movement) { 334 return isMenuVisible() 335 && ((mOnLeft && movement <= 0) 336 || (!mOnLeft && movement >= 0)); 337 } 338 339 @Override 340 public void setAppName(String appName) { 341 if (appName == null) { 342 return; 343 } 344 Resources res = mContext.getResources(); 345 final int count = mMenuItems.size(); 346 for (int i = 0; i < count; i++) { 347 MenuItem item = mMenuItems.get(i); 348 String description = String.format( 349 res.getString(R.string.notification_menu_accessibility), 350 appName, item.getContentDescription()); 351 View menuView = item.getMenuView(); 352 if (menuView != null) { 353 menuView.setContentDescription(description); 354 } 355 } 356 } 357 358 @Override 359 public void onHeightUpdate() { 360 if (mParent == null || mMenuItems.size() == 0) { 361 return; 362 } 363 int parentHeight = mParent.getCollapsedHeight(); 364 float translationY; 365 if (parentHeight < mVertSpaceForIcons) { 366 translationY = (parentHeight / 2) - (mHorizSpaceForIcon / 2); 367 } else { 368 translationY = (mVertSpaceForIcons - mHorizSpaceForIcon) / 2; 369 } 370 mMenuContainer.setTranslationY(translationY); 371 } 372 373 @Override 374 public void onTranslationUpdate(float translation) { 375 mTranslation = translation; 376 if (mAnimating || !mMenuFadedIn) { 377 // Don't adjust when animating, or if the menu hasn't been shown yet. 378 return; 379 } 380 final float fadeThreshold = mParent.getWidth() * 0.3f; 381 final float absTrans = Math.abs(translation); 382 float desiredAlpha = 0; 383 if (absTrans == 0) { 384 desiredAlpha = 0; 385 } else if (absTrans <= fadeThreshold) { 386 desiredAlpha = 1; 387 } else { 388 desiredAlpha = 1 - ((absTrans - fadeThreshold) / (mParent.getWidth() - fadeThreshold)); 389 } 390 setMenuAlpha(desiredAlpha); 391 } 392 393 @Override 394 public void onClick(View v) { 395 if (mMenuListener == null) { 396 // Nothing to do 397 return; 398 } 399 v.getLocationOnScreen(mIconLocation); 400 mParent.getLocationOnScreen(mParentLocation); 401 final int centerX = (int) (mHorizSpaceForIcon / 2); 402 final int centerY = v.getHeight() / 2; 403 final int x = mIconLocation[0] - mParentLocation[0] + centerX; 404 final int y = mIconLocation[1] - mParentLocation[1] + centerY; 405 final int index = mMenuContainer.indexOfChild(v); 406 mMenuListener.onMenuClicked(mParent, x, y, mMenuItems.get(index)); 407 } 408 409 private boolean isMenuLocationChange() { 410 boolean onLeft = mTranslation > mIconPadding; 411 boolean onRight = mTranslation < -mIconPadding; 412 if ((mOnLeft && onRight) || (!mOnLeft && onLeft)) { 413 return true; 414 } 415 return false; 416 } 417 418 private void setMenuLocation() { 419 boolean showOnLeft = mTranslation > 0; 420 if ((mIconsPlaced && showOnLeft == mOnLeft) || mSnapping || mParent == null) { 421 // Do nothing 422 return; 423 } 424 final boolean isRtl = mParent.isLayoutRtl(); 425 final int count = mMenuContainer.getChildCount(); 426 final int width = mParent.getWidth(); 427 for (int i = 0; i < count; i++) { 428 final View v = mMenuContainer.getChildAt(i); 429 final float left = isRtl 430 ? -(width - mHorizSpaceForIcon * (i + 1)) 431 : i * mHorizSpaceForIcon; 432 final float right = isRtl 433 ? -i * mHorizSpaceForIcon 434 : width - (mHorizSpaceForIcon * (i + 1)); 435 v.setTranslationX(showOnLeft ? left : right); 436 } 437 mOnLeft = showOnLeft; 438 mIconsPlaced = true; 439 } 440 441 private void setMenuAlpha(float alpha) { 442 mAlpha = alpha; 443 if (mMenuContainer == null) { 444 return; 445 } 446 if (alpha == 0) { 447 mMenuFadedIn = false; // Can fade in again once it's gone. 448 mMenuContainer.setVisibility(View.INVISIBLE); 449 } else { 450 mMenuContainer.setVisibility(View.VISIBLE); 451 } 452 final int count = mMenuContainer.getChildCount(); 453 for (int i = 0; i < count; i++) { 454 mMenuContainer.getChildAt(i).setAlpha(mAlpha); 455 } 456 } 457 458 /** 459 * Returns the horizontal space in pixels required to display the menu. 460 */ 461 private float getSpaceForMenu() { 462 return mHorizSpaceForIcon * mMenuContainer.getChildCount(); 463 } 464 465 private final class CheckForDrag implements Runnable { 466 @Override 467 public void run() { 468 final float absTransX = Math.abs(mTranslation); 469 final float bounceBackToMenuWidth = getSpaceForMenu(); 470 final float notiThreshold = mParent.getWidth() * 0.4f; 471 if ((!isMenuVisible() || isMenuLocationChange()) 472 && absTransX >= bounceBackToMenuWidth * 0.4 473 && absTransX < notiThreshold) { 474 fadeInMenu(notiThreshold); 475 } 476 } 477 } 478 479 private void fadeInMenu(final float notiThreshold) { 480 if (mDismissing || mAnimating) { 481 return; 482 } 483 if (isMenuLocationChange()) { 484 setMenuAlpha(0f); 485 } 486 final float transX = mTranslation; 487 final boolean fromLeft = mTranslation > 0; 488 setMenuLocation(); 489 mFadeAnimator = ValueAnimator.ofFloat(mAlpha, 1); 490 mFadeAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 491 @Override 492 public void onAnimationUpdate(ValueAnimator animation) { 493 final float absTrans = Math.abs(transX); 494 495 boolean pastMenu = (fromLeft && transX <= notiThreshold) 496 || (!fromLeft && absTrans <= notiThreshold); 497 if (pastMenu && !mMenuFadedIn) { 498 setMenuAlpha((float) animation.getAnimatedValue()); 499 } 500 } 501 }); 502 mFadeAnimator.addListener(new AnimatorListenerAdapter() { 503 @Override 504 public void onAnimationStart(Animator animation) { 505 mAnimating = true; 506 } 507 508 @Override 509 public void onAnimationCancel(Animator animation) { 510 // TODO should animate back to 0f from current alpha 511 setMenuAlpha(0f); 512 } 513 514 @Override 515 public void onAnimationEnd(Animator animation) { 516 mAnimating = false; 517 mMenuFadedIn = mAlpha == 1; 518 } 519 }); 520 mFadeAnimator.setInterpolator(Interpolators.ALPHA_IN); 521 mFadeAnimator.setDuration(ICON_ALPHA_ANIM_DURATION); 522 mFadeAnimator.start(); 523 } 524 525 @Override 526 public void setMenuItems(ArrayList<MenuItem> items) { 527 // Do nothing we use our own for now. 528 // TODO -- handle / allow custom menu items! 529 } 530 531 public static MenuItem createSnoozeItem(Context context) { 532 Resources res = context.getResources(); 533 NotificationSnooze content = (NotificationSnooze) LayoutInflater.from(context) 534 .inflate(R.layout.notification_snooze, null, false); 535 String snoozeDescription = res.getString(R.string.notification_menu_snooze_description); 536 MenuItem snooze = new NotificationMenuItem(context, snoozeDescription, content, 537 R.drawable.ic_snooze); 538 return snooze; 539 } 540 541 public static MenuItem createInfoItem(Context context) { 542 Resources res = context.getResources(); 543 String infoDescription = res.getString(R.string.notification_menu_gear_description); 544 NotificationInfo infoContent = (NotificationInfo) LayoutInflater.from(context).inflate( 545 R.layout.notification_info, null, false); 546 MenuItem info = new NotificationMenuItem(context, infoDescription, infoContent, 547 R.drawable.ic_settings); 548 return info; 549 } 550 551 private void addMenuView(MenuItem item, ViewGroup parent) { 552 View menuView = item.getMenuView(); 553 if (menuView != null) { 554 parent.addView(menuView); 555 menuView.setOnClickListener(this); 556 FrameLayout.LayoutParams lp = (LayoutParams) menuView.getLayoutParams(); 557 lp.width = (int) mHorizSpaceForIcon; 558 lp.height = (int) mHorizSpaceForIcon; 559 menuView.setLayoutParams(lp); 560 } 561 } 562 563 public static class NotificationMenuItem implements MenuItem { 564 View mMenuView; 565 GutsContent mGutsContent; 566 String mContentDescription; 567 568 public NotificationMenuItem(Context context, String s, GutsContent content, int iconResId) { 569 Resources res = context.getResources(); 570 int padding = res.getDimensionPixelSize(R.dimen.notification_menu_icon_padding); 571 int tint = res.getColor(R.color.notification_gear_color); 572 AlphaOptimizedImageView iv = new AlphaOptimizedImageView(context); 573 iv.setPadding(padding, padding, padding, padding); 574 Drawable icon = context.getResources().getDrawable(iconResId); 575 iv.setImageDrawable(icon); 576 iv.setColorFilter(tint); 577 iv.setAlpha(1f); 578 mMenuView = iv; 579 mContentDescription = s; 580 mGutsContent = content; 581 } 582 583 @Override 584 public View getMenuView() { 585 return mMenuView; 586 } 587 588 @Override 589 public View getGutsView() { 590 return mGutsContent.getContentView(); 591 } 592 593 @Override 594 public String getContentDescription() { 595 return mContentDescription; 596 } 597 } 598} 599