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