• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 package com.android.systemui.qs;
2 
3 import static com.android.internal.jank.InteractionJankMonitor.CUJ_NOTIFICATION_SHADE_QS_SCROLL_SWIPE;
4 import static com.android.systemui.qs.PageIndicator.PageScrollActionListener.LEFT;
5 import static com.android.systemui.qs.PageIndicator.PageScrollActionListener.RIGHT;
6 
7 import android.animation.Animator;
8 import android.animation.AnimatorListenerAdapter;
9 import android.animation.AnimatorSet;
10 import android.animation.ObjectAnimator;
11 import android.animation.PropertyValuesHolder;
12 import android.app.ActivityManager;
13 import android.content.Context;
14 import android.content.res.Configuration;
15 import android.os.Bundle;
16 import android.util.AttributeSet;
17 import android.view.InputDevice;
18 import android.view.KeyEvent;
19 import android.view.LayoutInflater;
20 import android.view.MotionEvent;
21 import android.view.View;
22 import android.view.ViewGroup;
23 import android.view.accessibility.AccessibilityEvent;
24 import android.view.accessibility.AccessibilityNodeInfo;
25 import android.view.animation.Interpolator;
26 import android.view.animation.OvershootInterpolator;
27 import android.widget.Scroller;
28 
29 import androidx.annotation.Nullable;
30 import androidx.annotation.VisibleForTesting;
31 import androidx.viewpager.widget.PagerAdapter;
32 import androidx.viewpager.widget.ViewPager;
33 
34 import com.android.internal.jank.InteractionJankMonitor;
35 import com.android.internal.logging.UiEventLogger;
36 import com.android.systemui.plugins.qs.QSTile;
37 import com.android.systemui.qs.PageIndicator.PageScrollActionListener.Direction;
38 import com.android.systemui.qs.QSPanel.QSTileLayout;
39 import com.android.systemui.qs.QSPanelControllerBase.TileRecord;
40 import com.android.systemui.qs.logging.QSLogger;
41 import com.android.systemui.res.R;
42 
43 import java.util.ArrayList;
44 import java.util.List;
45 import java.util.Set;
46 
47 public class PagedTileLayout extends ViewPager implements QSTileLayout {
48 
49     private static final String CURRENT_PAGE = "current_page";
50     private static final int NO_PAGE = -1;
51 
52     private static final int REVEAL_SCROLL_DURATION_MILLIS = 750;
53     private static final int SINGLE_PAGE_SCROLL_DURATION_MILLIS = 300;
54     private static final float BOUNCE_ANIMATION_TENSION = 1.3f;
55     private static final long BOUNCE_ANIMATION_DURATION = 450L;
56     private static final int TILE_ANIMATION_STAGGER_DELAY = 85;
57     private static final Interpolator SCROLL_CUBIC = (t) -> {
58         t -= 1.0f;
59         return t * t * t + 1.0f;
60     };
61 
62     private final ArrayList<TileRecord> mTiles = new ArrayList<>();
63     private final ArrayList<TileLayout> mPages = new ArrayList<>();
64 
65     private QSLogger mLogger;
66     @Nullable
67     private PageIndicator mPageIndicator;
68     private float mPageIndicatorPosition;
69 
70     @Nullable
71     private PageListener mPageListener;
72 
73     private boolean mListening;
74     @VisibleForTesting Scroller mScroller;
75 
76     /* set of animations used to indicate which tiles were just revealed  */
77     @Nullable
78     private AnimatorSet mBounceAnimatorSet;
79     private float mLastExpansion;
80     private boolean mDistributeTiles = false;
81     private int mPageToRestore = -1;
82     private int mLayoutOrientation;
83     private int mLayoutDirection;
84     private final UiEventLogger mUiEventLogger = QSEvents.INSTANCE.getQsUiEventsLogger();
85     private int mExcessHeight;
86     private int mLastExcessHeight;
87     private int mMinRows = 1;
88     private int mMaxColumns = TileLayout.NO_MAX_COLUMNS;
89 
90     /**
91      * it's fine to read this value when class is initialized because SysUI is always restarted
92      * when running tests in test harness, see SysUiTestIsolationRule. This check is done quite
93      * often - with every shade open action - so we don't want to potentially make it less
94      * performant only for test use case
95      */
96     private boolean mRunningInTestHarness = ActivityManager.isRunningInTestHarness();
97 
PagedTileLayout(Context context, AttributeSet attrs)98     public PagedTileLayout(Context context, AttributeSet attrs) {
99         super(context, attrs);
100         mScroller = new Scroller(context, SCROLL_CUBIC);
101         setAdapter(mAdapter);
102         setOnPageChangeListener(mOnPageChangeListener);
103         setCurrentItem(0, false);
104         mLayoutOrientation = getResources().getConfiguration().orientation;
105         mLayoutDirection = getLayoutDirection();
106     }
107     private int mLastMaxHeight = -1;
108 
setPageMargin(int marginPixelsStart, int marginPixelsEnd)109     public void setPageMargin(int marginPixelsStart, int marginPixelsEnd) {
110         // Using page margins creates some rounding issues that interfere with the correct position
111         // in the onPageChangedListener and therefore present bad positions to the PageIndicator.
112         // Instead, we use negative margins in the container and positive padding in the pages,
113         // matching the margin set from QSContainerImpl (note that new pages will always be inflated
114         // with the correct value.
115         // QSContainerImpl resources are set onAttachedView, so this view will always have the right
116         // values when attached.
117         MarginLayoutParams lp = (MarginLayoutParams) getLayoutParams();
118         lp.setMarginStart(-marginPixelsStart);
119         lp.setMarginEnd(-marginPixelsEnd);
120         setLayoutParams(lp);
121 
122         int nPages = mPages.size();
123         for (int i = 0; i < nPages; i++) {
124             View v = mPages.get(i);
125             v.setPadding(
126                     marginPixelsStart,
127                     v.getPaddingTop(),
128                     marginPixelsEnd,
129                     v.getPaddingBottom()
130             );
131         }
132     }
133 
saveInstanceState(Bundle outState)134     public void saveInstanceState(Bundle outState) {
135         int resolvedPage = mPageToRestore != NO_PAGE ? mPageToRestore : getCurrentPageNumber();
136         outState.putInt(CURRENT_PAGE, resolvedPage);
137     }
138 
restoreInstanceState(Bundle savedInstanceState)139     public void restoreInstanceState(Bundle savedInstanceState) {
140         // There's only 1 page at this point. We want to restore the correct page once the
141         // pages have been inflated
142         mPageToRestore = savedInstanceState.getInt(CURRENT_PAGE, NO_PAGE);
143     }
144 
145     @Override
getTilesHeight()146     public int getTilesHeight() {
147         // Use the first page as that is the maximum height we need to show.
148         TileLayout tileLayout = mPages.get(0);
149         if (tileLayout == null) {
150             return 0;
151         }
152         return tileLayout.getTilesHeight();
153     }
154 
155     @Override
onConfigurationChanged(Configuration newConfig)156     protected void onConfigurationChanged(Configuration newConfig) {
157         super.onConfigurationChanged(newConfig);
158         // Pass configuration change to non-attached pages as well. Some config changes will cause
159         // QS to recreate itself (as determined in FragmentHostManager), but in order to minimize
160         // those, make sure that all get passed to all pages.
161         int numPages = mPages.size();
162         for (int i = 0; i < numPages; i++) {
163             View page = mPages.get(i);
164             if (page.getParent() == null) {
165                 page.dispatchConfigurationChanged(newConfig);
166             }
167         }
168         if (mLayoutOrientation != newConfig.orientation) {
169             mLayoutOrientation = newConfig.orientation;
170             forceTilesRedistribution("orientation changed to " + mLayoutOrientation);
171             setCurrentItem(0, false);
172             mPageToRestore = 0;
173         } else {
174             // logging in case we missed redistribution because orientation was not changed
175             // while configuration changed, can be removed after b/255208946 is fixed
176             mLogger.d(
177                     "Orientation didn't change, tiles might be not redistributed, new config",
178                     newConfig);
179         }
180     }
181 
182     @Override
onRtlPropertiesChanged(int layoutDirection)183     public void onRtlPropertiesChanged(int layoutDirection) {
184         // The configuration change will change the flag in the view (that's returned in
185         // isLayoutRtl). As we detect the change, we use the cached direction to store the page
186         // before setting it.
187         final int page = getPageNumberForDirection(mLayoutDirection == LAYOUT_DIRECTION_RTL);
188         super.onRtlPropertiesChanged(layoutDirection);
189         if (mLayoutDirection != layoutDirection) {
190             mLayoutDirection = layoutDirection;
191             setAdapter(mAdapter);
192             setCurrentItem(page, false);
193         }
194     }
195 
196     @Override
onGenericMotionEvent(MotionEvent event)197     public boolean onGenericMotionEvent(MotionEvent event) {
198         if ((event.getSource() & InputDevice.SOURCE_CLASS_POINTER) != 0
199                 && event.getAction() == MotionEvent.ACTION_SCROLL) {
200             // Handle mouse (or ext. device) by swiping the page depending on the scroll
201             final float vscroll;
202             final float hscroll;
203             if ((event.getMetaState() & KeyEvent.META_SHIFT_ON) != 0) {
204                 vscroll = 0;
205                 hscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
206             } else {
207                 vscroll = -event.getAxisValue(MotionEvent.AXIS_VSCROLL);
208                 hscroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL);
209             }
210             if (hscroll != 0 || vscroll != 0) {
211                 boolean isForwardScroll =
212                         isLayoutRtl() ? (hscroll < 0 || vscroll < 0) : (hscroll > 0 || vscroll > 0);
213                 int swipeDirection = isForwardScroll ? RIGHT : LEFT;
214                 if (mScroller.isFinished()) {
215                     scrollByX(getDeltaXForPageScrolling(swipeDirection),
216                             SINGLE_PAGE_SCROLL_DURATION_MILLIS);
217                 }
218                 return true;
219             }
220         }
221         return super.onGenericMotionEvent(event);
222     }
223 
224     @Override
setCurrentItem(int item, boolean smoothScroll)225     public void setCurrentItem(int item, boolean smoothScroll) {
226         if (isLayoutRtl()) {
227             item = mPages.size() - 1 - item;
228         }
229         super.setCurrentItem(item, smoothScroll);
230     }
231 
232     /**
233      * Obtains the current page number respecting RTL
234      */
getCurrentPageNumber()235     private int getCurrentPageNumber() {
236         return getPageNumberForDirection(isLayoutRtl());
237     }
238 
getPageNumberForDirection(boolean isLayoutRTL)239     private int getPageNumberForDirection(boolean isLayoutRTL) {
240         int page = getCurrentItem();
241         if (isLayoutRTL) {
242             page = mPages.size() - 1 - page;
243         }
244         return page;
245     }
246 
247     // This will dump to the ui log all the tiles that are visible in this page
logVisibleTiles(TileLayout page)248     private void logVisibleTiles(TileLayout page) {
249         for (int i = 0; i < page.mRecords.size(); i++) {
250             QSTile t = page.mRecords.get(i).tile;
251             mUiEventLogger.logWithInstanceId(QSEvent.QS_TILE_VISIBLE, 0, t.getMetricsSpec(),
252                     t.getInstanceId());
253         }
254     }
255 
256     @Override
setListening(boolean listening, UiEventLogger uiEventLogger)257     public void setListening(boolean listening, UiEventLogger uiEventLogger) {
258         if (mListening == listening) return;
259         mListening = listening;
260         updateListening();
261     }
262 
263     @Override
setSquishinessFraction(float squishinessFraction)264     public void setSquishinessFraction(float squishinessFraction) {
265         int nPages = mPages.size();
266         for (int i = 0; i < nPages; i++) {
267             mPages.get(i).setSquishinessFraction(squishinessFraction);
268         }
269     }
270 
updateListening()271     private void updateListening() {
272         for (TileLayout tilePage : mPages) {
273             tilePage.setListening(tilePage.getParent() != null && mListening);
274         }
275     }
276 
277     @Override
fakeDragBy(float xOffset)278     public void fakeDragBy(float xOffset) {
279         try {
280             super.fakeDragBy(xOffset);
281             // Keep on drawing until the animation has finished.
282             postInvalidateOnAnimation();
283         } catch (NullPointerException e) {
284             mLogger.logException("FakeDragBy called before begin", e);
285             // If we were trying to fake drag, it means we just added a new tile to the last
286             // page, so animate there.
287             final int lastPageNumber = mPages.size() - 1;
288             post(() -> {
289                 setCurrentItem(lastPageNumber, true);
290                 if (mBounceAnimatorSet != null) {
291                     mBounceAnimatorSet.start();
292                 }
293                 setOffscreenPageLimit(1);
294             });
295         }
296     }
297 
298     @Override
endFakeDrag()299     public void endFakeDrag() {
300         try {
301             super.endFakeDrag();
302         } catch (NullPointerException e) {
303             // Not sure what's going on. Let's log it
304             mLogger.logException("endFakeDrag called without velocityTracker", e);
305         }
306     }
307 
308     @Override
computeScroll()309     public void computeScroll() {
310         if (!mScroller.isFinished() && mScroller.computeScrollOffset()) {
311             if (!isFakeDragging()) {
312                 beginFakeDrag();
313             }
314             fakeDragBy(getScrollX() - mScroller.getCurrX());
315         } else if (isFakeDragging()) {
316             endFakeDrag();
317             if (mBounceAnimatorSet != null) {
318                 mBounceAnimatorSet.start();
319             }
320             setOffscreenPageLimit(1);
321         }
322         super.computeScroll();
323     }
324 
325     @Override
hasOverlappingRendering()326     public boolean hasOverlappingRendering() {
327         return false;
328     }
329 
330     @Override
onFinishInflate()331     protected void onFinishInflate() {
332         super.onFinishInflate();
333         mPages.add(createTileLayout());
334         mAdapter.notifyDataSetChanged();
335     }
336 
createTileLayout()337     private TileLayout createTileLayout() {
338         TileLayout page = (TileLayout) LayoutInflater.from(getContext())
339                 .inflate(R.layout.qs_paged_page, this, false);
340         page.setMinRows(mMinRows);
341         page.setMaxColumns(mMaxColumns);
342         page.setSelected(false);
343 
344         // All pages should have the same squishiness, so grabbing the value from the first page
345         // and giving it to new pages.
346         float squishiness = mPages.isEmpty() ? 1f : mPages.get(0).getSquishinessFraction();
347         page.setSquishinessFraction(squishiness);
348 
349         return page;
350     }
351 
setPageIndicator(PageIndicator indicator)352     public void setPageIndicator(PageIndicator indicator) {
353         mPageIndicator = indicator;
354         mPageIndicator.setNumPages(mPages.size());
355         mPageIndicator.setLocation(mPageIndicatorPosition);
356         mPageIndicator.setPageScrollActionListener(swipeDirection -> {
357             if (mScroller.isFinished()) {
358                 scrollByX(getDeltaXForPageScrolling(swipeDirection),
359                         SINGLE_PAGE_SCROLL_DURATION_MILLIS);
360             }
361         });
362     }
363 
getDeltaXForPageScrolling(@irection int swipeDirection)364     private int getDeltaXForPageScrolling(@Direction int swipeDirection) {
365         if (swipeDirection == LEFT && getCurrentItem() != 0) {
366             return -getWidth();
367         } else if (swipeDirection == RIGHT && getCurrentItem() != mPages.size() - 1) {
368             return getWidth();
369         }
370         return 0;
371     }
372 
scrollByX(int x, int durationMillis)373     private void scrollByX(int x, int durationMillis) {
374         if (x != 0) {
375             mScroller.startScroll(/* startX= */ getScrollX(), /* startY= */ getScrollY(),
376                     /* dx= */ x, /* dy= */ 0, /* duration= */ durationMillis);
377             // scroller just sets its state, we need to invalidate view to actually start scrolling
378             postInvalidateOnAnimation();
379         }
380     }
381 
382     @Override
getOffsetTop(TileRecord tile)383     public int getOffsetTop(TileRecord tile) {
384         final ViewGroup parent = (ViewGroup) tile.tileView.getParent();
385         if (parent == null) return 0;
386         return parent.getTop() + getTop();
387     }
388 
389     @Override
addTile(TileRecord tile)390     public void addTile(TileRecord tile) {
391         mTiles.add(tile);
392         forceTilesRedistribution("adding new tile");
393         requestLayout();
394     }
395 
396     @Override
removeTile(TileRecord tile)397     public void removeTile(TileRecord tile) {
398         if (mTiles.remove(tile)) {
399             forceTilesRedistribution("removing tile");
400             requestLayout();
401         }
402     }
403 
404     @Override
setExpansion(float expansion, float proposedTranslation)405     public void setExpansion(float expansion, float proposedTranslation) {
406         mLastExpansion = expansion;
407         updateSelected();
408     }
409 
updateSelected()410     private void updateSelected() {
411         // Start the marquee when fully expanded and stop when fully collapsed. Leave as is for
412         // other expansion ratios since there is no way way to pause the marquee.
413         if (mLastExpansion > 0f && mLastExpansion < 1f) {
414             return;
415         }
416         boolean selected = mLastExpansion == 1f;
417 
418         // Disable accessibility temporarily while we update selected state purely for the
419         // marquee. This will ensure that accessibility doesn't announce the TYPE_VIEW_SELECTED
420         // event on any of the children.
421         setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
422         int currentItem = getCurrentPageNumber();
423         for (int i = 0; i < mPages.size(); i++) {
424             TileLayout page = mPages.get(i);
425             page.setSelected(i == currentItem ? selected : false);
426             if (page.isSelected()) {
427                 logVisibleTiles(page);
428             }
429         }
430         setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
431     }
432 
setPageListener(PageListener listener)433     public void setPageListener(PageListener listener) {
434         mPageListener = listener;
435     }
436 
getSpecsForPage(int page)437     public List<String> getSpecsForPage(int page) {
438         ArrayList<String> out = new ArrayList<>();
439         if (page < 0) return out;
440         int perPage = mPages.get(0).maxTiles();
441         int startOfPage = page * perPage;
442         int endOfPage = (page + 1) * perPage;
443         for (int i = startOfPage; i < endOfPage && i < mTiles.size(); i++) {
444             out.add(mTiles.get(i).tile.getTileSpec());
445         }
446         return out;
447     }
448 
distributeTiles()449     private void distributeTiles() {
450         emptyAndInflateOrRemovePages();
451 
452         final int tilesPerPageCount = mPages.get(0).maxTiles();
453         int index = 0;
454         final int totalTilesCount = mTiles.size();
455         mLogger.logTileDistributionInProgress(tilesPerPageCount, totalTilesCount);
456         for (int i = 0; i < totalTilesCount; i++) {
457             TileRecord tile = mTiles.get(i);
458             if (mPages.get(index).mRecords.size() == tilesPerPageCount) index++;
459             mLogger.logTileDistributed(tile.tile.getClass().getSimpleName(), index);
460             mPages.get(index).addTile(tile);
461         }
462     }
463 
emptyAndInflateOrRemovePages()464     private void emptyAndInflateOrRemovePages() {
465         final int numPages = getNumPages();
466         final int NP = mPages.size();
467         for (int i = 0; i < NP; i++) {
468             mPages.get(i).removeAllViews();
469         }
470         if (mPageIndicator != null) {
471             mPageIndicator.setNumPages(numPages);
472         }
473         if (NP == numPages) {
474             return;
475         }
476         while (mPages.size() < numPages) {
477             mLogger.d("Adding new page");
478             mPages.add(createTileLayout());
479         }
480         while (mPages.size() > numPages) {
481             mLogger.d("Removing page");
482             mPages.remove(mPages.size() - 1);
483         }
484         setAdapter(mAdapter);
485         mAdapter.notifyDataSetChanged();
486         if (mPageToRestore != NO_PAGE) {
487             setCurrentItem(mPageToRestore, false);
488             mPageToRestore = NO_PAGE;
489         }
490     }
491 
492     @Override
updateResources()493     public boolean updateResources() {
494         boolean changed = false;
495         for (int i = 0; i < mPages.size(); i++) {
496             changed |= mPages.get(i).updateResources();
497         }
498         if (changed) {
499             forceTilesRedistribution("resources in pages changed");
500             requestLayout();
501         } else {
502             // logging in case we missed redistribution because number of column in updateResources
503             // was not changed, can be removed after b/255208946 is fixed
504             mLogger.d("resource in pages didn't change, tiles might be not redistributed");
505         }
506         return changed;
507     }
508 
509     @Override
setMinRows(int minRows)510     public boolean setMinRows(int minRows) {
511         mMinRows = minRows;
512         boolean changed = false;
513         for (int i = 0; i < mPages.size(); i++) {
514             if (mPages.get(i).setMinRows(minRows)) {
515                 changed = true;
516                 forceTilesRedistribution("minRows changed in page");
517             }
518         }
519         return changed;
520     }
521 
522     @Override
getMinRows()523     public int getMinRows() {
524         return mMinRows;
525     }
526 
527     @Override
setMaxColumns(int maxColumns)528     public boolean setMaxColumns(int maxColumns) {
529         mMaxColumns = maxColumns;
530         boolean changed = false;
531         for (int i = 0; i < mPages.size(); i++) {
532             if (mPages.get(i).setMaxColumns(maxColumns)) {
533                 changed = true;
534                 forceTilesRedistribution("maxColumns in pages changed");
535             }
536         }
537         return changed;
538     }
539 
540     @Override
getMaxColumns()541     public int getMaxColumns() {
542         return mMaxColumns;
543     }
544 
545     /**
546      * Set the amount of excess space that we gave this view compared to the actual available
547      * height. This is because this view is in a scrollview.
548      */
setExcessHeight(int excessHeight)549     public void setExcessHeight(int excessHeight) {
550         mExcessHeight = excessHeight;
551     }
552 
553     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)554     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
555 
556         final int nTiles = mTiles.size();
557         // If we have no reason to recalculate the number of rows, skip this step. In particular,
558         // if the height passed by its parent is the same as the last time, we try not to remeasure.
559         if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec)
560                 || mLastExcessHeight != mExcessHeight) {
561 
562             mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec);
563             mLastExcessHeight = mExcessHeight;
564             // Only change the pages if the number of rows or columns (from updateResources) has
565             // changed or the tiles have changed
566             int availableHeight = mLastMaxHeight - mExcessHeight;
567             if (mPages.get(0).updateMaxRows(availableHeight, nTiles) || mDistributeTiles) {
568                 mDistributeTiles = false;
569                 distributeTiles();
570             }
571 
572             final int nRows = mPages.get(0).mRows;
573             for (int i = 0; i < mPages.size(); i++) {
574                 TileLayout t = mPages.get(i);
575                 t.mRows = nRows;
576             }
577         }
578 
579         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
580 
581         // The ViewPager likes to eat all of the space, instead force it to wrap to the max height
582         // of the pages.
583         int maxHeight = 0;
584         final int N = getChildCount();
585         for (int i = 0; i < N; i++) {
586             int height = getChildAt(i).getMeasuredHeight();
587             if (height > maxHeight) {
588                 maxHeight = height;
589             }
590         }
591         if (mPages.get(0).getParent() == null) {
592             // Measure page 0 so we know how tall it is if it's not attached to the pager.
593             mPages.get(0).measure(widthMeasureSpec, heightMeasureSpec);
594             int height = mPages.get(0).getMeasuredHeight();
595             if (height > maxHeight) {
596                 maxHeight = height;
597             }
598         }
599         setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom());
600     }
601 
602     @Override
onLayout(boolean changed, int l, int t, int r, int b)603     protected void onLayout(boolean changed, int l, int t, int r, int b) {
604         super.onLayout(changed, l, t, r, b);
605         if (mPages.get(0).getParent() == null) {
606             // Layout page 0, so we can get the bottom of the tiles. We only do this if the page
607             // is not attached.
608             mPages.get(0).layout(l, t, r, b);
609         }
610     }
611 
getColumnCount()612     public int getColumnCount() {
613         if (mPages.size() == 0) return 0;
614         return mPages.get(0).mColumns;
615     }
616 
617     /**
618      * Gets the number of pages in this paged tile layout
619      */
getNumPages()620     public int getNumPages() {
621         final int nTiles = mTiles.size();
622         // We should always have at least one page, even if it's empty.
623         int numPages = Math.max(nTiles / mPages.get(0).maxTiles(), 1);
624 
625         // Add one more not full page if needed
626         if (nTiles > numPages * mPages.get(0).maxTiles()) {
627             numPages++;
628         }
629 
630         return numPages;
631     }
632 
getNumVisibleTiles()633     public int getNumVisibleTiles() {
634         if (mPages.size() == 0) return 0;
635         TileLayout currentPage = mPages.get(getCurrentPageNumber());
636         return currentPage.mRecords.size();
637     }
638 
getNumTilesFirstPage()639     public int getNumTilesFirstPage() {
640         if (mPages.size() == 0) return 0;
641         return mPages.get(0).mRecords.size();
642     }
643 
startTileReveal(Set<String> tilesToReveal, final Runnable postAnimation)644     public void startTileReveal(Set<String> tilesToReveal, final Runnable postAnimation) {
645         if (shouldNotRunAnimation(tilesToReveal)) {
646             return;
647         }
648         // This method has side effects (beings the fake drag, if it returns true). If we have
649         // decided that we want to do a tile reveal, we do a last check to verify that we can
650         // actually perform a fake drag.
651         if (!beginFakeDrag()) {
652             return;
653         }
654 
655         final int lastPageNumber = mPages.size() - 1;
656         final TileLayout lastPage = mPages.get(lastPageNumber);
657         final ArrayList<Animator> bounceAnims = new ArrayList<>();
658         for (TileRecord tr : lastPage.mRecords) {
659             if (tilesToReveal.contains(tr.tile.getTileSpec())) {
660                 bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size()));
661             }
662         }
663 
664         if (bounceAnims.isEmpty()) {
665             // All tilesToReveal are on the first page. Nothing to do.
666             // TODO: potentially show a bounce animation for first page QS tiles
667             endFakeDrag();
668             return;
669         }
670 
671         mBounceAnimatorSet = new AnimatorSet();
672         mBounceAnimatorSet.playTogether(bounceAnims);
673         mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() {
674             @Override
675             public void onAnimationEnd(Animator animation) {
676                 mBounceAnimatorSet = null;
677                 postAnimation.run();
678             }
679         });
680         setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated.
681         int dx = getWidth() * lastPageNumber;
682         scrollByX(isLayoutRtl() ? -dx : dx, REVEAL_SCROLL_DURATION_MILLIS);
683     }
684 
shouldNotRunAnimation(Set<String> tilesToReveal)685     private boolean shouldNotRunAnimation(Set<String> tilesToReveal) {
686         // None of these have side effects. That way, we don't need to rely on short-circuiting
687         // behavior
688         boolean noAnimationNeeded = tilesToReveal.isEmpty() || mPages.size() < 2;
689         boolean scrollingInProgress = getScrollX() != 0 || !isFakeDragging();
690         // checking mRunningInTestHarness to disable animation in functional testing as it caused
691         // flakiness and is not needed there. Alternative solutions were more complex and would
692         // still be either potentially flaky or modify internal data.
693         // For more info see b/253493927 and b/293234595
694         return noAnimationNeeded || scrollingInProgress || mRunningInTestHarness;
695     }
696 
697     private int sanitizePageAction(int action) {
698         int pageLeftId = AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_LEFT.getId();
699         int pageRightId = AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_RIGHT.getId();
700         if (action == pageLeftId || action == pageRightId) {
701             if (!isLayoutRtl()) {
702                 if (action == pageLeftId) {
703                     return AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD;
704                 } else {
705                     return AccessibilityNodeInfo.ACTION_SCROLL_FORWARD;
706                 }
707             } else {
708                 if (action == pageLeftId) {
709                     return AccessibilityNodeInfo.ACTION_SCROLL_FORWARD;
710                 } else {
711                     return AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD;
712                 }
713             }
714         }
715         return action;
716     }
717 
718     @Override
719     public boolean performAccessibilityAction(int action, Bundle arguments) {
720         action = sanitizePageAction(action);
721         boolean performed = super.performAccessibilityAction(action, arguments);
722         if (performed && (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
723                 || action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)) {
724             requestAccessibilityFocus();
725         }
726         return performed;
727     }
728 
729     @Override
730     public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
731         super.onInitializeAccessibilityNodeInfoInternal(info);
732         // getCurrentItem does not respect RTL, so it works well together with page actions that
733         // use left/right positioning.
734         if (getCurrentItem() != 0) {
735             info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_LEFT);
736         }
737         if (getCurrentItem() != mPages.size() - 1) {
738             info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_PAGE_RIGHT);
739         }
740     }
741 
742     @Override
743     public void onInitializeAccessibilityEvent(AccessibilityEvent event) {
744         super.onInitializeAccessibilityEvent(event);
745         if (mAdapter != null && mAdapter.getCount() > 0) {
746             event.setItemCount(mAdapter.getCount());
747             event.setFromIndex(getCurrentPageNumber());
748             event.setToIndex(getCurrentPageNumber());
749         }
750     }
751 
752     private static Animator setupBounceAnimator(View view, int ordinal) {
753         view.setAlpha(0f);
754         view.setScaleX(0f);
755         view.setScaleY(0f);
756         ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view,
757                 PropertyValuesHolder.ofFloat(View.ALPHA, 1),
758                 PropertyValuesHolder.ofFloat(View.SCALE_X, 1),
759                 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1));
760         animator.setDuration(BOUNCE_ANIMATION_DURATION);
761         animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY);
762         animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION));
763         return animator;
764     }
765 
766     private final ViewPager.OnPageChangeListener mOnPageChangeListener =
767             new ViewPager.SimpleOnPageChangeListener() {
768 
769                 private int mCurrentScrollState = SCROLL_STATE_IDLE;
770                 // Flag to avoid redundant call InteractionJankMonitor::begin()
771                 private boolean mIsScrollJankTraceBegin = false;
772 
773                 @Override
774                 public void onPageSelected(int position) {
775                     updateSelected();
776                     if (mPageIndicator == null) return;
777                     if (mPageListener != null) {
778                         int pageNumber = isLayoutRtl() ? mPages.size() - 1 - position : position;
779                         mPageListener.onPageChanged(pageNumber == 0, pageNumber);
780                     }
781                 }
782 
783                 @Override
784                 public void onPageScrolled(int position, float positionOffset,
785                         int positionOffsetPixels) {
786 
787                     if (!mIsScrollJankTraceBegin && mCurrentScrollState == SCROLL_STATE_DRAGGING) {
788                         InteractionJankMonitor.getInstance().begin(PagedTileLayout.this,
789                                 CUJ_NOTIFICATION_SHADE_QS_SCROLL_SWIPE);
790                         mIsScrollJankTraceBegin = true;
791                     }
792 
793                     if (mPageIndicator == null) return;
794                     mPageIndicatorPosition = position + positionOffset;
795                     mPageIndicator.setLocation(mPageIndicatorPosition);
796                     if (mPageListener != null) {
797                         int pageNumber = isLayoutRtl() ? mPages.size() - 1 - position : position;
798                         mPageListener.onPageChanged(
799                                 positionOffsetPixels == 0 && pageNumber == 0,
800                                 // Send only valid page number on integer pages
801                                 positionOffsetPixels == 0 ? pageNumber : PageListener.INVALID_PAGE
802                         );
803                     }
804                 }
805 
806                 @Override
807                 public void onPageScrollStateChanged(int state) {
808                     if (state != mCurrentScrollState && state == SCROLL_STATE_IDLE) {
809                         InteractionJankMonitor.getInstance().end(
810                                 CUJ_NOTIFICATION_SHADE_QS_SCROLL_SWIPE);
811                         mIsScrollJankTraceBegin = false;
812                     }
813                     mCurrentScrollState = state;
814                 }
815             };
816 
817     private final PagerAdapter mAdapter = new PagerAdapter() {
818         @Override
819         public void destroyItem(ViewGroup container, int position, Object object) {
820             mLogger.d("Destantiating page at", position);
821             container.removeView((View) object);
822             updateListening();
823         }
824 
825         @Override
826         public Object instantiateItem(ViewGroup container, int position) {
827             mLogger.d("Instantiating page at", position);
828             if (isLayoutRtl()) {
829                 position = mPages.size() - 1 - position;
830             }
831             ViewGroup view = mPages.get(position);
832             if (view.getParent() != null) {
833                 container.removeView(view);
834             }
835             container.addView(view);
836             updateListening();
837             return view;
838         }
839 
840         @Override
841         public int getCount() {
842             return mPages.size();
843         }
844 
845         @Override
846         public boolean isViewFromObject(View view, Object object) {
847             return view == object;
848         }
849     };
850 
851     /**
852      * Force all tiles to be redistributed across pages.
853      * Should be called when one of the following changes: rows, columns, number of tiles.
854      */
forceTilesRedistribution(String reason)855     public void forceTilesRedistribution(String reason) {
856         mLogger.d("forcing tile redistribution across pages, reason", reason);
857         mDistributeTiles = true;
858     }
859 
setLogger(QSLogger qsLogger)860     public void setLogger(QSLogger qsLogger) {
861         mLogger = qsLogger;
862     }
863 
864     public interface PageListener {
865         int INVALID_PAGE = -1;
866 
867         void onPageChanged(boolean isFirst, int pageNumber);
868     }
869 }
870