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