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.systemui.R; 26 import com.android.systemui.qs.QSPanel.QSTileLayout; 27 import com.android.systemui.qs.QSPanel.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<TilePage> 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 int mHorizontalClipBound; 65 private final Rect mClippingRect; 66 private int mLastMaxHeight = -1; 67 PagedTileLayout(Context context, AttributeSet attrs)68 public PagedTileLayout(Context context, AttributeSet attrs) { 69 super(context, attrs); 70 mScroller = new Scroller(context, SCROLL_CUBIC); 71 setAdapter(mAdapter); 72 setOnPageChangeListener(mOnPageChangeListener); 73 setCurrentItem(0, false); 74 mLayoutOrientation = getResources().getConfiguration().orientation; 75 mLayoutDirection = getLayoutDirection(); 76 mClippingRect = new Rect(); 77 } 78 saveInstanceState(Bundle outState)79 public void saveInstanceState(Bundle outState) { 80 outState.putInt(CURRENT_PAGE, getCurrentItem()); 81 } 82 restoreInstanceState(Bundle savedInstanceState)83 public void restoreInstanceState(Bundle savedInstanceState) { 84 // There's only 1 page at this point. We want to restore the correct page once the 85 // pages have been inflated 86 mPageToRestore = savedInstanceState.getInt(CURRENT_PAGE, -1); 87 } 88 89 @Override onConfigurationChanged(Configuration newConfig)90 protected void onConfigurationChanged(Configuration newConfig) { 91 super.onConfigurationChanged(newConfig); 92 if (mLayoutOrientation != newConfig.orientation) { 93 mLayoutOrientation = newConfig.orientation; 94 setCurrentItem(0, false); 95 mPageToRestore = 0; 96 } 97 } 98 99 @Override onRtlPropertiesChanged(int layoutDirection)100 public void onRtlPropertiesChanged(int layoutDirection) { 101 super.onRtlPropertiesChanged(layoutDirection); 102 if (mLayoutDirection != layoutDirection) { 103 mLayoutDirection = layoutDirection; 104 setAdapter(mAdapter); 105 setCurrentItem(0, false); 106 mPageToRestore = 0; 107 } 108 } 109 110 @Override setCurrentItem(int item, boolean smoothScroll)111 public void setCurrentItem(int item, boolean smoothScroll) { 112 if (isLayoutRtl()) { 113 item = mPages.size() - 1 - item; 114 } 115 super.setCurrentItem(item, smoothScroll); 116 } 117 118 /** 119 * Obtains the current page number respecting RTL 120 */ getCurrentPageNumber()121 private int getCurrentPageNumber() { 122 int page = getCurrentItem(); 123 if (mLayoutDirection == LAYOUT_DIRECTION_RTL) { 124 page = mPages.size() - 1 - page; 125 } 126 return page; 127 } 128 129 @Override setListening(boolean listening)130 public void setListening(boolean listening) { 131 if (mListening == listening) return; 132 mListening = listening; 133 updateListening(); 134 } 135 updateListening()136 private void updateListening() { 137 for (TilePage tilePage : mPages) { 138 tilePage.setListening(tilePage.getParent() == null ? false : mListening); 139 } 140 } 141 142 @Override computeScroll()143 public void computeScroll() { 144 if (!mScroller.isFinished() && mScroller.computeScrollOffset()) { 145 fakeDragBy(getScrollX() - mScroller.getCurrX()); 146 // Keep on drawing until the animation has finished. 147 postInvalidateOnAnimation(); 148 return; 149 } else if (isFakeDragging()) { 150 endFakeDrag(); 151 mBounceAnimatorSet.start(); 152 setOffscreenPageLimit(1); 153 } 154 super.computeScroll(); 155 } 156 157 @Override hasOverlappingRendering()158 public boolean hasOverlappingRendering() { 159 return false; 160 } 161 162 @Override onFinishInflate()163 protected void onFinishInflate() { 164 super.onFinishInflate(); 165 mPages.add((TilePage) LayoutInflater.from(getContext()) 166 .inflate(R.layout.qs_paged_page, this, false)); 167 mAdapter.notifyDataSetChanged(); 168 } 169 setPageIndicator(PageIndicator indicator)170 public void setPageIndicator(PageIndicator indicator) { 171 mPageIndicator = indicator; 172 mPageIndicator.setNumPages(mPages.size()); 173 mPageIndicator.setLocation(mPageIndicatorPosition); 174 } 175 176 @Override getOffsetTop(TileRecord tile)177 public int getOffsetTop(TileRecord tile) { 178 final ViewGroup parent = (ViewGroup) tile.tileView.getParent(); 179 if (parent == null) return 0; 180 return parent.getTop() + getTop(); 181 } 182 183 @Override addTile(TileRecord tile)184 public void addTile(TileRecord tile) { 185 mTiles.add(tile); 186 mDistributeTiles = true; 187 requestLayout(); 188 } 189 190 @Override removeTile(TileRecord tile)191 public void removeTile(TileRecord tile) { 192 if (mTiles.remove(tile)) { 193 mDistributeTiles = true; 194 requestLayout(); 195 } 196 } 197 198 @Override setExpansion(float expansion)199 public void setExpansion(float expansion) { 200 mLastExpansion = expansion; 201 updateSelected(); 202 } 203 updateSelected()204 private void updateSelected() { 205 // Start the marquee when fully expanded and stop when fully collapsed. Leave as is for 206 // other expansion ratios since there is no way way to pause the marquee. 207 if (mLastExpansion > 0f && mLastExpansion < 1f) { 208 return; 209 } 210 boolean selected = mLastExpansion == 1f; 211 212 // Disable accessibility temporarily while we update selected state purely for the 213 // marquee. This will ensure that accessibility doesn't announce the TYPE_VIEW_SELECTED 214 // event on any of the children. 215 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); 216 int currentItem = getCurrentPageNumber(); 217 for (int i = 0; i < mPages.size(); i++) { 218 mPages.get(i).setSelected(i == currentItem ? selected : false); 219 } 220 setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO); 221 } 222 setPageListener(PageListener listener)223 public void setPageListener(PageListener listener) { 224 mPageListener = listener; 225 } 226 distributeTiles()227 private void distributeTiles() { 228 emptyAndInflateOrRemovePages(); 229 230 final int tileCount = mPages.get(0).maxTiles(); 231 if (DEBUG) Log.d(TAG, "Distributing tiles"); 232 int index = 0; 233 final int NT = mTiles.size(); 234 for (int i = 0; i < NT; i++) { 235 TileRecord tile = mTiles.get(i); 236 if (mPages.get(index).mRecords.size() == tileCount) index++; 237 if (DEBUG) { 238 Log.d(TAG, "Adding " + tile.tile.getClass().getSimpleName() + " to " 239 + index); 240 } 241 mPages.get(index).addTile(tile); 242 } 243 } 244 emptyAndInflateOrRemovePages()245 private void emptyAndInflateOrRemovePages() { 246 final int nTiles = mTiles.size(); 247 // We should always have at least one page, even if it's empty. 248 int numPages = Math.max(nTiles / mPages.get(0).maxTiles(), 1); 249 250 // Add one more not full page if needed 251 if (nTiles > numPages * mPages.get(0).maxTiles()) { 252 numPages++; 253 } 254 255 final int NP = mPages.size(); 256 for (int i = 0; i < NP; i++) { 257 mPages.get(i).removeAllViews(); 258 } 259 if (NP == numPages) { 260 return; 261 } 262 while (mPages.size() < numPages) { 263 if (DEBUG) Log.d(TAG, "Adding page"); 264 mPages.add((TilePage) LayoutInflater.from(getContext()) 265 .inflate(R.layout.qs_paged_page, this, false)); 266 } 267 while (mPages.size() > numPages) { 268 if (DEBUG) Log.d(TAG, "Removing page"); 269 mPages.remove(mPages.size() - 1); 270 } 271 mPageIndicator.setNumPages(mPages.size()); 272 setAdapter(mAdapter); 273 mAdapter.notifyDataSetChanged(); 274 if (mPageToRestore != -1) { 275 setCurrentItem(mPageToRestore, false); 276 mPageToRestore = -1; 277 } 278 } 279 280 @Override updateResources()281 public boolean updateResources() { 282 // Update bottom padding, useful for removing extra space once the panel page indicator is 283 // hidden. 284 Resources res = getContext().getResources(); 285 mHorizontalClipBound = res.getDimensionPixelSize(R.dimen.notification_side_paddings); 286 setPadding(0, 0, 0, 287 getContext().getResources().getDimensionPixelSize( 288 R.dimen.qs_paged_tile_layout_padding_bottom)); 289 boolean changed = false; 290 for (int i = 0; i < mPages.size(); i++) { 291 changed |= mPages.get(i).updateResources(); 292 } 293 if (changed) { 294 mDistributeTiles = true; 295 requestLayout(); 296 } 297 return changed; 298 } 299 300 @Override onLayout(boolean changed, int l, int t, int r, int b)301 protected void onLayout(boolean changed, int l, int t, int r, int b) { 302 super.onLayout(changed, l, t, r, b); 303 mClippingRect.set(mHorizontalClipBound, 0, (r - l) - mHorizontalClipBound, b - t); 304 setClipBounds(mClippingRect); 305 } 306 307 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)308 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 309 310 final int nTiles = mTiles.size(); 311 // If we have no reason to recalculate the number of rows, skip this step. In particular, 312 // if the height passed by its parent is the same as the last time, we try not to remeasure. 313 if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec)) { 314 315 mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec); 316 // Only change the pages if the number of rows or columns (from updateResources) has 317 // changed or the tiles have changed 318 if (mPages.get(0).updateMaxRows(heightMeasureSpec, nTiles) || mDistributeTiles) { 319 mDistributeTiles = false; 320 distributeTiles(); 321 } 322 323 final int nRows = mPages.get(0).mRows; 324 for (int i = 0; i < mPages.size(); i++) { 325 TilePage t = mPages.get(i); 326 t.mRows = nRows; 327 } 328 } 329 330 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 331 332 // The ViewPager likes to eat all of the space, instead force it to wrap to the max height 333 // of the pages. 334 int maxHeight = 0; 335 final int N = getChildCount(); 336 for (int i = 0; i < N; i++) { 337 int height = getChildAt(i).getMeasuredHeight(); 338 if (height > maxHeight) { 339 maxHeight = height; 340 } 341 } 342 setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom()); 343 } 344 getColumnCount()345 public int getColumnCount() { 346 if (mPages.size() == 0) return 0; 347 return mPages.get(0).mColumns; 348 } 349 getNumVisibleTiles()350 public int getNumVisibleTiles() { 351 if (mPages.size() == 0) return 0; 352 TilePage currentPage = mPages.get(getCurrentPageNumber()); 353 return currentPage.mRecords.size(); 354 } 355 startTileReveal(Set<String> tileSpecs, final Runnable postAnimation)356 public void startTileReveal(Set<String> tileSpecs, final Runnable postAnimation) { 357 if (tileSpecs.isEmpty() || mPages.size() < 2 || getScrollX() != 0 || !beginFakeDrag()) { 358 // Do not start the reveal animation unless there are tiles to animate, multiple 359 // TilePages available and the user has not already started dragging. 360 return; 361 } 362 363 final int lastPageNumber = mPages.size() - 1; 364 final TilePage lastPage = mPages.get(lastPageNumber); 365 final ArrayList<Animator> bounceAnims = new ArrayList<>(); 366 for (TileRecord tr : lastPage.mRecords) { 367 if (tileSpecs.contains(tr.tile.getTileSpec())) { 368 bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size())); 369 } 370 } 371 372 if (bounceAnims.isEmpty()) { 373 // All tileSpecs are on the first page. Nothing to do. 374 // TODO: potentially show a bounce animation for first page QS tiles 375 endFakeDrag(); 376 return; 377 } 378 379 mBounceAnimatorSet = new AnimatorSet(); 380 mBounceAnimatorSet.playTogether(bounceAnims); 381 mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() { 382 @Override 383 public void onAnimationEnd(Animator animation) { 384 mBounceAnimatorSet = null; 385 postAnimation.run(); 386 } 387 }); 388 setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated. 389 int dx = getWidth() * lastPageNumber; 390 mScroller.startScroll(getScrollX(), getScrollY(), isLayoutRtl() ? -dx : dx, 0, 391 REVEAL_SCROLL_DURATION_MILLIS); 392 postInvalidateOnAnimation(); 393 } 394 setupBounceAnimator(View view, int ordinal)395 private static Animator setupBounceAnimator(View view, int ordinal) { 396 view.setAlpha(0f); 397 view.setScaleX(0f); 398 view.setScaleY(0f); 399 ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view, 400 PropertyValuesHolder.ofFloat(View.ALPHA, 1), 401 PropertyValuesHolder.ofFloat(View.SCALE_X, 1), 402 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1)); 403 animator.setDuration(BOUNCE_ANIMATION_DURATION); 404 animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY); 405 animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION)); 406 return animator; 407 } 408 409 private final ViewPager.OnPageChangeListener mOnPageChangeListener = 410 new ViewPager.SimpleOnPageChangeListener() { 411 @Override 412 public void onPageSelected(int position) { 413 updateSelected(); 414 if (mPageIndicator == null) return; 415 if (mPageListener != null) { 416 mPageListener.onPageChanged(isLayoutRtl() ? position == mPages.size() - 1 417 : position == 0); 418 } 419 } 420 421 @Override 422 public void onPageScrolled(int position, float positionOffset, 423 int positionOffsetPixels) { 424 if (mPageIndicator == null) return; 425 mPageIndicatorPosition = position + positionOffset; 426 mPageIndicator.setLocation(mPageIndicatorPosition); 427 if (mPageListener != null) { 428 mPageListener.onPageChanged(positionOffsetPixels == 0 && 429 (isLayoutRtl() ? position == mPages.size() - 1 : position == 0)); 430 } 431 } 432 }; 433 434 public static class TilePage extends TileLayout { 435 TilePage(Context context, AttributeSet attrs)436 public TilePage(Context context, AttributeSet attrs) { 437 super(context, attrs); 438 } 439 isFull()440 public boolean isFull() { 441 return mRecords.size() >= maxTiles(); 442 } 443 maxTiles()444 public int maxTiles() { 445 // Each page should be able to hold at least one tile. If there's not enough room to 446 // show even 1 or there are no tiles, it probably means we are in the middle of setting 447 // up. 448 return Math.max(mColumns * mRows, 1); 449 } 450 451 @Override updateResources()452 public boolean updateResources() { 453 final int sidePadding = getContext().getResources().getDimensionPixelSize( 454 R.dimen.notification_side_paddings); 455 setPadding(sidePadding, 0, sidePadding, 0); 456 return super.updateResources(); 457 } 458 } 459 460 private final PagerAdapter mAdapter = new PagerAdapter() { 461 @Override 462 public void destroyItem(ViewGroup container, int position, Object object) { 463 if (DEBUG) Log.d(TAG, "Destantiating " + position); 464 container.removeView((View) object); 465 updateListening(); 466 } 467 468 @Override 469 public Object instantiateItem(ViewGroup container, int position) { 470 if (DEBUG) Log.d(TAG, "Instantiating " + position); 471 if (isLayoutRtl()) { 472 position = mPages.size() - 1 - position; 473 } 474 ViewGroup view = mPages.get(position); 475 if (view.getParent() != null) { 476 container.removeView(view); 477 } 478 container.addView(view); 479 updateListening(); 480 return view; 481 } 482 483 @Override 484 public int getCount() { 485 return mPages.size(); 486 } 487 488 @Override 489 public boolean isViewFromObject(View view, Object object) { 490 return view == object; 491 } 492 }; 493 494 public interface PageListener { onPageChanged(boolean isFirst)495 void onPageChanged(boolean isFirst); 496 } 497 } 498