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