1 package com.android.systemui.qs; 2 3 import android.animation.Animator; 4 import android.animation.AnimatorListenerAdapter; 5 import android.animation.AnimatorSet; 6 import android.animation.ObjectAnimator; 7 import android.animation.PropertyValuesHolder; 8 import android.content.Context; 9 import android.content.res.Configuration; 10 import android.os.Bundle; 11 import android.util.AttributeSet; 12 import android.util.Log; 13 import android.view.LayoutInflater; 14 import android.view.View; 15 import android.view.ViewGroup; 16 import android.view.animation.Interpolator; 17 import android.view.animation.OvershootInterpolator; 18 import android.widget.Scroller; 19 20 import androidx.viewpager.widget.PagerAdapter; 21 import androidx.viewpager.widget.ViewPager; 22 23 import com.android.internal.logging.UiEventLogger; 24 import com.android.systemui.R; 25 import com.android.systemui.plugins.qs.QSTile; 26 import com.android.systemui.qs.QSPanel.QSTileLayout; 27 import com.android.systemui.qs.QSPanelControllerBase.TileRecord; 28 29 import java.util.ArrayList; 30 import java.util.Set; 31 32 public class PagedTileLayout extends ViewPager implements QSTileLayout { 33 34 private static final boolean DEBUG = false; 35 private static final String CURRENT_PAGE = "current_page"; 36 37 private static final String TAG = "PagedTileLayout"; 38 private static final int REVEAL_SCROLL_DURATION_MILLIS = 750; 39 private static final float BOUNCE_ANIMATION_TENSION = 1.3f; 40 private static final long BOUNCE_ANIMATION_DURATION = 450L; 41 private static final int TILE_ANIMATION_STAGGER_DELAY = 85; 42 private static final Interpolator SCROLL_CUBIC = (t) -> { 43 t -= 1.0f; 44 return t * t * t + 1.0f; 45 }; 46 47 private final ArrayList<TileRecord> mTiles = new ArrayList<>(); 48 private final ArrayList<TileLayout> mPages = new ArrayList<>(); 49 50 private PageIndicator mPageIndicator; 51 private float mPageIndicatorPosition; 52 53 private PageListener mPageListener; 54 55 private boolean mListening; 56 private Scroller mScroller; 57 58 private AnimatorSet mBounceAnimatorSet; 59 private float mLastExpansion; 60 private boolean mDistributeTiles = false; 61 private int mPageToRestore = -1; 62 private int mLayoutOrientation; 63 private int mLayoutDirection; 64 private final UiEventLogger mUiEventLogger = QSEvents.INSTANCE.getQsUiEventsLogger(); 65 private int mExcessHeight; 66 private int mLastExcessHeight; 67 private int mMinRows = 1; 68 private int mMaxColumns = TileLayout.NO_MAX_COLUMNS; 69 PagedTileLayout(Context context, AttributeSet attrs)70 public PagedTileLayout(Context context, AttributeSet attrs) { 71 super(context, attrs); 72 mScroller = new Scroller(context, SCROLL_CUBIC); 73 setAdapter(mAdapter); 74 setOnPageChangeListener(mOnPageChangeListener); 75 setCurrentItem(0, false); 76 mLayoutOrientation = getResources().getConfiguration().orientation; 77 mLayoutDirection = getLayoutDirection(); 78 } 79 private int mLastMaxHeight = -1; 80 81 @Override setPageMargin(int marginPixels)82 public void setPageMargin(int marginPixels) { 83 // Using page margins creates some rounding issues that interfere with the correct position 84 // in the onPageChangedListener and therefore present bad positions to the PageIndicator. 85 // Instead, we use negative margins in the container and positive padding in the pages, 86 // matching the margin set from QSContainerImpl (note that new pages will always be inflated 87 // with the correct value. 88 // QSContainerImpl resources are set onAttachedView, so this view will always have the right 89 // values when attached. 90 MarginLayoutParams lp = (MarginLayoutParams) getLayoutParams(); 91 lp.setMarginStart(-marginPixels); 92 lp.setMarginEnd(-marginPixels); 93 setLayoutParams(lp); 94 95 int nPages = mPages.size(); 96 for (int i = 0; i < nPages; i++) { 97 View v = mPages.get(i); 98 v.setPadding(marginPixels, v.getPaddingTop(), marginPixels, v.getPaddingBottom()); 99 } 100 } 101 saveInstanceState(Bundle outState)102 public void saveInstanceState(Bundle outState) { 103 outState.putInt(CURRENT_PAGE, getCurrentItem()); 104 } 105 restoreInstanceState(Bundle savedInstanceState)106 public void restoreInstanceState(Bundle savedInstanceState) { 107 // There's only 1 page at this point. We want to restore the correct page once the 108 // pages have been inflated 109 mPageToRestore = savedInstanceState.getInt(CURRENT_PAGE, -1); 110 } 111 112 @Override getTilesHeight()113 public int getTilesHeight() { 114 // Use the first page as that is the maximum height we need to show. 115 TileLayout tileLayout = mPages.get(0); 116 if (tileLayout == null) { 117 return 0; 118 } 119 return tileLayout.getTilesHeight(); 120 } 121 122 @Override onConfigurationChanged(Configuration newConfig)123 protected void onConfigurationChanged(Configuration newConfig) { 124 super.onConfigurationChanged(newConfig); 125 // Pass configuration change to non-attached pages as well. Some config changes will cause 126 // QS to recreate itself (as determined in FragmentHostManager), but in order to minimize 127 // those, make sure that all get passed to all pages. 128 int numPages = mPages.size(); 129 for (int i = 0; i < numPages; i++) { 130 View page = mPages.get(i); 131 if (page.getParent() == null) { 132 page.dispatchConfigurationChanged(newConfig); 133 } 134 } 135 if (mLayoutOrientation != newConfig.orientation) { 136 mLayoutOrientation = newConfig.orientation; 137 mDistributeTiles = true; 138 setCurrentItem(0, false); 139 mPageToRestore = 0; 140 } 141 } 142 143 @Override onRtlPropertiesChanged(int layoutDirection)144 public void onRtlPropertiesChanged(int layoutDirection) { 145 super.onRtlPropertiesChanged(layoutDirection); 146 if (mLayoutDirection != layoutDirection) { 147 mLayoutDirection = layoutDirection; 148 setAdapter(mAdapter); 149 setCurrentItem(0, false); 150 mPageToRestore = 0; 151 } 152 } 153 154 @Override setCurrentItem(int item, boolean smoothScroll)155 public void setCurrentItem(int item, boolean smoothScroll) { 156 if (isLayoutRtl()) { 157 item = mPages.size() - 1 - item; 158 } 159 super.setCurrentItem(item, smoothScroll); 160 } 161 162 /** 163 * Obtains the current page number respecting RTL 164 */ getCurrentPageNumber()165 private int getCurrentPageNumber() { 166 int page = getCurrentItem(); 167 if (mLayoutDirection == LAYOUT_DIRECTION_RTL) { 168 page = mPages.size() - 1 - page; 169 } 170 return page; 171 } 172 173 // This will dump to the ui log all the tiles that are visible in this page logVisibleTiles(TileLayout page)174 private void logVisibleTiles(TileLayout page) { 175 for (int i = 0; i < page.mRecords.size(); i++) { 176 QSTile t = page.mRecords.get(i).tile; 177 mUiEventLogger.logWithInstanceId(QSEvent.QS_TILE_VISIBLE, 0, t.getMetricsSpec(), 178 t.getInstanceId()); 179 } 180 } 181 182 @Override setListening(boolean listening, UiEventLogger uiEventLogger)183 public void setListening(boolean listening, UiEventLogger uiEventLogger) { 184 if (mListening == listening) return; 185 mListening = listening; 186 updateListening(); 187 } 188 189 @Override setSquishinessFraction(float squishinessFraction)190 public void setSquishinessFraction(float squishinessFraction) { 191 int nPages = mPages.size(); 192 for (int i = 0; i < nPages; i++) { 193 mPages.get(i).setSquishinessFraction(squishinessFraction); 194 } 195 } 196 updateListening()197 private void updateListening() { 198 for (TileLayout tilePage : mPages) { 199 tilePage.setListening(tilePage.getParent() != null && mListening); 200 } 201 } 202 203 @Override fakeDragBy(float xOffset)204 public void fakeDragBy(float xOffset) { 205 try { 206 super.fakeDragBy(xOffset); 207 // Keep on drawing until the animation has finished. 208 postInvalidateOnAnimation(); 209 } catch (NullPointerException e) { 210 Log.e(TAG, "FakeDragBy called before begin", e); 211 // If we were trying to fake drag, it means we just added a new tile to the last 212 // page, so animate there. 213 final int lastPageNumber = mPages.size() - 1; 214 post(() -> { 215 setCurrentItem(lastPageNumber, true); 216 if (mBounceAnimatorSet != null) { 217 mBounceAnimatorSet.start(); 218 } 219 setOffscreenPageLimit(1); 220 }); 221 } 222 } 223 224 @Override endFakeDrag()225 public void endFakeDrag() { 226 try { 227 super.endFakeDrag(); 228 } catch (NullPointerException e) { 229 // Not sure what's going on. Let's log it 230 Log.e(TAG, "endFakeDrag called without velocityTracker", e); 231 } 232 } 233 234 @Override computeScroll()235 public void computeScroll() { 236 if (!mScroller.isFinished() && mScroller.computeScrollOffset()) { 237 if (!isFakeDragging()) { 238 beginFakeDrag(); 239 } 240 fakeDragBy(getScrollX() - mScroller.getCurrX()); 241 } else if (isFakeDragging()) { 242 endFakeDrag(); 243 if (mBounceAnimatorSet != null) { 244 mBounceAnimatorSet.start(); 245 } 246 setOffscreenPageLimit(1); 247 } 248 super.computeScroll(); 249 } 250 251 @Override hasOverlappingRendering()252 public boolean hasOverlappingRendering() { 253 return false; 254 } 255 256 @Override onFinishInflate()257 protected void onFinishInflate() { 258 super.onFinishInflate(); 259 mPages.add(createTileLayout()); 260 mAdapter.notifyDataSetChanged(); 261 } 262 createTileLayout()263 private TileLayout createTileLayout() { 264 TileLayout page = (TileLayout) LayoutInflater.from(getContext()) 265 .inflate(R.layout.qs_paged_page, this, false); 266 page.setMinRows(mMinRows); 267 page.setMaxColumns(mMaxColumns); 268 return page; 269 } 270 setPageIndicator(PageIndicator indicator)271 public void setPageIndicator(PageIndicator indicator) { 272 mPageIndicator = indicator; 273 mPageIndicator.setNumPages(mPages.size()); 274 mPageIndicator.setLocation(mPageIndicatorPosition); 275 } 276 277 @Override getOffsetTop(TileRecord tile)278 public int getOffsetTop(TileRecord tile) { 279 final ViewGroup parent = (ViewGroup) tile.tileView.getParent(); 280 if (parent == null) return 0; 281 return parent.getTop() + getTop(); 282 } 283 284 @Override addTile(TileRecord tile)285 public void addTile(TileRecord tile) { 286 mTiles.add(tile); 287 mDistributeTiles = true; 288 requestLayout(); 289 } 290 291 @Override removeTile(TileRecord tile)292 public void removeTile(TileRecord tile) { 293 if (mTiles.remove(tile)) { 294 mDistributeTiles = true; 295 requestLayout(); 296 } 297 } 298 299 @Override setExpansion(float expansion, float proposedTranslation)300 public void setExpansion(float expansion, float proposedTranslation) { 301 mLastExpansion = expansion; 302 updateSelected(); 303 } 304 updateSelected()305 private void updateSelected() { 306 // Start the marquee when fully expanded and stop when fully collapsed. Leave as is for 307 // other expansion ratios since there is no way way to pause the marquee. 308 if (mLastExpansion > 0f && mLastExpansion < 1f) { 309 return; 310 } 311 boolean selected = mLastExpansion == 1f; 312 313 // Disable accessibility temporarily while we update selected state purely for the 314 // marquee. This will ensure that accessibility doesn't announce the TYPE_VIEW_SELECTED 315 // event on any of the children. 316 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 317 int currentItem = getCurrentPageNumber(); 318 for (int i = 0; i < mPages.size(); i++) { 319 TileLayout page = mPages.get(i); 320 page.setSelected(i == currentItem ? selected : false); 321 if (page.isSelected()) { 322 logVisibleTiles(page); 323 } 324 } 325 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 326 } 327 setPageListener(PageListener listener)328 public void setPageListener(PageListener listener) { 329 mPageListener = listener; 330 } 331 distributeTiles()332 private void distributeTiles() { 333 emptyAndInflateOrRemovePages(); 334 335 final int tileCount = mPages.get(0).maxTiles(); 336 if (DEBUG) Log.d(TAG, "Distributing tiles"); 337 int index = 0; 338 final int NT = mTiles.size(); 339 for (int i = 0; i < NT; i++) { 340 TileRecord tile = mTiles.get(i); 341 if (mPages.get(index).mRecords.size() == tileCount) index++; 342 if (DEBUG) { 343 Log.d(TAG, "Adding " + tile.tile.getClass().getSimpleName() + " to " 344 + index); 345 } 346 mPages.get(index).addTile(tile); 347 } 348 } 349 emptyAndInflateOrRemovePages()350 private void emptyAndInflateOrRemovePages() { 351 final int numPages = getNumPages(); 352 final int NP = mPages.size(); 353 for (int i = 0; i < NP; i++) { 354 mPages.get(i).removeAllViews(); 355 } 356 if (NP == numPages) { 357 return; 358 } 359 while (mPages.size() < numPages) { 360 if (DEBUG) Log.d(TAG, "Adding page"); 361 mPages.add(createTileLayout()); 362 } 363 while (mPages.size() > numPages) { 364 if (DEBUG) Log.d(TAG, "Removing page"); 365 mPages.remove(mPages.size() - 1); 366 } 367 mPageIndicator.setNumPages(mPages.size()); 368 setAdapter(mAdapter); 369 mAdapter.notifyDataSetChanged(); 370 if (mPageToRestore != -1) { 371 setCurrentItem(mPageToRestore, false); 372 mPageToRestore = -1; 373 } 374 } 375 376 @Override updateResources()377 public boolean updateResources() { 378 boolean changed = false; 379 for (int i = 0; i < mPages.size(); i++) { 380 changed |= mPages.get(i).updateResources(); 381 } 382 if (changed) { 383 mDistributeTiles = true; 384 requestLayout(); 385 } 386 return changed; 387 } 388 389 @Override setMinRows(int minRows)390 public boolean setMinRows(int minRows) { 391 mMinRows = minRows; 392 boolean changed = false; 393 for (int i = 0; i < mPages.size(); i++) { 394 if (mPages.get(i).setMinRows(minRows)) { 395 changed = true; 396 mDistributeTiles = true; 397 } 398 } 399 return changed; 400 } 401 402 @Override setMaxColumns(int maxColumns)403 public boolean setMaxColumns(int maxColumns) { 404 mMaxColumns = maxColumns; 405 boolean changed = false; 406 for (int i = 0; i < mPages.size(); i++) { 407 if (mPages.get(i).setMaxColumns(maxColumns)) { 408 changed = true; 409 mDistributeTiles = true; 410 } 411 } 412 return changed; 413 } 414 415 /** 416 * Set the amount of excess space that we gave this view compared to the actual available 417 * height. This is because this view is in a scrollview. 418 */ setExcessHeight(int excessHeight)419 public void setExcessHeight(int excessHeight) { 420 mExcessHeight = excessHeight; 421 } 422 423 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)424 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 425 426 final int nTiles = mTiles.size(); 427 // If we have no reason to recalculate the number of rows, skip this step. In particular, 428 // if the height passed by its parent is the same as the last time, we try not to remeasure. 429 if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec) 430 || mLastExcessHeight != mExcessHeight) { 431 432 mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec); 433 mLastExcessHeight = mExcessHeight; 434 // Only change the pages if the number of rows or columns (from updateResources) has 435 // changed or the tiles have changed 436 int availableHeight = mLastMaxHeight - mExcessHeight; 437 if (mPages.get(0).updateMaxRows(availableHeight, nTiles) || mDistributeTiles) { 438 mDistributeTiles = false; 439 distributeTiles(); 440 } 441 442 final int nRows = mPages.get(0).mRows; 443 for (int i = 0; i < mPages.size(); i++) { 444 TileLayout t = mPages.get(i); 445 t.mRows = nRows; 446 } 447 } 448 449 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 450 451 // The ViewPager likes to eat all of the space, instead force it to wrap to the max height 452 // of the pages. 453 int maxHeight = 0; 454 final int N = getChildCount(); 455 for (int i = 0; i < N; i++) { 456 int height = getChildAt(i).getMeasuredHeight(); 457 if (height > maxHeight) { 458 maxHeight = height; 459 } 460 } 461 setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom()); 462 } 463 getColumnCount()464 public int getColumnCount() { 465 if (mPages.size() == 0) return 0; 466 return mPages.get(0).mColumns; 467 } 468 469 /** 470 * Gets the number of pages in this paged tile layout 471 */ getNumPages()472 public int getNumPages() { 473 final int nTiles = mTiles.size(); 474 // We should always have at least one page, even if it's empty. 475 int numPages = Math.max(nTiles / mPages.get(0).maxTiles(), 1); 476 477 // Add one more not full page if needed 478 if (nTiles > numPages * mPages.get(0).maxTiles()) { 479 numPages++; 480 } 481 482 return numPages; 483 } 484 getNumVisibleTiles()485 public int getNumVisibleTiles() { 486 if (mPages.size() == 0) return 0; 487 TileLayout currentPage = mPages.get(getCurrentPageNumber()); 488 return currentPage.mRecords.size(); 489 } 490 startTileReveal(Set<String> tileSpecs, final Runnable postAnimation)491 public void startTileReveal(Set<String> tileSpecs, final Runnable postAnimation) { 492 if (tileSpecs.isEmpty() || mPages.size() < 2 || getScrollX() != 0 || !beginFakeDrag()) { 493 // Do not start the reveal animation unless there are tiles to animate, multiple 494 // TileLayouts available and the user has not already started dragging. 495 return; 496 } 497 498 final int lastPageNumber = mPages.size() - 1; 499 final TileLayout lastPage = mPages.get(lastPageNumber); 500 final ArrayList<Animator> bounceAnims = new ArrayList<>(); 501 for (TileRecord tr : lastPage.mRecords) { 502 if (tileSpecs.contains(tr.tile.getTileSpec())) { 503 bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size())); 504 } 505 } 506 507 if (bounceAnims.isEmpty()) { 508 // All tileSpecs are on the first page. Nothing to do. 509 // TODO: potentially show a bounce animation for first page QS tiles 510 endFakeDrag(); 511 return; 512 } 513 514 mBounceAnimatorSet = new AnimatorSet(); 515 mBounceAnimatorSet.playTogether(bounceAnims); 516 mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() { 517 @Override 518 public void onAnimationEnd(Animator animation) { 519 mBounceAnimatorSet = null; 520 postAnimation.run(); 521 } 522 }); 523 setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated. 524 int dx = getWidth() * lastPageNumber; 525 mScroller.startScroll(getScrollX(), getScrollY(), isLayoutRtl() ? -dx : dx, 0, 526 REVEAL_SCROLL_DURATION_MILLIS); 527 postInvalidateOnAnimation(); 528 } 529 setupBounceAnimator(View view, int ordinal)530 private static Animator setupBounceAnimator(View view, int ordinal) { 531 view.setAlpha(0f); 532 view.setScaleX(0f); 533 view.setScaleY(0f); 534 ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view, 535 PropertyValuesHolder.ofFloat(View.ALPHA, 1), 536 PropertyValuesHolder.ofFloat(View.SCALE_X, 1), 537 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1)); 538 animator.setDuration(BOUNCE_ANIMATION_DURATION); 539 animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY); 540 animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION)); 541 return animator; 542 } 543 544 private final ViewPager.OnPageChangeListener mOnPageChangeListener = 545 new ViewPager.SimpleOnPageChangeListener() { 546 @Override 547 public void onPageSelected(int position) { 548 updateSelected(); 549 if (mPageIndicator == null) return; 550 if (mPageListener != null) { 551 mPageListener.onPageChanged(isLayoutRtl() ? position == mPages.size() - 1 552 : position == 0); 553 } 554 } 555 556 @Override 557 public void onPageScrolled(int position, float positionOffset, 558 int positionOffsetPixels) { 559 if (mPageIndicator == null) return; 560 mPageIndicatorPosition = position + positionOffset; 561 mPageIndicator.setLocation(mPageIndicatorPosition); 562 if (mPageListener != null) { 563 mPageListener.onPageChanged(positionOffsetPixels == 0 && 564 (isLayoutRtl() ? position == mPages.size() - 1 : position == 0)); 565 } 566 } 567 }; 568 569 private final PagerAdapter mAdapter = new PagerAdapter() { 570 @Override 571 public void destroyItem(ViewGroup container, int position, Object object) { 572 if (DEBUG) Log.d(TAG, "Destantiating " + position); 573 container.removeView((View) object); 574 updateListening(); 575 } 576 577 @Override 578 public Object instantiateItem(ViewGroup container, int position) { 579 if (DEBUG) Log.d(TAG, "Instantiating " + position); 580 if (isLayoutRtl()) { 581 position = mPages.size() - 1 - position; 582 } 583 ViewGroup view = mPages.get(position); 584 if (view.getParent() != null) { 585 container.removeView(view); 586 } 587 container.addView(view); 588 updateListening(); 589 return view; 590 } 591 592 @Override 593 public int getCount() { 594 return mPages.size(); 595 } 596 597 @Override 598 public boolean isViewFromObject(View view, Object object) { 599 return view == object; 600 } 601 }; 602 603 public interface PageListener { onPageChanged(boolean isFirst)604 void onPageChanged(boolean isFirst); 605 } 606 } 607