/* * Copyright (C) 2015 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.launcher3.allapps; import android.animation.ObjectAnimator; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.drawable.Drawable; import android.support.v7.widget.RecyclerView; import android.util.AttributeSet; import android.util.Property; import android.util.SparseIntArray; import android.view.MotionEvent; import android.view.View; import com.android.launcher3.BaseRecyclerView; import com.android.launcher3.BubbleTextView; import com.android.launcher3.DeviceProfile; import com.android.launcher3.ItemInfo; import com.android.launcher3.R; import com.android.launcher3.anim.SpringAnimationHandler; import com.android.launcher3.config.FeatureFlags; import com.android.launcher3.graphics.DrawableFactory; import com.android.launcher3.logging.UserEventDispatcher.LogContainerProvider; import com.android.launcher3.touch.OverScroll; import com.android.launcher3.touch.SwipeDetector; import com.android.launcher3.userevent.nano.LauncherLogProto.ContainerType; import com.android.launcher3.userevent.nano.LauncherLogProto.Target; import java.util.List; /** * A RecyclerView with custom fast scroll support for the all apps view. */ public class AllAppsRecyclerView extends BaseRecyclerView implements LogContainerProvider { private AlphabeticalAppsList mApps; private AllAppsFastScrollHelper mFastScrollHelper; private int mNumAppsPerRow; // The specific view heights that we use to calculate scroll private SparseIntArray mViewHeights = new SparseIntArray(); private SparseIntArray mCachedScrollPositions = new SparseIntArray(); // The empty-search result background private AllAppsBackgroundDrawable mEmptySearchBackground; private int mEmptySearchBackgroundTopOffset; private SpringAnimationHandler mSpringAnimationHandler; private OverScrollHelper mOverScrollHelper; private SwipeDetector mPullDetector; private float mContentTranslationY = 0; public static final Property CONTENT_TRANS_Y = new Property(Float.class, "appsRecyclerViewContentTransY") { @Override public Float get(AllAppsRecyclerView allAppsRecyclerView) { return allAppsRecyclerView.getContentTranslationY(); } @Override public void set(AllAppsRecyclerView allAppsRecyclerView, Float y) { allAppsRecyclerView.setContentTranslationY(y); } }; public AllAppsRecyclerView(Context context) { this(context, null); } public AllAppsRecyclerView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) { this(context, attrs, defStyleAttr, 0); } public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr); Resources res = getResources(); addOnItemTouchListener(this); mEmptySearchBackgroundTopOffset = res.getDimensionPixelSize( R.dimen.all_apps_empty_search_bg_top_offset); mOverScrollHelper = new OverScrollHelper(); mPullDetector = new SwipeDetector(getContext(), mOverScrollHelper, SwipeDetector.VERTICAL); mPullDetector.setDetectableScrollConditions(SwipeDetector.DIRECTION_BOTH, true); } public void setSpringAnimationHandler(SpringAnimationHandler springAnimationHandler) { if (FeatureFlags.LAUNCHER3_PHYSICS) { mSpringAnimationHandler = springAnimationHandler; addOnScrollListener(new SpringMotionOnScrollListener()); } } @Override public boolean onTouchEvent(MotionEvent e) { mPullDetector.onTouchEvent(e); if (FeatureFlags.LAUNCHER3_PHYSICS && mSpringAnimationHandler != null) { mSpringAnimationHandler.addMovement(e); } return super.onTouchEvent(e); } /** * Sets the list of apps in this view, used to determine the fastscroll position. */ public void setApps(AlphabeticalAppsList apps) { mApps = apps; mFastScrollHelper = new AllAppsFastScrollHelper(this, apps); } public AlphabeticalAppsList getApps() { return mApps; } /** * Sets the number of apps per row in this recycler view. */ public void setNumAppsPerRow(DeviceProfile grid, int numAppsPerRow) { mNumAppsPerRow = numAppsPerRow; RecyclerView.RecycledViewPool pool = getRecycledViewPool(); int approxRows = (int) Math.ceil(grid.availableHeightPx / grid.allAppsIconSizePx); pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_EMPTY_SEARCH, 1); pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET_DIVIDER, 1); pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET, 1); pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_ICON, approxRows * mNumAppsPerRow); pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_PREDICTION_ICON, mNumAppsPerRow); pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_PREDICTION_DIVIDER, 1); } /** * Ensures that we can present a stable scrollbar for views of varying types by pre-measuring * all the different view types. */ public void preMeasureViews(AllAppsGridAdapter adapter) { View icon = adapter.onCreateViewHolder(this, AllAppsGridAdapter.VIEW_TYPE_ICON).itemView; final int iconHeight = icon.getLayoutParams().height; mViewHeights.put(AllAppsGridAdapter.VIEW_TYPE_ICON, iconHeight); mViewHeights.put(AllAppsGridAdapter.VIEW_TYPE_PREDICTION_ICON, iconHeight); final int widthMeasureSpec = View.MeasureSpec.makeMeasureSpec( getResources().getDisplayMetrics().widthPixels, View.MeasureSpec.AT_MOST); final int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec( getResources().getDisplayMetrics().heightPixels, View.MeasureSpec.AT_MOST); putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec, AllAppsGridAdapter.VIEW_TYPE_PREDICTION_DIVIDER, AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET_DIVIDER); putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec, AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET); putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec, AllAppsGridAdapter.VIEW_TYPE_EMPTY_SEARCH); if (FeatureFlags.DISCOVERY_ENABLED) { putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec, AllAppsGridAdapter.VIEW_TYPE_APPS_LOADING_DIVIDER); putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec, AllAppsGridAdapter.VIEW_TYPE_DISCOVERY_ITEM); } } private void putSameHeightFor(AllAppsGridAdapter adapter, int w, int h, int... viewTypes) { View view = adapter.onCreateViewHolder(this, viewTypes[0]).itemView; view.measure(w, h); for (int viewType : viewTypes) { mViewHeights.put(viewType, view.getMeasuredHeight()); } } /** * Scrolls this recycler view to the top. */ public void scrollToTop() { // Ensure we reattach the scrollbar if it was previously detached while fast-scrolling if (mScrollbar != null) { mScrollbar.reattachThumbToScroll(); } scrollToPosition(0); } @Override public void onDraw(Canvas c) { // Draw the background if (mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) { mEmptySearchBackground.draw(c); } super.onDraw(c); } @Override protected void dispatchDraw(Canvas canvas) { canvas.translate(0, mContentTranslationY); super.dispatchDraw(canvas); canvas.translate(0, -mContentTranslationY); } public float getContentTranslationY() { return mContentTranslationY; } /** * Use this method instead of calling {@link #setTranslationY(float)}} directly to avoid drawing * on top of other Views. */ public void setContentTranslationY(float y) { mContentTranslationY = y; invalidate(); } @Override protected boolean verifyDrawable(Drawable who) { return who == mEmptySearchBackground || super.verifyDrawable(who); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { updateEmptySearchBackgroundBounds(); } @Override public void fillInLogContainerData(View v, ItemInfo info, Target target, Target targetParent) { if (mApps.hasFilter()) { targetParent.containerType = ContainerType.SEARCHRESULT; } else { if (v instanceof BubbleTextView) { BubbleTextView icon = (BubbleTextView) v; int position = getChildPosition(icon); if (position != NO_POSITION) { List items = mApps.getAdapterItems(); AlphabeticalAppsList.AdapterItem item = items.get(position); if (item.viewType == AllAppsGridAdapter.VIEW_TYPE_PREDICTION_ICON) { targetParent.containerType = ContainerType.PREDICTION; target.predictedRank = item.rowAppIndex; return; } } } targetParent.containerType = ContainerType.ALLAPPS; } } public void onSearchResultsChanged() { // Always scroll the view to the top so the user can see the changed results scrollToTop(); if (mApps.shouldShowEmptySearch()) { if (mEmptySearchBackground == null) { mEmptySearchBackground = DrawableFactory.get(getContext()) .getAllAppsBackground(getContext()); mEmptySearchBackground.setAlpha(0); mEmptySearchBackground.setCallback(this); updateEmptySearchBackgroundBounds(); } mEmptySearchBackground.animateBgAlpha(1f, 150); } else if (mEmptySearchBackground != null) { // For the time being, we just immediately hide the background to ensure that it does // not overlap with the results mEmptySearchBackground.setBgAlpha(0f); } } @Override public boolean onInterceptTouchEvent(MotionEvent e) { mPullDetector.onTouchEvent(e); boolean result = super.onInterceptTouchEvent(e) || mOverScrollHelper.isInOverScroll(); if (!result && e.getAction() == MotionEvent.ACTION_DOWN && mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) { mEmptySearchBackground.setHotspot(e.getX(), e.getY()); } return result; } /** * Maps the touch (from 0..1) to the adapter position that should be visible. */ @Override public String scrollToPositionAtProgress(float touchFraction) { int rowCount = mApps.getNumAppRows(); if (rowCount == 0) { return ""; } // Stop the scroller if it is scrolling stopScroll(); // Find the fastscroll section that maps to this touch fraction List fastScrollSections = mApps.getFastScrollerSections(); AlphabeticalAppsList.FastScrollSectionInfo lastInfo = fastScrollSections.get(0); for (int i = 1; i < fastScrollSections.size(); i++) { AlphabeticalAppsList.FastScrollSectionInfo info = fastScrollSections.get(i); if (info.touchFraction > touchFraction) { break; } lastInfo = info; } // Update the fast scroll int scrollY = getCurrentScrollY(); int availableScrollHeight = getAvailableScrollHeight(); mFastScrollHelper.smoothScrollToSection(scrollY, availableScrollHeight, lastInfo); return lastInfo.sectionName; } @Override public void onFastScrollCompleted() { super.onFastScrollCompleted(); mFastScrollHelper.onFastScrollCompleted(); } @Override public void setAdapter(Adapter adapter) { super.setAdapter(adapter); adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() { public void onChanged() { mCachedScrollPositions.clear(); } }); mFastScrollHelper.onSetAdapter((AllAppsGridAdapter) adapter); } @Override protected float getBottomFadingEdgeStrength() { // No bottom fading edge. return 0; } @Override protected boolean isPaddingOffsetRequired() { return true; } @Override protected int getTopPaddingOffset() { return -getPaddingTop(); } /** * Updates the bounds for the scrollbar. */ @Override public void onUpdateScrollbar(int dy) { List items = mApps.getAdapterItems(); // Skip early if there are no items or we haven't been measured if (items.isEmpty() || mNumAppsPerRow == 0) { mScrollbar.setThumbOffsetY(-1); return; } // Skip early if, there no child laid out in the container. int scrollY = getCurrentScrollY(); if (scrollY < 0) { mScrollbar.setThumbOffsetY(-1); return; } // Only show the scrollbar if there is height to be scrolled int availableScrollBarHeight = getAvailableScrollBarHeight(); int availableScrollHeight = getAvailableScrollHeight(); if (availableScrollHeight <= 0) { mScrollbar.setThumbOffsetY(-1); return; } if (mScrollbar.isThumbDetached()) { if (!mScrollbar.isDraggingThumb()) { // Calculate the current scroll position, the scrollY of the recycler view accounts // for the view padding, while the scrollBarY is drawn right up to the background // padding (ignoring padding) int scrollBarY = (int) (((float) scrollY / availableScrollHeight) * availableScrollBarHeight); int thumbScrollY = mScrollbar.getThumbOffsetY(); int diffScrollY = scrollBarY - thumbScrollY; if (diffScrollY * dy > 0f) { // User is scrolling in the same direction the thumb needs to catch up to the // current scroll position. We do this by mapping the difference in movement // from the original scroll bar position to the difference in movement necessary // in the detached thumb position to ensure that both speed towards the same // position at either end of the list. if (dy < 0) { int offset = (int) ((dy * thumbScrollY) / (float) scrollBarY); thumbScrollY += Math.max(offset, diffScrollY); } else { int offset = (int) ((dy * (availableScrollBarHeight - thumbScrollY)) / (float) (availableScrollBarHeight - scrollBarY)); thumbScrollY += Math.min(offset, diffScrollY); } thumbScrollY = Math.max(0, Math.min(availableScrollBarHeight, thumbScrollY)); mScrollbar.setThumbOffsetY(thumbScrollY); if (scrollBarY == thumbScrollY) { mScrollbar.reattachThumbToScroll(); } } else { // User is scrolling in an opposite direction to the direction that the thumb // needs to catch up to the scroll position. Do nothing except for updating // the scroll bar x to match the thumb width. mScrollbar.setThumbOffsetY(thumbScrollY); } } } else { synchronizeScrollBarThumbOffsetToViewScroll(scrollY, availableScrollHeight); } } @Override public boolean supportsFastScrolling() { // Only allow fast scrolling when the user is not searching, since the results are not // grouped in a meaningful order return !mApps.hasFilter(); } @Override public int getCurrentScrollY() { // Return early if there are no items or we haven't been measured List items = mApps.getAdapterItems(); if (items.isEmpty() || mNumAppsPerRow == 0 || getChildCount() == 0) { return -1; } // Calculate the y and offset for the item View child = getChildAt(0); int position = getChildPosition(child); if (position == NO_POSITION) { return -1; } return getPaddingTop() + getCurrentScrollY(position, getLayoutManager().getDecoratedTop(child)); } public int getCurrentScrollY(int position, int offset) { List items = mApps.getAdapterItems(); AlphabeticalAppsList.AdapterItem posItem = position < items.size() ? items.get(position) : null; int y = mCachedScrollPositions.get(position, -1); if (y < 0) { y = 0; for (int i = 0; i < position; i++) { AlphabeticalAppsList.AdapterItem item = items.get(i); if (AllAppsGridAdapter.isIconViewType(item.viewType)) { // Break once we reach the desired row if (posItem != null && posItem.viewType == item.viewType && posItem.rowIndex == item.rowIndex) { break; } // Otherwise, only account for the first icon in the row since they are the same // size within a row if (item.rowAppIndex == 0) { y += mViewHeights.get(item.viewType, 0); } } else { // Rest of the views span the full width y += mViewHeights.get(item.viewType, 0); } } mCachedScrollPositions.put(position, y); } return y - offset; } /** * Returns the available scroll height: * AvailableScrollHeight = Total height of the all items - last page height */ @Override protected int getAvailableScrollHeight() { return getPaddingTop() + getCurrentScrollY(mApps.getAdapterItems().size(), 0) - getHeight() + getPaddingBottom(); } /** * Updates the bounds of the empty search background. */ private void updateEmptySearchBackgroundBounds() { if (mEmptySearchBackground == null) { return; } // Center the empty search background on this new view bounds int x = (getMeasuredWidth() - mEmptySearchBackground.getIntrinsicWidth()) / 2; int y = mEmptySearchBackgroundTopOffset; mEmptySearchBackground.setBounds(x, y, x + mEmptySearchBackground.getIntrinsicWidth(), y + mEmptySearchBackground.getIntrinsicHeight()); } private class SpringMotionOnScrollListener extends RecyclerView.OnScrollListener { @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { if (mOverScrollHelper.isInOverScroll()) { // OverScroll will handle animating the springs. return; } // We only start the spring animation when we hit the top/bottom, to ensure // that all of the animations start at the same time. if (dy < 0 && !canScrollVertically(-1)) { mSpringAnimationHandler.animateToFinalPosition(0, 1); } else if (dy > 0 && !canScrollVertically(1)) { mSpringAnimationHandler.animateToFinalPosition(0, -1); } } } private class OverScrollHelper implements SwipeDetector.Listener { private static final float MAX_RELEASE_VELOCITY = 5000; // px / s private static final float MAX_OVERSCROLL_PERCENTAGE = 0.07f; private boolean mIsInOverScroll; // We use this value to calculate the actual amount the user has overscrolled. private float mFirstDisplacement = 0; private boolean mAlreadyScrollingUp; private int mFirstScrollYOnScrollUp; @Override public void onDragStart(boolean start) { } @Override public boolean onDrag(float displacement, float velocity) { boolean isScrollingUp = displacement > 0; if (isScrollingUp) { if (!mAlreadyScrollingUp) { mFirstScrollYOnScrollUp = getCurrentScrollY(); mAlreadyScrollingUp = true; } } else { mAlreadyScrollingUp = false; } // Only enter overscroll if the user is interacting with the RecyclerView directly // and if one of the following criteria are met: // - User scrolls down when they're already at the bottom. // - User starts scrolling up, hits the top, and continues scrolling up. boolean wasInOverScroll = mIsInOverScroll; mIsInOverScroll = !mScrollbar.isDraggingThumb() && ((!canScrollVertically(1) && displacement < 0) || (!canScrollVertically(-1) && isScrollingUp && mFirstScrollYOnScrollUp != 0)); if (wasInOverScroll && !mIsInOverScroll) { // Exit overscroll. This can happen when the user is in overscroll and then // scrolls the opposite way. reset(false /* shouldSpring */); } else if (mIsInOverScroll) { if (Float.compare(mFirstDisplacement, 0) == 0) { // Because users can scroll before entering overscroll, we need to // subtract the amount where the user was not in overscroll. mFirstDisplacement = displacement; } float overscrollY = displacement - mFirstDisplacement; setContentTranslationY(getDampedOverScroll(overscrollY)); } return mIsInOverScroll; } @Override public void onDragEnd(float velocity, boolean fling) { reset(mIsInOverScroll /* shouldSpring */); } private void reset(boolean shouldSpring) { float y = getContentTranslationY(); if (Float.compare(y, 0) != 0) { if (FeatureFlags.LAUNCHER3_PHYSICS && shouldSpring) { // We calculate our own velocity to give the springs the desired effect. float velocity = y / getDampedOverScroll(getHeight()) * MAX_RELEASE_VELOCITY; // We want to negate the velocity because we are moving to 0 from -1 due to the // downward motion. (y-axis -1 is above 0). mSpringAnimationHandler.animateToPositionWithVelocity(0, -1, -velocity); } ObjectAnimator.ofFloat(AllAppsRecyclerView.this, AllAppsRecyclerView.CONTENT_TRANS_Y, 0) .setDuration(100) .start(); } mIsInOverScroll = false; mFirstDisplacement = 0; mFirstScrollYOnScrollUp = 0; mAlreadyScrollingUp = false; } public boolean isInOverScroll() { return mIsInOverScroll; } private float getDampedOverScroll(float y) { return OverScroll.dampedScroll(y, getHeight()); } } }