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