• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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
onConfigurationChanged(Configuration newConfig)113     protected void onConfigurationChanged(Configuration newConfig) {
114         super.onConfigurationChanged(newConfig);
115         if (mLayoutOrientation != newConfig.orientation) {
116             mLayoutOrientation = newConfig.orientation;
117             setCurrentItem(0, false);
118             mPageToRestore = 0;
119         }
120     }
121 
122     @Override
onRtlPropertiesChanged(int layoutDirection)123     public void onRtlPropertiesChanged(int layoutDirection) {
124         super.onRtlPropertiesChanged(layoutDirection);
125         if (mLayoutDirection != layoutDirection) {
126             mLayoutDirection = layoutDirection;
127             setAdapter(mAdapter);
128             setCurrentItem(0, false);
129             mPageToRestore = 0;
130         }
131     }
132 
133     @Override
setCurrentItem(int item, boolean smoothScroll)134     public void setCurrentItem(int item, boolean smoothScroll) {
135         if (isLayoutRtl()) {
136             item = mPages.size() - 1 - item;
137         }
138         super.setCurrentItem(item, smoothScroll);
139     }
140 
141     /**
142      * Obtains the current page number respecting RTL
143      */
getCurrentPageNumber()144     private int getCurrentPageNumber() {
145         int page = getCurrentItem();
146         if (mLayoutDirection == LAYOUT_DIRECTION_RTL) {
147             page = mPages.size() - 1 - page;
148         }
149         return page;
150     }
151 
152     // This will dump to the ui log all the tiles that are visible in this page
logVisibleTiles(TileLayout page)153     private void logVisibleTiles(TileLayout page) {
154         for (int i = 0; i < page.mRecords.size(); i++) {
155             QSTile t = page.mRecords.get(i).tile;
156             mUiEventLogger.logWithInstanceId(QSEvent.QS_TILE_VISIBLE, 0, t.getMetricsSpec(),
157                     t.getInstanceId());
158         }
159     }
160 
161     @Override
setListening(boolean listening, UiEventLogger uiEventLogger)162     public void setListening(boolean listening, UiEventLogger uiEventLogger) {
163         if (mListening == listening) return;
164         mListening = listening;
165         updateListening();
166     }
167 
updateListening()168     private void updateListening() {
169         for (TileLayout tilePage : mPages) {
170             tilePage.setListening(tilePage.getParent() != null && mListening);
171         }
172     }
173 
174     @Override
fakeDragBy(float xOffset)175     public void fakeDragBy(float xOffset) {
176         try {
177             super.fakeDragBy(xOffset);
178             // Keep on drawing until the animation has finished.
179             postInvalidateOnAnimation();
180         } catch (NullPointerException e) {
181             Log.e(TAG, "FakeDragBy called before begin", e);
182             // If we were trying to fake drag, it means we just added a new tile to the last
183             // page, so animate there.
184             final int lastPageNumber = mPages.size() - 1;
185             post(() -> {
186                 setCurrentItem(lastPageNumber, true);
187                 if (mBounceAnimatorSet != null) {
188                     mBounceAnimatorSet.start();
189                 }
190                 setOffscreenPageLimit(1);
191             });
192         }
193     }
194 
195     @Override
endFakeDrag()196     public void endFakeDrag() {
197         try {
198             super.endFakeDrag();
199         } catch (NullPointerException e) {
200             // Not sure what's going on. Let's log it
201             Log.e(TAG, "endFakeDrag called without velocityTracker", e);
202         }
203     }
204 
205     @Override
computeScroll()206     public void computeScroll() {
207         if (!mScroller.isFinished() && mScroller.computeScrollOffset()) {
208             if (!isFakeDragging()) {
209                 beginFakeDrag();
210             }
211             fakeDragBy(getScrollX() - mScroller.getCurrX());
212         } else if (isFakeDragging()) {
213             endFakeDrag();
214             if (mBounceAnimatorSet != null) {
215                 mBounceAnimatorSet.start();
216             }
217             setOffscreenPageLimit(1);
218         }
219         super.computeScroll();
220     }
221 
222     @Override
hasOverlappingRendering()223     public boolean hasOverlappingRendering() {
224         return false;
225     }
226 
227     @Override
onFinishInflate()228     protected void onFinishInflate() {
229         super.onFinishInflate();
230         mPages.add(createTileLayout());
231         mAdapter.notifyDataSetChanged();
232     }
233 
createTileLayout()234     private TileLayout createTileLayout() {
235         TileLayout page = (TileLayout) LayoutInflater.from(getContext())
236                 .inflate(R.layout.qs_paged_page, this, false);
237         page.setMinRows(mMinRows);
238         page.setMaxColumns(mMaxColumns);
239         return page;
240     }
241 
setPageIndicator(PageIndicator indicator)242     public void setPageIndicator(PageIndicator indicator) {
243         mPageIndicator = indicator;
244         mPageIndicator.setNumPages(mPages.size());
245         mPageIndicator.setLocation(mPageIndicatorPosition);
246     }
247 
248     @Override
getOffsetTop(TileRecord tile)249     public int getOffsetTop(TileRecord tile) {
250         final ViewGroup parent = (ViewGroup) tile.tileView.getParent();
251         if (parent == null) return 0;
252         return parent.getTop() + getTop();
253     }
254 
255     @Override
addTile(TileRecord tile)256     public void addTile(TileRecord tile) {
257         mTiles.add(tile);
258         mDistributeTiles = true;
259         requestLayout();
260     }
261 
262     @Override
removeTile(TileRecord tile)263     public void removeTile(TileRecord tile) {
264         if (mTiles.remove(tile)) {
265             mDistributeTiles = true;
266             requestLayout();
267         }
268     }
269 
270     @Override
setExpansion(float expansion, float proposedTranslation)271     public void setExpansion(float expansion, float proposedTranslation) {
272         mLastExpansion = expansion;
273         updateSelected();
274     }
275 
updateSelected()276     private void updateSelected() {
277         // Start the marquee when fully expanded and stop when fully collapsed. Leave as is for
278         // other expansion ratios since there is no way way to pause the marquee.
279         if (mLastExpansion > 0f && mLastExpansion < 1f) {
280             return;
281         }
282         boolean selected = mLastExpansion == 1f;
283 
284         // Disable accessibility temporarily while we update selected state purely for the
285         // marquee. This will ensure that accessibility doesn't announce the TYPE_VIEW_SELECTED
286         // event on any of the children.
287         setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
288         int currentItem = getCurrentPageNumber();
289         for (int i = 0; i < mPages.size(); i++) {
290             TileLayout page = mPages.get(i);
291             page.setSelected(i == currentItem ? selected : false);
292             if (page.isSelected()) {
293                 logVisibleTiles(page);
294             }
295         }
296         setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
297     }
298 
setPageListener(PageListener listener)299     public void setPageListener(PageListener listener) {
300         mPageListener = listener;
301     }
302 
distributeTiles()303     private void distributeTiles() {
304         emptyAndInflateOrRemovePages();
305 
306         final int tileCount = mPages.get(0).maxTiles();
307         if (DEBUG) Log.d(TAG, "Distributing tiles");
308         int index = 0;
309         final int NT = mTiles.size();
310         for (int i = 0; i < NT; i++) {
311             TileRecord tile = mTiles.get(i);
312             if (mPages.get(index).mRecords.size() == tileCount) index++;
313             if (DEBUG) {
314                 Log.d(TAG, "Adding " + tile.tile.getClass().getSimpleName() + " to "
315                         + index);
316             }
317             mPages.get(index).addTile(tile);
318         }
319     }
320 
emptyAndInflateOrRemovePages()321     private void emptyAndInflateOrRemovePages() {
322         final int numPages = getNumPages();
323         final int NP = mPages.size();
324         for (int i = 0; i < NP; i++) {
325             mPages.get(i).removeAllViews();
326         }
327         if (NP == numPages) {
328             return;
329         }
330         while (mPages.size() < numPages) {
331             if (DEBUG) Log.d(TAG, "Adding page");
332             mPages.add(createTileLayout());
333         }
334         while (mPages.size() > numPages) {
335             if (DEBUG) Log.d(TAG, "Removing page");
336             mPages.remove(mPages.size() - 1);
337         }
338         mPageIndicator.setNumPages(mPages.size());
339         setAdapter(mAdapter);
340         mAdapter.notifyDataSetChanged();
341         if (mPageToRestore != -1) {
342             setCurrentItem(mPageToRestore, false);
343             mPageToRestore = -1;
344         }
345     }
346 
347     @Override
updateResources()348     public boolean updateResources() {
349         boolean changed = false;
350         for (int i = 0; i < mPages.size(); i++) {
351             changed |= mPages.get(i).updateResources();
352         }
353         if (changed) {
354             mDistributeTiles = true;
355             requestLayout();
356         }
357         return changed;
358     }
359 
360     @Override
setMinRows(int minRows)361     public boolean setMinRows(int minRows) {
362         mMinRows = minRows;
363         boolean changed = false;
364         for (int i = 0; i < mPages.size(); i++) {
365             if (mPages.get(i).setMinRows(minRows)) {
366                 changed = true;
367                 mDistributeTiles = true;
368             }
369         }
370         return changed;
371     }
372 
373     @Override
setMaxColumns(int maxColumns)374     public boolean setMaxColumns(int maxColumns) {
375         mMaxColumns = maxColumns;
376         boolean changed = false;
377         for (int i = 0; i < mPages.size(); i++) {
378             if (mPages.get(i).setMaxColumns(maxColumns)) {
379                 changed = true;
380                 mDistributeTiles = true;
381             }
382         }
383         return changed;
384     }
385 
386     /**
387      * Set the amount of excess space that we gave this view compared to the actual available
388      * height. This is because this view is in a scrollview.
389      */
setExcessHeight(int excessHeight)390     public void setExcessHeight(int excessHeight) {
391         mExcessHeight = excessHeight;
392     }
393 
394     @Override
onMeasure(int widthMeasureSpec, int heightMeasureSpec)395     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
396 
397         final int nTiles = mTiles.size();
398         // If we have no reason to recalculate the number of rows, skip this step. In particular,
399         // if the height passed by its parent is the same as the last time, we try not to remeasure.
400         if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec)
401                 || mLastExcessHeight != mExcessHeight) {
402 
403             mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec);
404             mLastExcessHeight = mExcessHeight;
405             // Only change the pages if the number of rows or columns (from updateResources) has
406             // changed or the tiles have changed
407             int availableHeight = mLastMaxHeight - mExcessHeight;
408             if (mPages.get(0).updateMaxRows(availableHeight, nTiles) || mDistributeTiles) {
409                 mDistributeTiles = false;
410                 distributeTiles();
411             }
412 
413             final int nRows = mPages.get(0).mRows;
414             for (int i = 0; i < mPages.size(); i++) {
415                 TileLayout t = mPages.get(i);
416                 t.mRows = nRows;
417             }
418         }
419 
420         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
421 
422         // The ViewPager likes to eat all of the space, instead force it to wrap to the max height
423         // of the pages.
424         int maxHeight = 0;
425         final int N = getChildCount();
426         for (int i = 0; i < N; i++) {
427             int height = getChildAt(i).getMeasuredHeight();
428             if (height > maxHeight) {
429                 maxHeight = height;
430             }
431         }
432         setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom());
433     }
434 
getColumnCount()435     public int getColumnCount() {
436         if (mPages.size() == 0) return 0;
437         return mPages.get(0).mColumns;
438     }
439 
440     /**
441      * Gets the number of pages in this paged tile layout
442      */
getNumPages()443     public int getNumPages() {
444         final int nTiles = mTiles.size();
445         // We should always have at least one page, even if it's empty.
446         int numPages = Math.max(nTiles / mPages.get(0).maxTiles(), 1);
447 
448         // Add one more not full page if needed
449         if (nTiles > numPages * mPages.get(0).maxTiles()) {
450             numPages++;
451         }
452 
453         return numPages;
454     }
455 
getNumVisibleTiles()456     public int getNumVisibleTiles() {
457         if (mPages.size() == 0) return 0;
458         TileLayout currentPage = mPages.get(getCurrentPageNumber());
459         return currentPage.mRecords.size();
460     }
461 
startTileReveal(Set<String> tileSpecs, final Runnable postAnimation)462     public void startTileReveal(Set<String> tileSpecs, final Runnable postAnimation) {
463         if (tileSpecs.isEmpty() || mPages.size() < 2 || getScrollX() != 0 || !beginFakeDrag()) {
464             // Do not start the reveal animation unless there are tiles to animate, multiple
465             // TileLayouts available and the user has not already started dragging.
466             return;
467         }
468 
469         final int lastPageNumber = mPages.size() - 1;
470         final TileLayout lastPage = mPages.get(lastPageNumber);
471         final ArrayList<Animator> bounceAnims = new ArrayList<>();
472         for (TileRecord tr : lastPage.mRecords) {
473             if (tileSpecs.contains(tr.tile.getTileSpec())) {
474                 bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size()));
475             }
476         }
477 
478         if (bounceAnims.isEmpty()) {
479             // All tileSpecs are on the first page. Nothing to do.
480             // TODO: potentially show a bounce animation for first page QS tiles
481             endFakeDrag();
482             return;
483         }
484 
485         mBounceAnimatorSet = new AnimatorSet();
486         mBounceAnimatorSet.playTogether(bounceAnims);
487         mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() {
488             @Override
489             public void onAnimationEnd(Animator animation) {
490                 mBounceAnimatorSet = null;
491                 postAnimation.run();
492             }
493         });
494         setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated.
495         int dx = getWidth() * lastPageNumber;
496         mScroller.startScroll(getScrollX(), getScrollY(), isLayoutRtl() ? -dx  : dx, 0,
497             REVEAL_SCROLL_DURATION_MILLIS);
498         postInvalidateOnAnimation();
499     }
500 
setupBounceAnimator(View view, int ordinal)501     private static Animator setupBounceAnimator(View view, int ordinal) {
502         view.setAlpha(0f);
503         view.setScaleX(0f);
504         view.setScaleY(0f);
505         ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view,
506                 PropertyValuesHolder.ofFloat(View.ALPHA, 1),
507                 PropertyValuesHolder.ofFloat(View.SCALE_X, 1),
508                 PropertyValuesHolder.ofFloat(View.SCALE_Y, 1));
509         animator.setDuration(BOUNCE_ANIMATION_DURATION);
510         animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY);
511         animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION));
512         return animator;
513     }
514 
515     private final ViewPager.OnPageChangeListener mOnPageChangeListener =
516             new ViewPager.SimpleOnPageChangeListener() {
517                 @Override
518                 public void onPageSelected(int position) {
519                     updateSelected();
520                     if (mPageIndicator == null) return;
521                     if (mPageListener != null) {
522                         mPageListener.onPageChanged(isLayoutRtl() ? position == mPages.size() - 1
523                                 : position == 0);
524                     }
525                 }
526 
527                 @Override
528                 public void onPageScrolled(int position, float positionOffset,
529                         int positionOffsetPixels) {
530                     if (mPageIndicator == null) return;
531                     mPageIndicatorPosition = position + positionOffset;
532                     mPageIndicator.setLocation(mPageIndicatorPosition);
533                     if (mPageListener != null) {
534                         mPageListener.onPageChanged(positionOffsetPixels == 0 &&
535                                 (isLayoutRtl() ? position == mPages.size() - 1 : position == 0));
536                     }
537                 }
538             };
539 
540     private final PagerAdapter mAdapter = new PagerAdapter() {
541         @Override
542         public void destroyItem(ViewGroup container, int position, Object object) {
543             if (DEBUG) Log.d(TAG, "Destantiating " + position);
544             container.removeView((View) object);
545             updateListening();
546         }
547 
548         @Override
549         public Object instantiateItem(ViewGroup container, int position) {
550             if (DEBUG) Log.d(TAG, "Instantiating " + position);
551             if (isLayoutRtl()) {
552                 position = mPages.size() - 1 - position;
553             }
554             ViewGroup view = mPages.get(position);
555             if (view.getParent() != null) {
556                 container.removeView(view);
557             }
558             container.addView(view);
559             updateListening();
560             return view;
561         }
562 
563         @Override
564         public int getCount() {
565             return mPages.size();
566         }
567 
568         @Override
569         public boolean isViewFromObject(View view, Object object) {
570             return view == object;
571         }
572     };
573 
574     public interface PageListener {
onPageChanged(boolean isFirst)575         void onPageChanged(boolean isFirst);
576     }
577 }
578