1 package com.android.systemui.qs; 2 3 import static com.android.internal.jank.InteractionJankMonitor.CUJ_NOTIFICATION_SHADE_QS_SCROLL_SWIPE; 4 import static com.android.systemui.qs.PageIndicator.PageScrollActionListener.LEFT; 5 import static com.android.systemui.qs.PageIndicator.PageScrollActionListener.RIGHT; 6 7 import android.animation.Animator; 8 import android.animation.AnimatorListenerAdapter; 9 import android.animation.AnimatorSet; 10 import android.animation.ObjectAnimator; 11 import android.animation.PropertyValuesHolder; 12 import android.app.ActivityManager; 13 import android.content.Context; 14 import android.content.res.Configuration; 15 import android.os.Bundle; 16 import android.util.AttributeSet; 17 import android.view.InputDevice; 18 import android.view.KeyEvent; 19 import android.view.LayoutInflater; 20 import android.view.MotionEvent; 21 import android.view.View; 22 import android.view.ViewGroup; 23 import android.view.accessibility.AccessibilityEvent; 24 import android.view.accessibility.AccessibilityNodeInfo; 25 import android.view.animation.Interpolator; 26 import android.view.animation.OvershootInterpolator; 27 import android.widget.Scroller; 28 29 import androidx.annotation.Nullable; 30 import androidx.annotation.VisibleForTesting; 31 import androidx.viewpager.widget.PagerAdapter; 32 import androidx.viewpager.widget.ViewPager; 33 34 import com.android.internal.jank.InteractionJankMonitor; 35 import com.android.internal.logging.UiEventLogger; 36 import com.android.systemui.plugins.qs.QSTile; 37 import com.android.systemui.qs.PageIndicator.PageScrollActionListener.Direction; 38 import com.android.systemui.qs.QSPanel.QSTileLayout; 39 import com.android.systemui.qs.QSPanelControllerBase.TileRecord; 40 import com.android.systemui.qs.logging.QSLogger; 41 import com.android.systemui.res.R; 42 43 import java.util.ArrayList; 44 import java.util.List; 45 import java.util.Set; 46 47 public class PagedTileLayout extends ViewPager implements QSTileLayout { 48 49 private static final String CURRENT_PAGE = "current_page"; 50 private static final int NO_PAGE = -1; 51 52 private static final int REVEAL_SCROLL_DURATION_MILLIS = 750; 53 private static final int SINGLE_PAGE_SCROLL_DURATION_MILLIS = 300; 54 private static final float BOUNCE_ANIMATION_TENSION = 1.3f; 55 private static final long BOUNCE_ANIMATION_DURATION = 450L; 56 private static final int TILE_ANIMATION_STAGGER_DELAY = 85; 57 private static final Interpolator SCROLL_CUBIC = (t) -> { 58 t -= 1.0f; 59 return t * t * t + 1.0f; 60 }; 61 62 private final ArrayList<TileRecord> mTiles = new ArrayList<>(); 63 private final ArrayList<TileLayout> mPages = new ArrayList<>(); 64 65 private QSLogger mLogger; 66 @Nullable 67 private PageIndicator mPageIndicator; 68 private float mPageIndicatorPosition; 69 70 @Nullable 71 private PageListener mPageListener; 72 73 private boolean mListening; 74 @VisibleForTesting Scroller mScroller; 75 76 /* set of animations used to indicate which tiles were just revealed */ 77 @Nullable 78 private AnimatorSet mBounceAnimatorSet; 79 private float mLastExpansion; 80 private boolean mDistributeTiles = false; 81 private int mPageToRestore = -1; 82 private int mLayoutOrientation; 83 private int mLayoutDirection; 84 private final UiEventLogger mUiEventLogger = QSEvents.INSTANCE.getQsUiEventsLogger(); 85 private int mExcessHeight; 86 private int mLastExcessHeight; 87 private int mMinRows = 1; 88 private int mMaxColumns = TileLayout.NO_MAX_COLUMNS; 89 90 /** 91 * it's fine to read this value when class is initialized because SysUI is always restarted 92 * when running tests in test harness, see SysUiTestIsolationRule. This check is done quite 93 * often - with every shade open action - so we don't want to potentially make it less 94 * performant only for test use case 95 */ 96 private boolean mRunningInTestHarness = ActivityManager.isRunningInTestHarness(); 97 PagedTileLayout(Context context, AttributeSet attrs)98 public PagedTileLayout(Context context, AttributeSet attrs) { 99 super(context, attrs); 100 mScroller = new Scroller(context, SCROLL_CUBIC); 101 setAdapter(mAdapter); 102 setOnPageChangeListener(mOnPageChangeListener); 103 setCurrentItem(0, false); 104 mLayoutOrientation = getResources().getConfiguration().orientation; 105 mLayoutDirection = getLayoutDirection(); 106 } 107 private int mLastMaxHeight = -1; 108 setPageMargin(int marginPixelsStart, int marginPixelsEnd)109 public void setPageMargin(int marginPixelsStart, int marginPixelsEnd) { 110 // Using page margins creates some rounding issues that interfere with the correct position 111 // in the onPageChangedListener and therefore present bad positions to the PageIndicator. 112 // Instead, we use negative margins in the container and positive padding in the pages, 113 // matching the margin set from QSContainerImpl (note that new pages will always be inflated 114 // with the correct value. 115 // QSContainerImpl resources are set onAttachedView, so this view will always have the right 116 // values when attached. 117 MarginLayoutParams lp = (MarginLayoutParams) getLayoutParams(); 118 lp.setMarginStart(-marginPixelsStart); 119 lp.setMarginEnd(-marginPixelsEnd); 120 setLayoutParams(lp); 121 122 int nPages = mPages.size(); 123 for (int i = 0; i < nPages; i++) { 124 View v = mPages.get(i); 125 v.setPadding( 126 marginPixelsStart, 127 v.getPaddingTop(), 128 marginPixelsEnd, 129 v.getPaddingBottom() 130 ); 131 } 132 } 133 saveInstanceState(Bundle outState)134 public void saveInstanceState(Bundle outState) { 135 int resolvedPage = mPageToRestore != NO_PAGE ? mPageToRestore : getCurrentPageNumber(); 136 outState.putInt(CURRENT_PAGE, resolvedPage); 137 } 138 restoreInstanceState(Bundle savedInstanceState)139 public void restoreInstanceState(Bundle savedInstanceState) { 140 // There's only 1 page at this point. We want to restore the correct page once the 141 // pages have been inflated 142 mPageToRestore = savedInstanceState.getInt(CURRENT_PAGE, NO_PAGE); 143 } 144 145 @Override getTilesHeight()146 public int getTilesHeight() { 147 // Use the first page as that is the maximum height we need to show. 148 TileLayout tileLayout = mPages.get(0); 149 if (tileLayout == null) { 150 return 0; 151 } 152 return tileLayout.getTilesHeight(); 153 } 154 155 @Override onConfigurationChanged(Configuration newConfig)156 protected void onConfigurationChanged(Configuration newConfig) { 157 super.onConfigurationChanged(newConfig); 158 // Pass configuration change to non-attached pages as well. Some config changes will cause 159 // QS to recreate itself (as determined in FragmentHostManager), but in order to minimize 160 // those, make sure that all get passed to all pages. 161 int numPages = mPages.size(); 162 for (int i = 0; i < numPages; i++) { 163 View page = mPages.get(i); 164 if (page.getParent() == null) { 165 page.dispatchConfigurationChanged(newConfig); 166 } 167 } 168 if (mLayoutOrientation != newConfig.orientation) { 169 mLayoutOrientation = newConfig.orientation; 170 forceTilesRedistribution("orientation changed to " + mLayoutOrientation); 171 setCurrentItem(0, false); 172 mPageToRestore = 0; 173 } else { 174 // logging in case we missed redistribution because orientation was not changed 175 // while configuration changed, can be removed after b/255208946 is fixed 176 mLogger.d( 177 "Orientation didn't change, tiles might be not redistributed, new config", 178 newConfig); 179 } 180 } 181 182 @Override onRtlPropertiesChanged(int layoutDirection)183 public void onRtlPropertiesChanged(int layoutDirection) { 184 // The configuration change will change the flag in the view (that's returned in 185 // isLayoutRtl). As we detect the change, we use the cached direction to store the page 186 // before setting it. 187 final int page = getPageNumberForDirection(mLayoutDirection == LAYOUT_DIRECTION_RTL); 188 super.onRtlPropertiesChanged(layoutDirection); 189 if (mLayoutDirection != layoutDirection) { 190 mLayoutDirection = layoutDirection; 191 setAdapter(mAdapter); 192 setCurrentItem(page, false); 193 } 194 } 195 196 @Override onGenericMotionEvent(MotionEvent event)197 public boolean onGenericMotionEvent(MotionEvent event) { 198 if ((event.getSource() & InputDevice.SOURCE_CLASS_POINTER) != 0 199 && event.getAction() == MotionEvent.ACTION_SCROLL) { 200 // Handle mouse (or ext. device) by swiping the page depending on the scroll 201 final float vscroll; 202 final float hscroll; 203 if ((event.getMetaState() & KeyEvent.META_SHIFT_ON) != 0) { 204 vscroll = 0; 205 hscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL); 206 } else { 207 vscroll = -event.getAxisValue(MotionEvent.AXIS_VSCROLL); 208 hscroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL); 209 } 210 if (hscroll != 0 || vscroll != 0) { 211 boolean isForwardScroll = 212 isLayoutRtl() ? (hscroll < 0 || vscroll < 0) : (hscroll > 0 || vscroll > 0); 213 int swipeDirection = isForwardScroll ? RIGHT : LEFT; 214 if (mScroller.isFinished()) { 215 scrollByX(getDeltaXForPageScrolling(swipeDirection), 216 SINGLE_PAGE_SCROLL_DURATION_MILLIS); 217 } 218 return true; 219 } 220 } 221 return super.onGenericMotionEvent(event); 222 } 223 224 @Override setCurrentItem(int item, boolean smoothScroll)225 public void setCurrentItem(int item, boolean smoothScroll) { 226 if (isLayoutRtl()) { 227 item = mPages.size() - 1 - item; 228 } 229 super.setCurrentItem(item, smoothScroll); 230 } 231 232 /** 233 * Obtains the current page number respecting RTL 234 */ getCurrentPageNumber()235 private int getCurrentPageNumber() { 236 return getPageNumberForDirection(isLayoutRtl()); 237 } 238 getPageNumberForDirection(boolean isLayoutRTL)239 private int getPageNumberForDirection(boolean isLayoutRTL) { 240 int page = getCurrentItem(); 241 if (isLayoutRTL) { 242 page = mPages.size() - 1 - page; 243 } 244 return page; 245 } 246 247 // This will dump to the ui log all the tiles that are visible in this page logVisibleTiles(TileLayout page)248 private void logVisibleTiles(TileLayout page) { 249 for (int i = 0; i < page.mRecords.size(); i++) { 250 QSTile t = page.mRecords.get(i).tile; 251 mUiEventLogger.logWithInstanceId(QSEvent.QS_TILE_VISIBLE, 0, t.getMetricsSpec(), 252 t.getInstanceId()); 253 } 254 } 255 256 @Override setListening(boolean listening, UiEventLogger uiEventLogger)257 public void setListening(boolean listening, UiEventLogger uiEventLogger) { 258 if (mListening == listening) return; 259 mListening = listening; 260 updateListening(); 261 } 262 263 @Override setSquishinessFraction(float squishinessFraction)264 public void setSquishinessFraction(float squishinessFraction) { 265 int nPages = mPages.size(); 266 for (int i = 0; i < nPages; i++) { 267 mPages.get(i).setSquishinessFraction(squishinessFraction); 268 } 269 } 270 updateListening()271 private void updateListening() { 272 for (TileLayout tilePage : mPages) { 273 tilePage.setListening(tilePage.getParent() != null && mListening); 274 } 275 } 276 277 @Override fakeDragBy(float xOffset)278 public void fakeDragBy(float xOffset) { 279 try { 280 super.fakeDragBy(xOffset); 281 // Keep on drawing until the animation has finished. 282 postInvalidateOnAnimation(); 283 } catch (NullPointerException e) { 284 mLogger.logException("FakeDragBy called before begin", e); 285 // If we were trying to fake drag, it means we just added a new tile to the last 286 // page, so animate there. 287 final int lastPageNumber = mPages.size() - 1; 288 post(() -> { 289 setCurrentItem(lastPageNumber, true); 290 if (mBounceAnimatorSet != null) { 291 mBounceAnimatorSet.start(); 292 } 293 setOffscreenPageLimit(1); 294 }); 295 } 296 } 297 298 @Override endFakeDrag()299 public void endFakeDrag() { 300 try { 301 super.endFakeDrag(); 302 } catch (NullPointerException e) { 303 // Not sure what's going on. Let's log it 304 mLogger.logException("endFakeDrag called without velocityTracker", e); 305 } 306 } 307 308 @Override computeScroll()309 public void computeScroll() { 310 if (!mScroller.isFinished() && mScroller.computeScrollOffset()) { 311 if (!isFakeDragging()) { 312 beginFakeDrag(); 313 } 314 fakeDragBy(getScrollX() - mScroller.getCurrX()); 315 } else if (isFakeDragging()) { 316 endFakeDrag(); 317 if (mBounceAnimatorSet != null) { 318 mBounceAnimatorSet.start(); 319 } 320 setOffscreenPageLimit(1); 321 } 322 super.computeScroll(); 323 } 324 325 @Override hasOverlappingRendering()326 public boolean hasOverlappingRendering() { 327 return false; 328 } 329 330 @Override onFinishInflate()331 protected void onFinishInflate() { 332 super.onFinishInflate(); 333 mPages.add(createTileLayout()); 334 mAdapter.notifyDataSetChanged(); 335 } 336 createTileLayout()337 private TileLayout createTileLayout() { 338 TileLayout page = (TileLayout) LayoutInflater.from(getContext()) 339 .inflate(R.layout.qs_paged_page, this, false); 340 page.setMinRows(mMinRows); 341 page.setMaxColumns(mMaxColumns); 342 page.setSelected(false); 343 344 // All pages should have the same squishiness, so grabbing the value from the first page 345 // and giving it to new pages. 346 float squishiness = mPages.isEmpty() ? 1f : mPages.get(0).getSquishinessFraction(); 347 page.setSquishinessFraction(squishiness); 348 349 return page; 350 } 351 setPageIndicator(PageIndicator indicator)352 public void setPageIndicator(PageIndicator indicator) { 353 mPageIndicator = indicator; 354 mPageIndicator.setNumPages(mPages.size()); 355 mPageIndicator.setLocation(mPageIndicatorPosition); 356 mPageIndicator.setPageScrollActionListener(swipeDirection -> { 357 if (mScroller.isFinished()) { 358 scrollByX(getDeltaXForPageScrolling(swipeDirection), 359 SINGLE_PAGE_SCROLL_DURATION_MILLIS); 360 } 361 }); 362 } 363 getDeltaXForPageScrolling(@irection int swipeDirection)364 private int getDeltaXForPageScrolling(@Direction int swipeDirection) { 365 if (swipeDirection == LEFT && getCurrentItem() != 0) { 366 return -getWidth(); 367 } else if (swipeDirection == RIGHT && getCurrentItem() != mPages.size() - 1) { 368 return getWidth(); 369 } 370 return 0; 371 } 372 scrollByX(int x, int durationMillis)373 private void scrollByX(int x, int durationMillis) { 374 if (x != 0) { 375 mScroller.startScroll(/* startX= */ getScrollX(), /* startY= */ getScrollY(), 376 /* dx= */ x, /* dy= */ 0, /* duration= */ durationMillis); 377 // scroller just sets its state, we need to invalidate view to actually start scrolling 378 postInvalidateOnAnimation(); 379 } 380 } 381 382 @Override getOffsetTop(TileRecord tile)383 public int getOffsetTop(TileRecord tile) { 384 final ViewGroup parent = (ViewGroup) tile.tileView.getParent(); 385 if (parent == null) return 0; 386 return parent.getTop() + getTop(); 387 } 388 389 @Override addTile(TileRecord tile)390 public void addTile(TileRecord tile) { 391 mTiles.add(tile); 392 forceTilesRedistribution("adding new tile"); 393 requestLayout(); 394 } 395 396 @Override removeTile(TileRecord tile)397 public void removeTile(TileRecord tile) { 398 if (mTiles.remove(tile)) { 399 forceTilesRedistribution("removing tile"); 400 requestLayout(); 401 } 402 } 403 404 @Override setExpansion(float expansion, float proposedTranslation)405 public void setExpansion(float expansion, float proposedTranslation) { 406 mLastExpansion = expansion; 407 updateSelected(); 408 } 409 updateSelected()410 private void updateSelected() { 411 // Start the marquee when fully expanded and stop when fully collapsed. Leave as is for 412 // other expansion ratios since there is no way way to pause the marquee. 413 if (mLastExpansion > 0f && mLastExpansion < 1f) { 414 return; 415 } 416 boolean selected = mLastExpansion == 1f; 417 418 // Disable accessibility temporarily while we update selected state purely for the 419 // marquee. This will ensure that accessibility doesn't announce the TYPE_VIEW_SELECTED 420 // event on any of the children. 421 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 422 int currentItem = getCurrentPageNumber(); 423 for (int i = 0; i < mPages.size(); i++) { 424 TileLayout page = mPages.get(i); 425 page.setSelected(i == currentItem ? selected : false); 426 if (page.isSelected()) { 427 logVisibleTiles(page); 428 } 429 } 430 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 431 } 432 setPageListener(PageListener listener)433 public void setPageListener(PageListener listener) { 434 mPageListener = listener; 435 } 436 getSpecsForPage(int page)437 public List<String> getSpecsForPage(int page) { 438 ArrayList<String> out = new ArrayList<>(); 439 if (page < 0) return out; 440 int perPage = mPages.get(0).maxTiles(); 441 int startOfPage = page * perPage; 442 int endOfPage = (page + 1) * perPage; 443 for (int i = startOfPage; i < endOfPage && i < mTiles.size(); i++) { 444 out.add(mTiles.get(i).tile.getTileSpec()); 445 } 446 return out; 447 } 448 distributeTiles()449 private void distributeTiles() { 450 emptyAndInflateOrRemovePages(); 451 452 final int tilesPerPageCount = mPages.get(0).maxTiles(); 453 int index = 0; 454 final int totalTilesCount = mTiles.size(); 455 mLogger.logTileDistributionInProgress(tilesPerPageCount, totalTilesCount); 456 for (int i = 0; i < totalTilesCount; i++) { 457 TileRecord tile = mTiles.get(i); 458 if (mPages.get(index).mRecords.size() == tilesPerPageCount) index++; 459 mLogger.logTileDistributed(tile.tile.getClass().getSimpleName(), index); 460 mPages.get(index).addTile(tile); 461 } 462 } 463 emptyAndInflateOrRemovePages()464 private void emptyAndInflateOrRemovePages() { 465 final int numPages = getNumPages(); 466 final int NP = mPages.size(); 467 for (int i = 0; i < NP; i++) { 468 mPages.get(i).removeAllViews(); 469 } 470 if (mPageIndicator != null) { 471 mPageIndicator.setNumPages(numPages); 472 } 473 if (NP == numPages) { 474 return; 475 } 476 while (mPages.size() < numPages) { 477 mLogger.d("Adding new page"); 478 mPages.add(createTileLayout()); 479 } 480 while (mPages.size() > numPages) { 481 mLogger.d("Removing page"); 482 mPages.remove(mPages.size() - 1); 483 } 484 setAdapter(mAdapter); 485 mAdapter.notifyDataSetChanged(); 486 if (mPageToRestore != NO_PAGE) { 487 setCurrentItem(mPageToRestore, false); 488 mPageToRestore = NO_PAGE; 489 } 490 } 491 492 @Override updateResources()493 public boolean updateResources() { 494 boolean changed = false; 495 for (int i = 0; i < mPages.size(); i++) { 496 changed |= mPages.get(i).updateResources(); 497 } 498 if (changed) { 499 forceTilesRedistribution("resources in pages changed"); 500 requestLayout(); 501 } else { 502 // logging in case we missed redistribution because number of column in updateResources 503 // was not changed, can be removed after b/255208946 is fixed 504 mLogger.d("resource in pages didn't change, tiles might be not redistributed"); 505 } 506 return changed; 507 } 508 509 @Override setMinRows(int minRows)510 public boolean setMinRows(int minRows) { 511 mMinRows = minRows; 512 boolean changed = false; 513 for (int i = 0; i < mPages.size(); i++) { 514 if (mPages.get(i).setMinRows(minRows)) { 515 changed = true; 516 forceTilesRedistribution("minRows changed in page"); 517 } 518 } 519 return changed; 520 } 521 522 @Override getMinRows()523 public int getMinRows() { 524 return mMinRows; 525 } 526 527 @Override setMaxColumns(int maxColumns)528 public boolean setMaxColumns(int maxColumns) { 529 mMaxColumns = maxColumns; 530 boolean changed = false; 531 for (int i = 0; i < mPages.size(); i++) { 532 if (mPages.get(i).setMaxColumns(maxColumns)) { 533 changed = true; 534 forceTilesRedistribution("maxColumns in pages changed"); 535 } 536 } 537 return changed; 538 } 539 540 @Override getMaxColumns()541 public int getMaxColumns() { 542 return mMaxColumns; 543 } 544 545 /** 546 * Set the amount of excess space that we gave this view compared to the actual available 547 * height. This is because this view is in a scrollview. 548 */ setExcessHeight(int excessHeight)549 public void setExcessHeight(int excessHeight) { 550 mExcessHeight = excessHeight; 551 } 552 553 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)554 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 555 556 final int nTiles = mTiles.size(); 557 // If we have no reason to recalculate the number of rows, skip this step. In particular, 558 // if the height passed by its parent is the same as the last time, we try not to remeasure. 559 if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec) 560 || mLastExcessHeight != mExcessHeight) { 561 562 mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec); 563 mLastExcessHeight = mExcessHeight; 564 // Only change the pages if the number of rows or columns (from updateResources) has 565 // changed or the tiles have changed 566 int availableHeight = mLastMaxHeight - mExcessHeight; 567 if (mPages.get(0).updateMaxRows(availableHeight, nTiles) || mDistributeTiles) { 568 mDistributeTiles = false; 569 distributeTiles(); 570 } 571 572 final int nRows = mPages.get(0).mRows; 573 for (int i = 0; i < mPages.size(); i++) { 574 TileLayout t = mPages.get(i); 575 t.mRows = nRows; 576 } 577 } 578 579 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 580 581 // The ViewPager likes to eat all of the space, instead force it to wrap to the max height 582 // of the pages. 583 int maxHeight = 0; 584 final int N = getChildCount(); 585 for (int i = 0; i < N; i++) { 586 int height = getChildAt(i).getMeasuredHeight(); 587 if (height > maxHeight) { 588 maxHeight = height; 589 } 590 } 591 if (mPages.get(0).getParent() == null) { 592 // Measure page 0 so we know how tall it is if it's not attached to the pager. 593 mPages.get(0).measure(widthMeasureSpec, heightMeasureSpec); 594 int height = mPages.get(0).getMeasuredHeight(); 595 if (height > maxHeight) { 596 maxHeight = height; 597 } 598 } 599 setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom()); 600 } 601 602 @Override onLayout(boolean changed, int l, int t, int r, int b)603 protected void onLayout(boolean changed, int l, int t, int r, int b) { 604 super.onLayout(changed, l, t, r, b); 605 if (mPages.get(0).getParent() == null) { 606 // Layout page 0, so we can get the bottom of the tiles. We only do this if the page 607 // is not attached. 608 mPages.get(0).layout(l, t, r, b); 609 } 610 } 611 getColumnCount()612 public int getColumnCount() { 613 if (mPages.size() == 0) return 0; 614 return mPages.get(0).mColumns; 615 } 616 617 /** 618 * Gets the number of pages in this paged tile layout 619 */ getNumPages()620 public int getNumPages() { 621 final int nTiles = mTiles.size(); 622 // We should always have at least one page, even if it's empty. 623 int numPages = Math.max(nTiles / mPages.get(0).maxTiles(), 1); 624 625 // Add one more not full page if needed 626 if (nTiles > numPages * mPages.get(0).maxTiles()) { 627 numPages++; 628 } 629 630 return numPages; 631 } 632 getNumVisibleTiles()633 public int getNumVisibleTiles() { 634 if (mPages.size() == 0) return 0; 635 TileLayout currentPage = mPages.get(getCurrentPageNumber()); 636 return currentPage.mRecords.size(); 637 } 638 getNumTilesFirstPage()639 public int getNumTilesFirstPage() { 640 if (mPages.size() == 0) return 0; 641 return mPages.get(0).mRecords.size(); 642 } 643 startTileReveal(Set<String> tilesToReveal, final Runnable postAnimation)644 public void startTileReveal(Set<String> tilesToReveal, final Runnable postAnimation) { 645 if (shouldNotRunAnimation(tilesToReveal)) { 646 return; 647 } 648 // This method has side effects (beings the fake drag, if it returns true). If we have 649 // decided that we want to do a tile reveal, we do a last check to verify that we can 650 // actually perform a fake drag. 651 if (!beginFakeDrag()) { 652 return; 653 } 654 655 final int lastPageNumber = mPages.size() - 1; 656 final TileLayout lastPage = mPages.get(lastPageNumber); 657 final ArrayList<Animator> bounceAnims = new ArrayList<>(); 658 for (TileRecord tr : lastPage.mRecords) { 659 if (tilesToReveal.contains(tr.tile.getTileSpec())) { 660 bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size())); 661 } 662 } 663 664 if (bounceAnims.isEmpty()) { 665 // All tilesToReveal are on the first page. Nothing to do. 666 // TODO: potentially show a bounce animation for first page QS tiles 667 endFakeDrag(); 668 return; 669 } 670 671 mBounceAnimatorSet = new AnimatorSet(); 672 mBounceAnimatorSet.playTogether(bounceAnims); 673 mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() { 674 @Override 675 public void onAnimationEnd(Animator animation) { 676 mBounceAnimatorSet = null; 677 postAnimation.run(); 678 } 679 }); 680 setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated. 681 int dx = getWidth() * lastPageNumber; 682 scrollByX(isLayoutRtl() ? -dx : dx, REVEAL_SCROLL_DURATION_MILLIS); 683 } 684 shouldNotRunAnimation(Set<String> tilesToReveal)685 private boolean shouldNotRunAnimation(Set<String> tilesToReveal) { 686 // None of these have side effects. That way, we don't need to rely on short-circuiting 687 // behavior 688 boolean noAnimationNeeded = tilesToReveal.isEmpty() || mPages.size() < 2; 689 boolean scrollingInProgress = getScrollX() != 0 || !isFakeDragging(); 690 // checking mRunningInTestHarness to disable animation in functional testing as it caused 691 // flakiness and is not needed there. Alternative solutions were more complex and would 692 // still be either potentially flaky or modify internal data. 693 // For more info see b/253493927 and b/293234595 694 return noAnimationNeeded || scrollingInProgress || mRunningInTestHarness; 695 } 696 697 private int sanitizePageAction(int action) { 698 int pageLeftId = AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_LEFT.getId(); 699 int pageRightId = AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_RIGHT.getId(); 700 if (action == pageLeftId || action == pageRightId) { 701 if (!isLayoutRtl()) { 702 if (action == pageLeftId) { 703 return AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD; 704 } else { 705 return AccessibilityNodeInfo.ACTION_SCROLL_FORWARD; 706 } 707 } else { 708 if (action == pageLeftId) { 709 return AccessibilityNodeInfo.ACTION_SCROLL_FORWARD; 710 } else { 711 return AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD; 712 } 713 } 714 } 715 return action; 716 } 717 718 @Override 719 public boolean performAccessibilityAction(int action, Bundle arguments) { 720 action = sanitizePageAction(action); 721 boolean performed = super.performAccessibilityAction(action, arguments); 722 if (performed && (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD 723 || action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)) { 724 requestAccessibilityFocus(); 725 } 726 return performed; 727 } 728 729 @Override 730 public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { 731 super.onInitializeAccessibilityNodeInfoInternal(info); 732 // getCurrentItem does not respect RTL, so it works well together with page actions that 733 // use left/right positioning. 734 if (getCurrentItem() != 0) { 735 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_LEFT); 736 } 737 if (getCurrentItem() != mPages.size() - 1) { 738 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_RIGHT); 739 } 740 } 741 742 @Override 743 public void onInitializeAccessibilityEvent(AccessibilityEvent event) { 744 super.onInitializeAccessibilityEvent(event); 745 if (mAdapter != null && mAdapter.getCount() > 0) { 746 event.setItemCount(mAdapter.getCount()); 747 event.setFromIndex(getCurrentPageNumber()); 748 event.setToIndex(getCurrentPageNumber()); 749 } 750 } 751 752 private static Animator setupBounceAnimator(View view, int ordinal) { 753 view.setAlpha(0f); 754 view.setScaleX(0f); 755 view.setScaleY(0f); 756 ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view, 757 PropertyValuesHolder.ofFloat(View.ALPHA, 1), 758 PropertyValuesHolder.ofFloat(View.SCALE_X, 1), 759 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1)); 760 animator.setDuration(BOUNCE_ANIMATION_DURATION); 761 animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY); 762 animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION)); 763 return animator; 764 } 765 766 private final ViewPager.OnPageChangeListener mOnPageChangeListener = 767 new ViewPager.SimpleOnPageChangeListener() { 768 769 private int mCurrentScrollState = SCROLL_STATE_IDLE; 770 // Flag to avoid redundant call InteractionJankMonitor::begin() 771 private boolean mIsScrollJankTraceBegin = false; 772 773 @Override 774 public void onPageSelected(int position) { 775 updateSelected(); 776 if (mPageIndicator == null) return; 777 if (mPageListener != null) { 778 int pageNumber = isLayoutRtl() ? mPages.size() - 1 - position : position; 779 mPageListener.onPageChanged(pageNumber == 0, pageNumber); 780 } 781 } 782 783 @Override 784 public void onPageScrolled(int position, float positionOffset, 785 int positionOffsetPixels) { 786 787 if (!mIsScrollJankTraceBegin && mCurrentScrollState == SCROLL_STATE_DRAGGING) { 788 InteractionJankMonitor.getInstance().begin(PagedTileLayout.this, 789 CUJ_NOTIFICATION_SHADE_QS_SCROLL_SWIPE); 790 mIsScrollJankTraceBegin = true; 791 } 792 793 if (mPageIndicator == null) return; 794 mPageIndicatorPosition = position + positionOffset; 795 mPageIndicator.setLocation(mPageIndicatorPosition); 796 if (mPageListener != null) { 797 int pageNumber = isLayoutRtl() ? mPages.size() - 1 - position : position; 798 mPageListener.onPageChanged( 799 positionOffsetPixels == 0 && pageNumber == 0, 800 // Send only valid page number on integer pages 801 positionOffsetPixels == 0 ? pageNumber : PageListener.INVALID_PAGE 802 ); 803 } 804 } 805 806 @Override 807 public void onPageScrollStateChanged(int state) { 808 if (state != mCurrentScrollState && state == SCROLL_STATE_IDLE) { 809 InteractionJankMonitor.getInstance().end( 810 CUJ_NOTIFICATION_SHADE_QS_SCROLL_SWIPE); 811 mIsScrollJankTraceBegin = false; 812 } 813 mCurrentScrollState = state; 814 } 815 }; 816 817 private final PagerAdapter mAdapter = new PagerAdapter() { 818 @Override 819 public void destroyItem(ViewGroup container, int position, Object object) { 820 mLogger.d("Destantiating page at", position); 821 container.removeView((View) object); 822 updateListening(); 823 } 824 825 @Override 826 public Object instantiateItem(ViewGroup container, int position) { 827 mLogger.d("Instantiating page at", position); 828 if (isLayoutRtl()) { 829 position = mPages.size() - 1 - position; 830 } 831 ViewGroup view = mPages.get(position); 832 if (view.getParent() != null) { 833 container.removeView(view); 834 } 835 container.addView(view); 836 updateListening(); 837 return view; 838 } 839 840 @Override 841 public int getCount() { 842 return mPages.size(); 843 } 844 845 @Override 846 public boolean isViewFromObject(View view, Object object) { 847 return view == object; 848 } 849 }; 850 851 /** 852 * Force all tiles to be redistributed across pages. 853 * Should be called when one of the following changes: rows, columns, number of tiles. 854 */ forceTilesRedistribution(String reason)855 public void forceTilesRedistribution(String reason) { 856 mLogger.d("forcing tile redistribution across pages, reason", reason); 857 mDistributeTiles = true; 858 } 859 setLogger(QSLogger qsLogger)860 public void setLogger(QSLogger qsLogger) { 861 mLogger = qsLogger; 862 } 863 864 public interface PageListener { 865 int INVALID_PAGE = -1; 866 867 void onPageChanged(boolean isFirst, int pageNumber); 868 } 869 } 870