1 package com.android.systemui.qs;
2 
3 import android.animation.Animator;
4 import android.animation.AnimatorListenerAdapter;
5 import android.animation.AnimatorSet;
6 import android.animation.ObjectAnimator;
7 import android.animation.PropertyValuesHolder;
8 import android.content.Context;
9 import android.content.res.Configuration;
10 import android.os.Bundle;
11 import android.util.AttributeSet;
12 import android.util.Log;
13 import android.view.LayoutInflater;
14 import android.view.View;
15 import android.view.ViewGroup;
16 import android.view.animation.Interpolator;
17 import android.view.animation.OvershootInterpolator;
18 import android.widget.Scroller;
19 
20 import androidx.viewpager.widget.PagerAdapter;
21 import androidx.viewpager.widget.ViewPager;
22 
23 import com.android.internal.logging.UiEventLogger;
24 import com.android.systemui.R;
25 import com.android.systemui.plugins.qs.QSTile;
26 import com.android.systemui.qs.QSPanel.QSTileLayout;
27 import com.android.systemui.qs.QSPanelControllerBase.TileRecord;
28 
29 import java.util.ArrayList;
30 import java.util.Set;
31 
32 public class PagedTileLayout extends ViewPager implements QSTileLayout {
33 
34     private static final boolean DEBUG = false;
35     private static final String CURRENT_PAGE = "current_page";
36 
37     private static final String TAG = "PagedTileLayout";
38     private static final int REVEAL_SCROLL_DURATION_MILLIS = 750;
39     private static final float BOUNCE_ANIMATION_TENSION = 1.3f;
40     private static final long BOUNCE_ANIMATION_DURATION = 450L;
41     private static final int TILE_ANIMATION_STAGGER_DELAY = 85;
42     private static final Interpolator SCROLL_CUBIC = (t) -> {
43         t -= 1.0f;
44         return t * t * t + 1.0f;
45     };
46 
47     private final ArrayList<TileRecord> mTiles = new ArrayList<>();
48     private final ArrayList<TileLayout> mPages = new ArrayList<>();
49 
50     private PageIndicator mPageIndicator;
51     private float mPageIndicatorPosition;
52 
53     private PageListener mPageListener;
54 
55     private boolean mListening;
56     private Scroller mScroller;
57 
58     private AnimatorSet mBounceAnimatorSet;
59     private float mLastExpansion;
60     private boolean mDistributeTiles = false;
61     private int mPageToRestore = -1;
62     private int mLayoutOrientation;
63     private int mLayoutDirection;
64     private final UiEventLogger mUiEventLogger = QSEvents.INSTANCE.getQsUiEventsLogger();
65     private int mExcessHeight;
66     private int mLastExcessHeight;
67     private int mMinRows = 1;
68     private int mMaxColumns = TileLayout.NO_MAX_COLUMNS;
69 
PagedTileLayout(Context context, AttributeSet attrs)70     public PagedTileLayout(Context context, AttributeSet attrs) {
71         super(context, attrs);
72         mScroller = new Scroller(context, SCROLL_CUBIC);
73         setAdapter(mAdapter);
74         setOnPageChangeListener(mOnPageChangeListener);
75         setCurrentItem(0, false);
76         mLayoutOrientation = getResources().getConfiguration().orientation;
77         mLayoutDirection = getLayoutDirection();
78     }
79     private int mLastMaxHeight = -1;
80 
81     @Override
setPageMargin(int marginPixels)82     public void setPageMargin(int marginPixels) {
83         // Using page margins creates some rounding issues that interfere with the correct position
84         // in the onPageChangedListener and therefore present bad positions to the PageIndicator.
85         // Instead, we use negative margins in the container and positive padding in the pages,
86         // matching the margin set from QSContainerImpl (note that new pages will always be inflated
87         // with the correct value.
88         // QSContainerImpl resources are set onAttachedView, so this view will always have the right
89         // values when attached.
90         MarginLayoutParams lp = (MarginLayoutParams) getLayoutParams();
91         lp.setMarginStart(-marginPixels);
92         lp.setMarginEnd(-marginPixels);
93         setLayoutParams(lp);
94 
95         int nPages = mPages.size();
96         for (int i = 0; i < nPages; i++) {
97             View v = mPages.get(i);
98             v.setPadding(marginPixels, v.getPaddingTop(), marginPixels, v.getPaddingBottom());
99         }
100     }
101 
saveInstanceState(Bundle outState)102     public void saveInstanceState(Bundle outState) {
103         outState.putInt(CURRENT_PAGE, getCurrentItem());
104     }
105 
restoreInstanceState(Bundle savedInstanceState)106     public void restoreInstanceState(Bundle savedInstanceState) {
107         // There's only 1 page at this point. We want to restore the correct page once the
108         // pages have been inflated
109         mPageToRestore = savedInstanceState.getInt(CURRENT_PAGE, -1);
110     }
111 
112     @Override
getTilesHeight()113     public int getTilesHeight() {
114         // Use the first page as that is the maximum height we need to show.
115         TileLayout tileLayout = mPages.get(0);
116         if (tileLayout == null) {
117             return 0;
118         }
119         return tileLayout.getTilesHeight();
120     }
121 
122     @Override
onConfigurationChanged(Configuration newConfig)123     protected void onConfigurationChanged(Configuration newConfig) {
124         super.onConfigurationChanged(newConfig);
125         // Pass configuration change to non-attached pages as well. Some config changes will cause
126         // QS to recreate itself (as determined in FragmentHostManager), but in order to minimize
127         // those, make sure that all get passed to all pages.
128         int numPages = mPages.size();
129         for (int i = 0; i < numPages; i++) {
130             View page = mPages.get(i);
131             if (page.getParent() == null) {
132                 page.dispatchConfigurationChanged(newConfig);
133             }
134         }
135         if (mLayoutOrientation != newConfig.orientation) {
136             mLayoutOrientation = newConfig.orientation;
137             mDistributeTiles = true;
138             setCurrentItem(0, false);
139             mPageToRestore = 0;
140         }
141     }
142 
143     @Override
onRtlPropertiesChanged(int layoutDirection)144     public void onRtlPropertiesChanged(int layoutDirection) {
145         super.onRtlPropertiesChanged(layoutDirection);
146         if (mLayoutDirection != layoutDirection) {
147             mLayoutDirection = layoutDirection;
148             setAdapter(mAdapter);
149             setCurrentItem(0, false);
150             mPageToRestore = 0;
151         }
152     }
153 
154     @Override
setCurrentItem(int item, boolean smoothScroll)155     public void setCurrentItem(int item, boolean smoothScroll) {
156         if (isLayoutRtl()) {
157             item = mPages.size() - 1 - item;
158         }
159         super.setCurrentItem(item, smoothScroll);
160     }
161 
162     /**
163      * Obtains the current page number respecting RTL
164      */
getCurrentPageNumber()165     private int getCurrentPageNumber() {
166         int page = getCurrentItem();
167         if (mLayoutDirection == LAYOUT_DIRECTION_RTL) {
168             page = mPages.size() - 1 - page;
169         }
170         return page;
171     }
172 
173     // This will dump to the ui log all the tiles that are visible in this page
logVisibleTiles(TileLayout page)174     private void logVisibleTiles(TileLayout page) {
175         for (int i = 0; i < page.mRecords.size(); i++) {
176             QSTile t = page.mRecords.get(i).tile;
177             mUiEventLogger.logWithInstanceId(QSEvent.QS_TILE_VISIBLE, 0, t.getMetricsSpec(),
178                     t.getInstanceId());
179         }
180     }
181 
182     @Override
setListening(boolean listening, UiEventLogger uiEventLogger)183     public void setListening(boolean listening, UiEventLogger uiEventLogger) {
184         if (mListening == listening) return;
185         mListening = listening;
186         updateListening();
187     }
188 
189     @Override
setSquishinessFraction(float squishinessFraction)190     public void setSquishinessFraction(float squishinessFraction) {
191         int nPages = mPages.size();
192         for (int i = 0; i < nPages; i++) {
193             mPages.get(i).setSquishinessFraction(squishinessFraction);
194         }
195     }
196 
updateListening()197     private void updateListening() {
198         for (TileLayout tilePage : mPages) {
199             tilePage.setListening(tilePage.getParent() != null && mListening);
200         }
201     }
202 
203     @Override
fakeDragBy(float xOffset)204     public void fakeDragBy(float xOffset) {
205         try {
206             super.fakeDragBy(xOffset);
207             // Keep on drawing until the animation has finished.
208             postInvalidateOnAnimation();
209         } catch (NullPointerException e) {
210             Log.e(TAG, "FakeDragBy called before begin", e);
211             // If we were trying to fake drag, it means we just added a new tile to the last
212             // page, so animate there.
213             final int lastPageNumber = mPages.size() - 1;
214             post(() -> {
215                 setCurrentItem(lastPageNumber, true);
216                 if (mBounceAnimatorSet != null) {
217                     mBounceAnimatorSet.start();
218                 }
219                 setOffscreenPageLimit(1);
220             });
221         }
222     }
223 
224     @Override
endFakeDrag()225     public void endFakeDrag() {
226         try {
227             super.endFakeDrag();
228         } catch (NullPointerException e) {
229             // Not sure what's going on. Let's log it
230             Log.e(TAG, "endFakeDrag called without velocityTracker", e);
231         }
232     }
233 
234     @Override
computeScroll()235     public void computeScroll() {
236         if (!mScroller.isFinished() && mScroller.computeScrollOffset()) {
237             if (!isFakeDragging()) {
238                 beginFakeDrag();
239             }
240             fakeDragBy(getScrollX() - mScroller.getCurrX());
241         } else if (isFakeDragging()) {
242             endFakeDrag();
243             if (mBounceAnimatorSet != null) {
244                 mBounceAnimatorSet.start();
245             }
246             setOffscreenPageLimit(1);
247         }
248         super.computeScroll();
249     }
250 
251     @Override
hasOverlappingRendering()252     public boolean hasOverlappingRendering() {
253         return false;
254     }
255 
256     @Override
onFinishInflate()257     protected void onFinishInflate() {
258         super.onFinishInflate();
259         mPages.add(createTileLayout());
260         mAdapter.notifyDataSetChanged();
261     }
262 
createTileLayout()263     private TileLayout createTileLayout() {
264         TileLayout page = (TileLayout) LayoutInflater.from(getContext())
265                 .inflate(R.layout.qs_paged_page, this, false);
266         page.setMinRows(mMinRows);
267         page.setMaxColumns(mMaxColumns);
268         return page;
269     }
270 
setPageIndicator(PageIndicator indicator)271     public void setPageIndicator(PageIndicator indicator) {
272         mPageIndicator = indicator;
273         mPageIndicator.setNumPages(mPages.size());
274         mPageIndicator.setLocation(mPageIndicatorPosition);
275     }
276 
277     @Override
getOffsetTop(TileRecord tile)278     public int getOffsetTop(TileRecord tile) {
279         final ViewGroup parent = (ViewGroup) tile.tileView.getParent();
280         if (parent == null) return 0;
281         return parent.getTop() + getTop();
282     }
283 
284     @Override
addTile(TileRecord tile)285     public void addTile(TileRecord tile) {
286         mTiles.add(tile);
287         mDistributeTiles = true;
288         requestLayout();
289     }
290 
291     @Override
removeTile(TileRecord tile)292     public void removeTile(TileRecord tile) {
293         if (mTiles.remove(tile)) {
294             mDistributeTiles = true;
295             requestLayout();
296         }
297     }
298 
299     @Override
setExpansion(float expansion, float proposedTranslation)300     public void setExpansion(float expansion, float proposedTranslation) {
301         mLastExpansion = expansion;
302         updateSelected();
303     }
304 
updateSelected()305     private void updateSelected() {
306         // Start the marquee when fully expanded and stop when fully collapsed. Leave as is for
307         // other expansion ratios since there is no way way to pause the marquee.
308         if (mLastExpansion > 0f && mLastExpansion < 1f) {
309             return;
310         }
311         boolean selected = mLastExpansion == 1f;
312 
313         // Disable accessibility temporarily while we update selected state purely for the
314         // marquee. This will ensure that accessibility doesn't announce the TYPE_VIEW_SELECTED
315         // event on any of the children.
316         setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
317         int currentItem = getCurrentPageNumber();
318         for (int i = 0; i < mPages.size(); i++) {
319             TileLayout page = mPages.get(i);
320             page.setSelected(i == currentItem ? selected : false);
321             if (page.isSelected()) {
322                 logVisibleTiles(page);
323             }
324         }
325         setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
326     }
327 
setPageListener(PageListener listener)328     public void setPageListener(PageListener listener) {
329         mPageListener = listener;
330     }
331 
distributeTiles()332     private void distributeTiles() {
333         emptyAndInflateOrRemovePages();
334 
335         final int tileCount = mPages.get(0).maxTiles();
336         if (DEBUG) Log.d(TAG, "Distributing tiles");
337         int index = 0;
338         final int NT = mTiles.size();
339         for (int i = 0; i < NT; i++) {
340             TileRecord tile = mTiles.get(i);
341             if (mPages.get(index).mRecords.size() == tileCount) index++;
342             if (DEBUG) {
343                 Log.d(TAG, "Adding " + tile.tile.getClass().getSimpleName() + " to "
344                         + index);
345             }
346             mPages.get(index).addTile(tile);
347         }
348     }
349 
emptyAndInflateOrRemovePages()350     private void emptyAndInflateOrRemovePages() {
351         final int numPages = getNumPages();
352         final int NP = mPages.size();
353         for (int i = 0; i < NP; i++) {
354             mPages.get(i).removeAllViews();
355         }
356         if (NP == numPages) {
357             return;
358         }
359         while (mPages.size() < numPages) {
360             if (DEBUG) Log.d(TAG, "Adding page");
361             mPages.add(createTileLayout());
362         }
363         while (mPages.size() > numPages) {
364             if (DEBUG) Log.d(TAG, "Removing page");
365             mPages.remove(mPages.size() - 1);
366         }
367         mPageIndicator.setNumPages(mPages.size());
368         setAdapter(mAdapter);
369         mAdapter.notifyDataSetChanged();
370         if (mPageToRestore != -1) {
371             setCurrentItem(mPageToRestore, false);
372             mPageToRestore = -1;
373         }
374     }
375 
376     @Override
updateResources()377     public boolean updateResources() {
378         boolean changed = false;
379         for (int i = 0; i < mPages.size(); i++) {
380             changed |= mPages.get(i).updateResources();
381         }
382         if (changed) {
383             mDistributeTiles = true;
384             requestLayout();
385         }
386         return changed;
387     }
388 
389     @Override
setMinRows(int minRows)390     public boolean setMinRows(int minRows) {
391         mMinRows = minRows;
392         boolean changed = false;
393         for (int i = 0; i < mPages.size(); i++) {
394             if (mPages.get(i).setMinRows(minRows)) {
395                 changed = true;
396                 mDistributeTiles = true;
397             }
398         }
399         return changed;
400     }
401 
402     @Override
setMaxColumns(int maxColumns)403     public boolean setMaxColumns(int maxColumns) {
404         mMaxColumns = maxColumns;
405         boolean changed = false;
406         for (int i = 0; i < mPages.size(); i++) {
407             if (mPages.get(i).setMaxColumns(maxColumns)) {
408                 changed = true;
409                 mDistributeTiles = true;
410             }
411         }
412         return changed;
413     }
414 
415     /**
416      * Set the amount of excess space that we gave this view compared to the actual available
417      * height. This is because this view is in a scrollview.
418      */
setExcessHeight(int excessHeight)419     public void setExcessHeight(int excessHeight) {
420         mExcessHeight = excessHeight;
421     }
422 
423     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)424     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
425 
426         final int nTiles = mTiles.size();
427         // If we have no reason to recalculate the number of rows, skip this step. In particular,
428         // if the height passed by its parent is the same as the last time, we try not to remeasure.
429         if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec)
430                 || mLastExcessHeight != mExcessHeight) {
431 
432             mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec);
433             mLastExcessHeight = mExcessHeight;
434             // Only change the pages if the number of rows or columns (from updateResources) has
435             // changed or the tiles have changed
436             int availableHeight = mLastMaxHeight - mExcessHeight;
437             if (mPages.get(0).updateMaxRows(availableHeight, nTiles) || mDistributeTiles) {
438                 mDistributeTiles = false;
439                 distributeTiles();
440             }
441 
442             final int nRows = mPages.get(0).mRows;
443             for (int i = 0; i < mPages.size(); i++) {
444                 TileLayout t = mPages.get(i);
445                 t.mRows = nRows;
446             }
447         }
448 
449         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
450 
451         // The ViewPager likes to eat all of the space, instead force it to wrap to the max height
452         // of the pages.
453         int maxHeight = 0;
454         final int N = getChildCount();
455         for (int i = 0; i < N; i++) {
456             int height = getChildAt(i).getMeasuredHeight();
457             if (height > maxHeight) {
458                 maxHeight = height;
459             }
460         }
461         setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom());
462     }
463 
getColumnCount()464     public int getColumnCount() {
465         if (mPages.size() == 0) return 0;
466         return mPages.get(0).mColumns;
467     }
468 
469     /**
470      * Gets the number of pages in this paged tile layout
471      */
getNumPages()472     public int getNumPages() {
473         final int nTiles = mTiles.size();
474         // We should always have at least one page, even if it's empty.
475         int numPages = Math.max(nTiles / mPages.get(0).maxTiles(), 1);
476 
477         // Add one more not full page if needed
478         if (nTiles > numPages * mPages.get(0).maxTiles()) {
479             numPages++;
480         }
481 
482         return numPages;
483     }
484 
getNumVisibleTiles()485     public int getNumVisibleTiles() {
486         if (mPages.size() == 0) return 0;
487         TileLayout currentPage = mPages.get(getCurrentPageNumber());
488         return currentPage.mRecords.size();
489     }
490 
startTileReveal(Set<String> tileSpecs, final Runnable postAnimation)491     public void startTileReveal(Set<String> tileSpecs, final Runnable postAnimation) {
492         if (tileSpecs.isEmpty() || mPages.size() < 2 || getScrollX() != 0 || !beginFakeDrag()) {
493             // Do not start the reveal animation unless there are tiles to animate, multiple
494             // TileLayouts available and the user has not already started dragging.
495             return;
496         }
497 
498         final int lastPageNumber = mPages.size() - 1;
499         final TileLayout lastPage = mPages.get(lastPageNumber);
500         final ArrayList<Animator> bounceAnims = new ArrayList<>();
501         for (TileRecord tr : lastPage.mRecords) {
502             if (tileSpecs.contains(tr.tile.getTileSpec())) {
503                 bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size()));
504             }
505         }
506 
507         if (bounceAnims.isEmpty()) {
508             // All tileSpecs are on the first page. Nothing to do.
509             // TODO: potentially show a bounce animation for first page QS tiles
510             endFakeDrag();
511             return;
512         }
513 
514         mBounceAnimatorSet = new AnimatorSet();
515         mBounceAnimatorSet.playTogether(bounceAnims);
516         mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() {
517             @Override
518             public void onAnimationEnd(Animator animation) {
519                 mBounceAnimatorSet = null;
520                 postAnimation.run();
521             }
522         });
523         setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated.
524         int dx = getWidth() * lastPageNumber;
525         mScroller.startScroll(getScrollX(), getScrollY(), isLayoutRtl() ? -dx  : dx, 0,
526             REVEAL_SCROLL_DURATION_MILLIS);
527         postInvalidateOnAnimation();
528     }
529 
setupBounceAnimator(View view, int ordinal)530     private static Animator setupBounceAnimator(View view, int ordinal) {
531         view.setAlpha(0f);
532         view.setScaleX(0f);
533         view.setScaleY(0f);
534         ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view,
535                 PropertyValuesHolder.ofFloat(View.ALPHA, 1),
536                 PropertyValuesHolder.ofFloat(View.SCALE_X, 1),
537                 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1));
538         animator.setDuration(BOUNCE_ANIMATION_DURATION);
539         animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY);
540         animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION));
541         return animator;
542     }
543 
544     private final ViewPager.OnPageChangeListener mOnPageChangeListener =
545             new ViewPager.SimpleOnPageChangeListener() {
546                 @Override
547                 public void onPageSelected(int position) {
548                     updateSelected();
549                     if (mPageIndicator == null) return;
550                     if (mPageListener != null) {
551                         mPageListener.onPageChanged(isLayoutRtl() ? position == mPages.size() - 1
552                                 : position == 0);
553                     }
554                 }
555 
556                 @Override
557                 public void onPageScrolled(int position, float positionOffset,
558                         int positionOffsetPixels) {
559                     if (mPageIndicator == null) return;
560                     mPageIndicatorPosition = position + positionOffset;
561                     mPageIndicator.setLocation(mPageIndicatorPosition);
562                     if (mPageListener != null) {
563                         mPageListener.onPageChanged(positionOffsetPixels == 0 &&
564                                 (isLayoutRtl() ? position == mPages.size() - 1 : position == 0));
565                     }
566                 }
567             };
568 
569     private final PagerAdapter mAdapter = new PagerAdapter() {
570         @Override
571         public void destroyItem(ViewGroup container, int position, Object object) {
572             if (DEBUG) Log.d(TAG, "Destantiating " + position);
573             container.removeView((View) object);
574             updateListening();
575         }
576 
577         @Override
578         public Object instantiateItem(ViewGroup container, int position) {
579             if (DEBUG) Log.d(TAG, "Instantiating " + position);
580             if (isLayoutRtl()) {
581                 position = mPages.size() - 1 - position;
582             }
583             ViewGroup view = mPages.get(position);
584             if (view.getParent() != null) {
585                 container.removeView(view);
586             }
587             container.addView(view);
588             updateListening();
589             return view;
590         }
591 
592         @Override
593         public int getCount() {
594             return mPages.size();
595         }
596 
597         @Override
598         public boolean isViewFromObject(View view, Object object) {
599             return view == object;
600         }
601     };
602 
603     public interface PageListener {
onPageChanged(boolean isFirst)604         void onPageChanged(boolean isFirst);
605     }
606 }
607