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 computeScroll()180 public void computeScroll() { 181 if (!mScroller.isFinished() && mScroller.computeScrollOffset()) { 182 if (!isFakeDragging()) { 183 beginFakeDrag(); 184 } 185 fakeDragBy(getScrollX() - mScroller.getCurrX()); 186 } else if (isFakeDragging()) { 187 endFakeDrag(); 188 mBounceAnimatorSet.start(); 189 setOffscreenPageLimit(1); 190 } 191 super.computeScroll(); 192 } 193 194 @Override hasOverlappingRendering()195 public boolean hasOverlappingRendering() { 196 return false; 197 } 198 199 @Override onFinishInflate()200 protected void onFinishInflate() { 201 super.onFinishInflate(); 202 mPages.add(createTilePage()); 203 mAdapter.notifyDataSetChanged(); 204 } 205 createTilePage()206 private TilePage createTilePage() { 207 TilePage page = (TilePage) LayoutInflater.from(getContext()) 208 .inflate(R.layout.qs_paged_page, this, false); 209 page.setMinRows(mMinRows); 210 page.setMaxColumns(mMaxColumns); 211 return page; 212 } 213 setPageIndicator(PageIndicator indicator)214 public void setPageIndicator(PageIndicator indicator) { 215 mPageIndicator = indicator; 216 mPageIndicator.setNumPages(mPages.size()); 217 mPageIndicator.setLocation(mPageIndicatorPosition); 218 } 219 220 @Override getOffsetTop(TileRecord tile)221 public int getOffsetTop(TileRecord tile) { 222 final ViewGroup parent = (ViewGroup) tile.tileView.getParent(); 223 if (parent == null) return 0; 224 return parent.getTop() + getTop(); 225 } 226 227 @Override addTile(TileRecord tile)228 public void addTile(TileRecord tile) { 229 mTiles.add(tile); 230 mDistributeTiles = true; 231 requestLayout(); 232 } 233 234 @Override removeTile(TileRecord tile)235 public void removeTile(TileRecord tile) { 236 if (mTiles.remove(tile)) { 237 mDistributeTiles = true; 238 requestLayout(); 239 } 240 } 241 242 @Override setExpansion(float expansion)243 public void setExpansion(float expansion) { 244 mLastExpansion = expansion; 245 updateSelected(); 246 } 247 updateSelected()248 private void updateSelected() { 249 // Start the marquee when fully expanded and stop when fully collapsed. Leave as is for 250 // other expansion ratios since there is no way way to pause the marquee. 251 if (mLastExpansion > 0f && mLastExpansion < 1f) { 252 return; 253 } 254 boolean selected = mLastExpansion == 1f; 255 256 // Disable accessibility temporarily while we update selected state purely for the 257 // marquee. This will ensure that accessibility doesn't announce the TYPE_VIEW_SELECTED 258 // event on any of the children. 259 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 260 int currentItem = getCurrentPageNumber(); 261 for (int i = 0; i < mPages.size(); i++) { 262 TilePage page = mPages.get(i); 263 page.setSelected(i == currentItem ? selected : false); 264 if (page.isSelected()) { 265 logVisibleTiles(page); 266 } 267 } 268 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 269 } 270 setPageListener(PageListener listener)271 public void setPageListener(PageListener listener) { 272 mPageListener = listener; 273 } 274 distributeTiles()275 private void distributeTiles() { 276 emptyAndInflateOrRemovePages(); 277 278 final int tileCount = mPages.get(0).maxTiles(); 279 if (DEBUG) Log.d(TAG, "Distributing tiles"); 280 int index = 0; 281 final int NT = mTiles.size(); 282 for (int i = 0; i < NT; i++) { 283 TileRecord tile = mTiles.get(i); 284 if (mPages.get(index).mRecords.size() == tileCount) index++; 285 if (DEBUG) { 286 Log.d(TAG, "Adding " + tile.tile.getClass().getSimpleName() + " to " 287 + index); 288 } 289 mPages.get(index).addTile(tile); 290 } 291 } 292 emptyAndInflateOrRemovePages()293 private void emptyAndInflateOrRemovePages() { 294 final int numPages = getNumPages(); 295 final int NP = mPages.size(); 296 for (int i = 0; i < NP; i++) { 297 mPages.get(i).removeAllViews(); 298 } 299 if (NP == numPages) { 300 return; 301 } 302 while (mPages.size() < numPages) { 303 if (DEBUG) Log.d(TAG, "Adding page"); 304 mPages.add(createTilePage()); 305 } 306 while (mPages.size() > numPages) { 307 if (DEBUG) Log.d(TAG, "Removing page"); 308 mPages.remove(mPages.size() - 1); 309 } 310 mPageIndicator.setNumPages(mPages.size()); 311 setAdapter(mAdapter); 312 mAdapter.notifyDataSetChanged(); 313 if (mPageToRestore != -1) { 314 setCurrentItem(mPageToRestore, false); 315 mPageToRestore = -1; 316 } 317 } 318 319 @Override updateResources()320 public boolean updateResources() { 321 // Update bottom padding, useful for removing extra space once the panel page indicator is 322 // hidden. 323 Resources res = getContext().getResources(); 324 mHorizontalClipBound = res.getDimensionPixelSize(R.dimen.notification_side_paddings); 325 setPadding(0, 0, 0, 326 getContext().getResources().getDimensionPixelSize( 327 R.dimen.qs_paged_tile_layout_padding_bottom)); 328 boolean changed = false; 329 for (int i = 0; i < mPages.size(); i++) { 330 changed |= mPages.get(i).updateResources(); 331 } 332 if (changed) { 333 mDistributeTiles = true; 334 requestLayout(); 335 } 336 return changed; 337 } 338 339 @Override onLayout(boolean changed, int l, int t, int r, int b)340 protected void onLayout(boolean changed, int l, int t, int r, int b) { 341 super.onLayout(changed, l, t, r, b); 342 mClippingRect.set(mHorizontalClipBound, 0, (r - l) - mHorizontalClipBound, b - t); 343 setClipBounds(mClippingRect); 344 } 345 346 @Override setMinRows(int minRows)347 public boolean setMinRows(int minRows) { 348 mMinRows = minRows; 349 boolean changed = false; 350 for (int i = 0; i < mPages.size(); i++) { 351 if (mPages.get(i).setMinRows(minRows)) { 352 changed = true; 353 mDistributeTiles = true; 354 } 355 } 356 return changed; 357 } 358 359 @Override setMaxColumns(int maxColumns)360 public boolean setMaxColumns(int maxColumns) { 361 mMaxColumns = maxColumns; 362 boolean changed = false; 363 for (int i = 0; i < mPages.size(); i++) { 364 if (mPages.get(i).setMaxColumns(maxColumns)) { 365 changed = true; 366 mDistributeTiles = true; 367 } 368 } 369 return changed; 370 } 371 372 /** 373 * Set the amount of excess space that we gave this view compared to the actual available 374 * height. This is because this view is in a scrollview. 375 */ setExcessHeight(int excessHeight)376 public void setExcessHeight(int excessHeight) { 377 mExcessHeight = excessHeight; 378 } 379 380 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)381 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 382 383 final int nTiles = mTiles.size(); 384 // If we have no reason to recalculate the number of rows, skip this step. In particular, 385 // if the height passed by its parent is the same as the last time, we try not to remeasure. 386 if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec) 387 || mLastExcessHeight != mExcessHeight) { 388 389 mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec); 390 mLastExcessHeight = mExcessHeight; 391 // Only change the pages if the number of rows or columns (from updateResources) has 392 // changed or the tiles have changed 393 int availableHeight = mLastMaxHeight - mExcessHeight; 394 if (mPages.get(0).updateMaxRows(availableHeight, nTiles) || mDistributeTiles) { 395 mDistributeTiles = false; 396 distributeTiles(); 397 } 398 399 final int nRows = mPages.get(0).mRows; 400 for (int i = 0; i < mPages.size(); i++) { 401 TilePage t = mPages.get(i); 402 t.mRows = nRows; 403 } 404 } 405 406 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 407 408 // The ViewPager likes to eat all of the space, instead force it to wrap to the max height 409 // of the pages. 410 int maxHeight = 0; 411 final int N = getChildCount(); 412 for (int i = 0; i < N; i++) { 413 int height = getChildAt(i).getMeasuredHeight(); 414 if (height > maxHeight) { 415 maxHeight = height; 416 } 417 } 418 setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom()); 419 } 420 getColumnCount()421 public int getColumnCount() { 422 if (mPages.size() == 0) return 0; 423 return mPages.get(0).mColumns; 424 } 425 426 /** 427 * Gets the number of pages in this paged tile layout 428 */ getNumPages()429 public int getNumPages() { 430 final int nTiles = mTiles.size(); 431 // We should always have at least one page, even if it's empty. 432 int numPages = Math.max(nTiles / mPages.get(0).maxTiles(), 1); 433 434 // Add one more not full page if needed 435 if (nTiles > numPages * mPages.get(0).maxTiles()) { 436 numPages++; 437 } 438 439 return numPages; 440 } 441 getNumVisibleTiles()442 public int getNumVisibleTiles() { 443 if (mPages.size() == 0) return 0; 444 TilePage currentPage = mPages.get(getCurrentPageNumber()); 445 return currentPage.mRecords.size(); 446 } 447 startTileReveal(Set<String> tileSpecs, final Runnable postAnimation)448 public void startTileReveal(Set<String> tileSpecs, final Runnable postAnimation) { 449 if (tileSpecs.isEmpty() || mPages.size() < 2 || getScrollX() != 0 || !beginFakeDrag()) { 450 // Do not start the reveal animation unless there are tiles to animate, multiple 451 // TilePages available and the user has not already started dragging. 452 return; 453 } 454 455 final int lastPageNumber = mPages.size() - 1; 456 final TilePage lastPage = mPages.get(lastPageNumber); 457 final ArrayList<Animator> bounceAnims = new ArrayList<>(); 458 for (TileRecord tr : lastPage.mRecords) { 459 if (tileSpecs.contains(tr.tile.getTileSpec())) { 460 bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size())); 461 } 462 } 463 464 if (bounceAnims.isEmpty()) { 465 // All tileSpecs are on the first page. Nothing to do. 466 // TODO: potentially show a bounce animation for first page QS tiles 467 endFakeDrag(); 468 return; 469 } 470 471 mBounceAnimatorSet = new AnimatorSet(); 472 mBounceAnimatorSet.playTogether(bounceAnims); 473 mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() { 474 @Override 475 public void onAnimationEnd(Animator animation) { 476 mBounceAnimatorSet = null; 477 postAnimation.run(); 478 } 479 }); 480 setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated. 481 int dx = getWidth() * lastPageNumber; 482 mScroller.startScroll(getScrollX(), getScrollY(), isLayoutRtl() ? -dx : dx, 0, 483 REVEAL_SCROLL_DURATION_MILLIS); 484 postInvalidateOnAnimation(); 485 } 486 setupBounceAnimator(View view, int ordinal)487 private static Animator setupBounceAnimator(View view, int ordinal) { 488 view.setAlpha(0f); 489 view.setScaleX(0f); 490 view.setScaleY(0f); 491 ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view, 492 PropertyValuesHolder.ofFloat(View.ALPHA, 1), 493 PropertyValuesHolder.ofFloat(View.SCALE_X, 1), 494 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1)); 495 animator.setDuration(BOUNCE_ANIMATION_DURATION); 496 animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY); 497 animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION)); 498 return animator; 499 } 500 501 private final ViewPager.OnPageChangeListener mOnPageChangeListener = 502 new ViewPager.SimpleOnPageChangeListener() { 503 @Override 504 public void onPageSelected(int position) { 505 updateSelected(); 506 if (mPageIndicator == null) return; 507 if (mPageListener != null) { 508 mPageListener.onPageChanged(isLayoutRtl() ? position == mPages.size() - 1 509 : position == 0); 510 } 511 512 } 513 514 @Override 515 public void onPageScrolled(int position, float positionOffset, 516 int positionOffsetPixels) { 517 if (mPageIndicator == null) return; 518 mPageIndicatorPosition = position + positionOffset; 519 mPageIndicator.setLocation(mPageIndicatorPosition); 520 if (mPageListener != null) { 521 mPageListener.onPageChanged(positionOffsetPixels == 0 && 522 (isLayoutRtl() ? position == mPages.size() - 1 : position == 0)); 523 } 524 } 525 }; 526 527 public static class TilePage extends TileLayout { 528 TilePage(Context context, AttributeSet attrs)529 public TilePage(Context context, AttributeSet attrs) { 530 super(context, attrs); 531 } 532 isFull()533 public boolean isFull() { 534 return mRecords.size() >= maxTiles(); 535 } 536 maxTiles()537 public int maxTiles() { 538 // Each page should be able to hold at least one tile. If there's not enough room to 539 // show even 1 or there are no tiles, it probably means we are in the middle of setting 540 // up. 541 return Math.max(mColumns * mRows, 1); 542 } 543 } 544 545 private final PagerAdapter mAdapter = new PagerAdapter() { 546 @Override 547 public void destroyItem(ViewGroup container, int position, Object object) { 548 if (DEBUG) Log.d(TAG, "Destantiating " + position); 549 container.removeView((View) object); 550 updateListening(); 551 } 552 553 @Override 554 public Object instantiateItem(ViewGroup container, int position) { 555 if (DEBUG) Log.d(TAG, "Instantiating " + position); 556 if (isLayoutRtl()) { 557 position = mPages.size() - 1 - position; 558 } 559 ViewGroup view = mPages.get(position); 560 if (view.getParent() != null) { 561 container.removeView(view); 562 } 563 container.addView(view); 564 updateListening(); 565 return view; 566 } 567 568 @Override 569 public int getCount() { 570 return mPages.size(); 571 } 572 573 @Override 574 public boolean isViewFromObject(View view, Object object) { 575 return view == object; 576 } 577 }; 578 579 public interface PageListener { onPageChanged(boolean isFirst)580 void onPageChanged(boolean isFirst); 581 } 582 } 583