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