blob: 991d9fa549ac4999f05678770ac2b7083f17b44b [file] [log] [blame]
package com.android.systemui.qs;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.PropertyValuesHolder;
import android.content.Context;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
import android.os.Bundle;
import android.util.AttributeSet;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Interpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.Scroller;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import com.android.systemui.R;
import com.android.systemui.qs.QSPanel.QSTileLayout;
import com.android.systemui.qs.QSPanel.TileRecord;
import java.util.ArrayList;
import java.util.Set;
public class PagedTileLayout extends ViewPager implements QSTileLayout {
private static final boolean DEBUG = false;
private static final String CURRENT_PAGE = "current_page";
private static final String TAG = "PagedTileLayout";
private static final int REVEAL_SCROLL_DURATION_MILLIS = 750;
private static final float BOUNCE_ANIMATION_TENSION = 1.3f;
private static final long BOUNCE_ANIMATION_DURATION = 450L;
private static final int TILE_ANIMATION_STAGGER_DELAY = 85;
private static final Interpolator SCROLL_CUBIC = (t) -> {
t -= 1.0f;
return t * t * t + 1.0f;
};
private final ArrayList<TileRecord> mTiles = new ArrayList<>();
private final ArrayList<TilePage> mPages = new ArrayList<>();
private PageIndicator mPageIndicator;
private float mPageIndicatorPosition;
private PageListener mPageListener;
private boolean mListening;
private Scroller mScroller;
private AnimatorSet mBounceAnimatorSet;
private float mLastExpansion;
private boolean mDistributeTiles = false;
private int mPageToRestore = -1;
private int mLayoutOrientation;
private int mLayoutDirection;
private int mHorizontalClipBound;
private final Rect mClippingRect;
private int mLastMaxHeight = -1;
public PagedTileLayout(Context context, AttributeSet attrs) {
super(context, attrs);
mScroller = new Scroller(context, SCROLL_CUBIC);
setAdapter(mAdapter);
setOnPageChangeListener(mOnPageChangeListener);
setCurrentItem(0, false);
mLayoutOrientation = getResources().getConfiguration().orientation;
mLayoutDirection = getLayoutDirection();
mClippingRect = new Rect();
}
public void saveInstanceState(Bundle outState) {
outState.putInt(CURRENT_PAGE, getCurrentItem());
}
public void restoreInstanceState(Bundle savedInstanceState) {
// There's only 1 page at this point. We want to restore the correct page once the
// pages have been inflated
mPageToRestore = savedInstanceState.getInt(CURRENT_PAGE, -1);
}
@Override
protected void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (mLayoutOrientation != newConfig.orientation) {
mLayoutOrientation = newConfig.orientation;
setCurrentItem(0, false);
mPageToRestore = 0;
}
}
@Override
public void onRtlPropertiesChanged(int layoutDirection) {
super.onRtlPropertiesChanged(layoutDirection);
if (mLayoutDirection != layoutDirection) {
mLayoutDirection = layoutDirection;
setAdapter(mAdapter);
setCurrentItem(0, false);
mPageToRestore = 0;
}
}
@Override
public void setCurrentItem(int item, boolean smoothScroll) {
if (isLayoutRtl()) {
item = mPages.size() - 1 - item;
}
super.setCurrentItem(item, smoothScroll);
}
/**
* Obtains the current page number respecting RTL
*/
private int getCurrentPageNumber() {
int page = getCurrentItem();
if (mLayoutDirection == LAYOUT_DIRECTION_RTL) {
page = mPages.size() - 1 - page;
}
return page;
}
@Override
public void setListening(boolean listening) {
if (mListening == listening) return;
mListening = listening;
updateListening();
}
private void updateListening() {
for (TilePage tilePage : mPages) {
tilePage.setListening(tilePage.getParent() == null ? false : mListening);
}
}
@Override
public void computeScroll() {
if (!mScroller.isFinished() && mScroller.computeScrollOffset()) {
fakeDragBy(getScrollX() - mScroller.getCurrX());
// Keep on drawing until the animation has finished.
postInvalidateOnAnimation();
return;
} else if (isFakeDragging()) {
endFakeDrag();
mBounceAnimatorSet.start();
setOffscreenPageLimit(1);
}
super.computeScroll();
}
@Override
public boolean hasOverlappingRendering() {
return false;
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
mPages.add((TilePage) LayoutInflater.from(getContext())
.inflate(R.layout.qs_paged_page, this, false));
mAdapter.notifyDataSetChanged();
}
public void setPageIndicator(PageIndicator indicator) {
mPageIndicator = indicator;
mPageIndicator.setNumPages(mPages.size());
mPageIndicator.setLocation(mPageIndicatorPosition);
}
@Override
public int getOffsetTop(TileRecord tile) {
final ViewGroup parent = (ViewGroup) tile.tileView.getParent();
if (parent == null) return 0;
return parent.getTop() + getTop();
}
@Override
public void addTile(TileRecord tile) {
mTiles.add(tile);
mDistributeTiles = true;
requestLayout();
}
@Override
public void removeTile(TileRecord tile) {
if (mTiles.remove(tile)) {
mDistributeTiles = true;
requestLayout();
}
}
@Override
public void setExpansion(float expansion) {
mLastExpansion = expansion;
updateSelected();
}
private void updateSelected() {
// Start the marquee when fully expanded and stop when fully collapsed. Leave as is for
// other expansion ratios since there is no way way to pause the marquee.
if (mLastExpansion > 0f && mLastExpansion < 1f) {
return;
}
boolean selected = mLastExpansion == 1f;
// Disable accessibility temporarily while we update selected state purely for the
// marquee. This will ensure that accessibility doesn't announce the TYPE_VIEW_SELECTED
// event on any of the children.
setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
int currentItem = getCurrentPageNumber();
for (int i = 0; i < mPages.size(); i++) {
mPages.get(i).setSelected(i == currentItem ? selected : false);
}
setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_AUTO);
}
public void setPageListener(PageListener listener) {
mPageListener = listener;
}
private void distributeTiles() {
emptyAndInflateOrRemovePages();
final int tileCount = mPages.get(0).maxTiles();
if (DEBUG) Log.d(TAG, "Distributing tiles");
int index = 0;
final int NT = mTiles.size();
for (int i = 0; i < NT; i++) {
TileRecord tile = mTiles.get(i);
if (mPages.get(index).mRecords.size() == tileCount) index++;
if (DEBUG) {
Log.d(TAG, "Adding " + tile.tile.getClass().getSimpleName() + " to "
+ index);
}
mPages.get(index).addTile(tile);
}
}
private void emptyAndInflateOrRemovePages() {
final int nTiles = mTiles.size();
// We should always have at least one page, even if it's empty.
int numPages = Math.max(nTiles / mPages.get(0).maxTiles(), 1);
// Add one more not full page if needed
numPages += (nTiles % mPages.get(0).maxTiles() == 0 ? 0 : 1);
final int NP = mPages.size();
for (int i = 0; i < NP; i++) {
mPages.get(i).removeAllViews();
}
if (NP == numPages) {
return;
}
while (mPages.size() < numPages) {
if (DEBUG) Log.d(TAG, "Adding page");
mPages.add((TilePage) LayoutInflater.from(getContext())
.inflate(R.layout.qs_paged_page, this, false));
}
while (mPages.size() > numPages) {
if (DEBUG) Log.d(TAG, "Removing page");
mPages.remove(mPages.size() - 1);
}
mPageIndicator.setNumPages(mPages.size());
setAdapter(mAdapter);
mAdapter.notifyDataSetChanged();
if (mPageToRestore != -1) {
setCurrentItem(mPageToRestore, false);
mPageToRestore = -1;
}
}
@Override
public boolean updateResources() {
// Update bottom padding, useful for removing extra space once the panel page indicator is
// hidden.
Resources res = getContext().getResources();
mHorizontalClipBound = res.getDimensionPixelSize(R.dimen.notification_side_paddings);
setPadding(0, 0, 0,
getContext().getResources().getDimensionPixelSize(
R.dimen.qs_paged_tile_layout_padding_bottom));
boolean changed = false;
for (int i = 0; i < mPages.size(); i++) {
changed |= mPages.get(i).updateResources();
}
if (changed) {
mDistributeTiles = true;
requestLayout();
}
return changed;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
mClippingRect.set(mHorizontalClipBound, 0, (r - l) - mHorizontalClipBound, b - t);
setClipBounds(mClippingRect);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
final int nTiles = mTiles.size();
// If we have no reason to recalculate the number of rows, skip this step. In particular,
// if the height passed by its parent is the same as the last time, we try not to remeasure.
if (mDistributeTiles || mLastMaxHeight != MeasureSpec.getSize(heightMeasureSpec)) {
mLastMaxHeight = MeasureSpec.getSize(heightMeasureSpec);
// Only change the pages if the number of rows or columns (from updateResources) has
// changed or the tiles have changed
if (mPages.get(0).updateMaxRows(heightMeasureSpec, nTiles) || mDistributeTiles) {
mDistributeTiles = false;
distributeTiles();
}
final int nRows = mPages.get(0).mRows;
for (int i = 0; i < mPages.size(); i++) {
TilePage t = mPages.get(i);
t.mRows = nRows;
}
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// The ViewPager likes to eat all of the space, instead force it to wrap to the max height
// of the pages.
int maxHeight = 0;
final int N = getChildCount();
for (int i = 0; i < N; i++) {
int height = getChildAt(i).getMeasuredHeight();
if (height > maxHeight) {
maxHeight = height;
}
}
setMeasuredDimension(getMeasuredWidth(), maxHeight + getPaddingBottom());
}
public int getColumnCount() {
if (mPages.size() == 0) return 0;
return mPages.get(0).mColumns;
}
public int getNumVisibleTiles() {
if (mPages.size() == 0) return 0;
TilePage currentPage = mPages.get(getCurrentPageNumber());
return currentPage.mRecords.size();
}
public void startTileReveal(Set<String> tileSpecs, final Runnable postAnimation) {
if (tileSpecs.isEmpty() || mPages.size() < 2 || getScrollX() != 0 || !beginFakeDrag()) {
// Do not start the reveal animation unless there are tiles to animate, multiple
// TilePages available and the user has not already started dragging.
return;
}
final int lastPageNumber = mPages.size() - 1;
final TilePage lastPage = mPages.get(lastPageNumber);
final ArrayList<Animator> bounceAnims = new ArrayList<>();
for (TileRecord tr : lastPage.mRecords) {
if (tileSpecs.contains(tr.tile.getTileSpec())) {
bounceAnims.add(setupBounceAnimator(tr.tileView, bounceAnims.size()));
}
}
if (bounceAnims.isEmpty()) {
// All tileSpecs are on the first page. Nothing to do.
// TODO: potentially show a bounce animation for first page QS tiles
endFakeDrag();
return;
}
mBounceAnimatorSet = new AnimatorSet();
mBounceAnimatorSet.playTogether(bounceAnims);
mBounceAnimatorSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
mBounceAnimatorSet = null;
postAnimation.run();
}
});
setOffscreenPageLimit(lastPageNumber); // Ensure the page to reveal has been inflated.
int dx = getWidth() * lastPageNumber;
mScroller.startScroll(getScrollX(), getScrollY(), isLayoutRtl() ? -dx : dx, 0,
REVEAL_SCROLL_DURATION_MILLIS);
postInvalidateOnAnimation();
}
private static Animator setupBounceAnimator(View view, int ordinal) {
view.setAlpha(0f);
view.setScaleX(0f);
view.setScaleY(0f);
ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder(view,
PropertyValuesHolder.ofFloat(View.ALPHA, 1),
PropertyValuesHolder.ofFloat(View.SCALE_X, 1),
PropertyValuesHolder.ofFloat(View.SCALE_Y, 1));
animator.setDuration(BOUNCE_ANIMATION_DURATION);
animator.setStartDelay(ordinal * TILE_ANIMATION_STAGGER_DELAY);
animator.setInterpolator(new OvershootInterpolator(BOUNCE_ANIMATION_TENSION));
return animator;
}
private final ViewPager.OnPageChangeListener mOnPageChangeListener =
new ViewPager.SimpleOnPageChangeListener() {
@Override
public void onPageSelected(int position) {
updateSelected();
if (mPageIndicator == null) return;
if (mPageListener != null) {
mPageListener.onPageChanged(isLayoutRtl() ? position == mPages.size() - 1
: position == 0);
}
}
@Override
public void onPageScrolled(int position, float positionOffset,
int positionOffsetPixels) {
if (mPageIndicator == null) return;
mPageIndicatorPosition = position + positionOffset;
mPageIndicator.setLocation(mPageIndicatorPosition);
if (mPageListener != null) {
mPageListener.onPageChanged(positionOffsetPixels == 0 &&
(isLayoutRtl() ? position == mPages.size() - 1 : position == 0));
}
}
};
public static class TilePage extends TileLayout {
public TilePage(Context context, AttributeSet attrs) {
super(context, attrs);
}
public boolean isFull() {
return mRecords.size() >= maxTiles();
}
public int maxTiles() {
// Each page should be able to hold at least one tile. If there's not enough room to
// show even 1 or there are no tiles, it probably means we are in the middle of setting
// up.
return Math.max(mColumns * mRows, 1);
}
@Override
public boolean updateResources() {
final int sidePadding = getContext().getResources().getDimensionPixelSize(
R.dimen.notification_side_paddings);
setPadding(sidePadding, 0, sidePadding, 0);
return super.updateResources();
}
}
private final PagerAdapter mAdapter = new PagerAdapter() {
@Override
public void destroyItem(ViewGroup container, int position, Object object) {
if (DEBUG) Log.d(TAG, "Destantiating " + position);
container.removeView((View) object);
updateListening();
}
@Override
public Object instantiateItem(ViewGroup container, int position) {
if (DEBUG) Log.d(TAG, "Instantiating " + position);
if (isLayoutRtl()) {
position = mPages.size() - 1 - position;
}
ViewGroup view = mPages.get(position);
if (view.getParent() != null) {
container.removeView(view);
}
container.addView(view);
updateListening();
return view;
}
@Override
public int getCount() {
return mPages.size();
}
@Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
}
};
public interface PageListener {
void onPageChanged(boolean isFirst);
}
}