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 numPages += (nTiles % mPages.get(0).maxTiles() == 0 ? 0 : 1); 252 253 final int NP = mPages.size(); 254 for (int i = 0; i < NP; i++) { 255 mPages.get(i).removeAllViews(); 256 } 257 if (NP == numPages) { 258 return; 259 } 260 while (mPages.size() < numPages) { 261 if (DEBUG) Log.d(TAG, "Adding page"); 262 mPages.add((TilePage) LayoutInflater.from(getContext()) 263 .inflate(R.layout.qs_paged_page, this, false)); 264 } 265 while (mPages.size() > numPages) { 266 if (DEBUG) Log.d(TAG, "Removing page"); 267 mPages.remove(mPages.size() - 1); 268 } 269 mPageIndicator.setNumPages(mPages.size()); 270 setAdapter(mAdapter); 271 mAdapter.notifyDataSetChanged(); 272 if (mPageToRestore != -1) { 273 setCurrentItem(mPageToRestore, false); 274 mPageToRestore = -1; 275 } 276 } 277 278 @Override updateResources()279 public boolean updateResources() { 280 // Update bottom padding, useful for removing extra space once the panel page indicator is 281 // hidden. 282 Resources res = getContext().getResources(); 283 mHorizontalClipBound = res.getDimensionPixelSize(R.dimen.notification_side_paddings); 284 setPadding(0, 0, 0, 285 getContext().getResources().getDimensionPixelSize( 286 R.dimen.qs_paged_tile_layout_padding_bottom)); 287 boolean changed = false; 288 for (int i = 0; i < mPages.size(); i++) { 289 changed |= mPages.get(i).updateResources(); 290 } 291 if (changed) { 292 mDistributeTiles = true; 293 requestLayout(); 294 } 295 return changed; 296 } 297 298 @Override onLayout(boolean changed, int l, int t, int r, int b)299 protected void onLayout(boolean changed, int l, int t, int r, int b) { 300 super.onLayout(changed, l, t, r, b); 301 mClippingRect.set(mHorizontalClipBound, 0, (r - l) - mHorizontalClipBound, b - t); 302 setClipBounds(mClippingRect); 303 } 304 305 @Override onMeasure(int widthMeasureSpec, int heightMeasureSpec)306 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 307 308 final int nTiles = mTiles.size(); 309 // If we have no reason to recalculate the number of rows, skip this step. In particular, 310 // if the height passed by its parent is the same as the last time, we try not to remeasure. 311 if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec)) { 312 313 mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec); 314 // Only change the pages if the number of rows or columns (from updateResources) has 315 // changed or the tiles have changed 316 if (mPages.get(0).updateMaxRows(heightMeasureSpec, nTiles) || mDistributeTiles) { 317 mDistributeTiles = false; 318 distributeTiles(); 319 } 320 321 final int nRows = mPages.get(0).mRows; 322 for (int i = 0; i < mPages.size(); i++) { 323 TilePage t = mPages.get(i); 324 t.mRows = nRows; 325 } 326 } 327 328 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 329 330 // The ViewPager likes to eat all of the space, instead force it to wrap to the max height 331 // of the pages. 332 int maxHeight = 0; 333 final int N = getChildCount(); 334 for (int i = 0; i < N; i++) { 335 int height = getChildAt(i).getMeasuredHeight(); 336 if (height > maxHeight) { 337 maxHeight = height; 338 } 339 } 340 setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom()); 341 } 342 getColumnCount()343 public int getColumnCount() { 344 if (mPages.size() == 0) return 0; 345 return mPages.get(0).mColumns; 346 } 347 getNumVisibleTiles()348 public int getNumVisibleTiles() { 349 if (mPages.size() == 0) return 0; 350 TilePage currentPage = mPages.get(getCurrentPageNumber()); 351 return currentPage.mRecords.size(); 352 } 353 startTileReveal(Set<String> tileSpecs, final Runnable postAnimation)354 public void startTileReveal(Set<String> tileSpecs, final Runnable postAnimation) { 355 if (tileSpecs.isEmpty() || mPages.size() < 2 || getScrollX() != 0 || !beginFakeDrag()) { 356 // Do not start the reveal animation unless there are tiles to animate, multiple 357 // TilePages available and the user has not already started dragging. 358 return; 359 } 360 361 final int lastPageNumber = mPages.size() - 1; 362 final TilePage lastPage = mPages.get(lastPageNumber); 363 final ArrayList<Animator> bounceAnims = new ArrayList<>(); 364 for (TileRecord tr : lastPage.mRecords) { 365 if (tileSpecs.contains(tr.tile.getTileSpec())) { 366 bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size())); 367 } 368 } 369 370 if (bounceAnims.isEmpty()) { 371 // All tileSpecs are on the first page. Nothing to do. 372 // TODO: potentially show a bounce animation for first page QS tiles 373 endFakeDrag(); 374 return; 375 } 376 377 mBounceAnimatorSet = new AnimatorSet(); 378 mBounceAnimatorSet.playTogether(bounceAnims); 379 mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() { 380 @Override 381 public void onAnimationEnd(Animator animation) { 382 mBounceAnimatorSet = null; 383 postAnimation.run(); 384 } 385 }); 386 setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated. 387 int dx = getWidth() * lastPageNumber; 388 mScroller.startScroll(getScrollX(), getScrollY(), isLayoutRtl() ? -dx : dx, 0, 389 REVEAL_SCROLL_DURATION_MILLIS); 390 postInvalidateOnAnimation(); 391 } 392 setupBounceAnimator(View view, int ordinal)393 private static Animator setupBounceAnimator(View view, int ordinal) { 394 view.setAlpha(0f); 395 view.setScaleX(0f); 396 view.setScaleY(0f); 397 ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view, 398 PropertyValuesHolder.ofFloat(View.ALPHA, 1), 399 PropertyValuesHolder.ofFloat(View.SCALE_X, 1), 400 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1)); 401 animator.setDuration(BOUNCE_ANIMATION_DURATION); 402 animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY); 403 animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION)); 404 return animator; 405 } 406 407 private final ViewPager.OnPageChangeListener mOnPageChangeListener = 408 new ViewPager.SimpleOnPageChangeListener() { 409 @Override 410 public void onPageSelected(int position) { 411 updateSelected(); 412 if (mPageIndicator == null) return; 413 if (mPageListener != null) { 414 mPageListener.onPageChanged(isLayoutRtl() ? position == mPages.size() - 1 415 : position == 0); 416 } 417 } 418 419 @Override 420 public void onPageScrolled(int position, float positionOffset, 421 int positionOffsetPixels) { 422 if (mPageIndicator == null) return; 423 mPageIndicatorPosition = position + positionOffset; 424 mPageIndicator.setLocation(mPageIndicatorPosition); 425 if (mPageListener != null) { 426 mPageListener.onPageChanged(positionOffsetPixels == 0 && 427 (isLayoutRtl() ? position == mPages.size() - 1 : position == 0)); 428 } 429 } 430 }; 431 432 public static class TilePage extends TileLayout { 433 TilePage(Context context, AttributeSet attrs)434 public TilePage(Context context, AttributeSet attrs) { 435 super(context, attrs); 436 } 437 isFull()438 public boolean isFull() { 439 return mRecords.size() >= maxTiles(); 440 } 441 maxTiles()442 public int maxTiles() { 443 // Each page should be able to hold at least one tile. If there's not enough room to 444 // show even 1 or there are no tiles, it probably means we are in the middle of setting 445 // up. 446 return Math.max(mColumns * mRows, 1); 447 } 448 449 @Override updateResources()450 public boolean updateResources() { 451 final int sidePadding = getContext().getResources().getDimensionPixelSize( 452 R.dimen.notification_side_paddings); 453 setPadding(sidePadding, 0, sidePadding, 0); 454 return super.updateResources(); 455 } 456 } 457 458 private final PagerAdapter mAdapter = new PagerAdapter() { 459 @Override 460 public void destroyItem(ViewGroup container, int position, Object object) { 461 if (DEBUG) Log.d(TAG, "Destantiating " + position); 462 container.removeView((View) object); 463 updateListening(); 464 } 465 466 @Override 467 public Object instantiateItem(ViewGroup container, int position) { 468 if (DEBUG) Log.d(TAG, "Instantiating " + position); 469 if (isLayoutRtl()) { 470 position = mPages.size() - 1 - position; 471 } 472 ViewGroup view = mPages.get(position); 473 if (view.getParent() != null) { 474 container.removeView(view); 475 } 476 container.addView(view); 477 updateListening(); 478 return view; 479 } 480 481 @Override 482 public int getCount() { 483 return mPages.size(); 484 } 485 486 @Override 487 public boolean isViewFromObject(View view, Object object) { 488 return view == object; 489 } 490 }; 491 492 public interface PageListener { onPageChanged(boolean isFirst)493 void onPageChanged(boolean isFirst); 494 } 495 } 496