1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.launcher3.allapps;
17 
18 import android.content.Context;
19 import android.content.res.Resources;
20 import android.graphics.Canvas;
21 import android.graphics.drawable.Drawable;
22 import android.os.Bundle;
23 import android.support.v7.widget.RecyclerView;
24 import android.util.AttributeSet;
25 import android.view.View;
26 
27 import com.android.launcher3.BaseRecyclerView;
28 import com.android.launcher3.BubbleTextView;
29 import com.android.launcher3.DeviceProfile;
30 import com.android.launcher3.R;
31 import com.android.launcher3.Stats;
32 import com.android.launcher3.Utilities;
33 
34 import java.util.List;
35 
36 /**
37  * A RecyclerView with custom fast scroll support for the all apps view.
38  */
39 public class AllAppsRecyclerView extends BaseRecyclerView
40         implements Stats.LaunchSourceProvider {
41 
42     private AlphabeticalAppsList mApps;
43     private AllAppsFastScrollHelper mFastScrollHelper;
44     private BaseRecyclerView.ScrollPositionState mScrollPosState =
45             new BaseRecyclerView.ScrollPositionState();
46     private int mNumAppsPerRow;
47 
48     // The specific icon heights that we use to calculate scroll
49     private int mPredictionIconHeight;
50     private int mIconHeight;
51 
52     // The empty-search result background
53     private AllAppsBackgroundDrawable mEmptySearchBackground;
54     private int mEmptySearchBackgroundTopOffset;
55 
56     private HeaderElevationController mElevationController;
57 
AllAppsRecyclerView(Context context)58     public AllAppsRecyclerView(Context context) {
59         this(context, null);
60     }
61 
AllAppsRecyclerView(Context context, AttributeSet attrs)62     public AllAppsRecyclerView(Context context, AttributeSet attrs) {
63         this(context, attrs, 0);
64     }
65 
AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr)66     public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) {
67         this(context, attrs, defStyleAttr, 0);
68     }
69 
AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)70     public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr,
71             int defStyleRes) {
72         super(context, attrs, defStyleAttr);
73         Resources res = getResources();
74         addOnItemTouchListener(this);
75         mScrollbar.setDetachThumbOnFastScroll();
76         mEmptySearchBackgroundTopOffset = res.getDimensionPixelSize(
77                 R.dimen.all_apps_empty_search_bg_top_offset);
78     }
79 
80     /**
81      * Sets the list of apps in this view, used to determine the fastscroll position.
82      */
setApps(AlphabeticalAppsList apps)83     public void setApps(AlphabeticalAppsList apps) {
84         mApps = apps;
85         mFastScrollHelper = new AllAppsFastScrollHelper(this, apps);
86     }
87 
setElevationController(HeaderElevationController elevationController)88     public void setElevationController(HeaderElevationController elevationController) {
89         mElevationController = elevationController;
90     }
91 
92     /**
93      * Sets the number of apps per row in this recycler view.
94      */
setNumAppsPerRow(DeviceProfile grid, int numAppsPerRow)95     public void setNumAppsPerRow(DeviceProfile grid, int numAppsPerRow) {
96         mNumAppsPerRow = numAppsPerRow;
97 
98         RecyclerView.RecycledViewPool pool = getRecycledViewPool();
99         int approxRows = (int) Math.ceil(grid.availableHeightPx / grid.allAppsIconSizePx);
100         pool.setMaxRecycledViews(AllAppsGridAdapter.EMPTY_SEARCH_VIEW_TYPE, 1);
101         pool.setMaxRecycledViews(AllAppsGridAdapter.SEARCH_MARKET_DIVIDER_VIEW_TYPE, 1);
102         pool.setMaxRecycledViews(AllAppsGridAdapter.SEARCH_MARKET_VIEW_TYPE, 1);
103         pool.setMaxRecycledViews(AllAppsGridAdapter.ICON_VIEW_TYPE, approxRows * mNumAppsPerRow);
104         pool.setMaxRecycledViews(AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE, mNumAppsPerRow);
105         pool.setMaxRecycledViews(AllAppsGridAdapter.SECTION_BREAK_VIEW_TYPE, approxRows);
106     }
107 
108     /**
109      * Sets the heights of the icons in this view (for scroll calculations).
110      */
setPremeasuredIconHeights(int predictionIconHeight, int iconHeight)111     public void setPremeasuredIconHeights(int predictionIconHeight, int iconHeight) {
112         mPredictionIconHeight = predictionIconHeight;
113         mIconHeight = iconHeight;
114     }
115 
116     /**
117      * Scrolls this recycler view to the top.
118      */
scrollToTop()119     public void scrollToTop() {
120         // Ensure we reattach the scrollbar if it was previously detached while fast-scrolling
121         if (mScrollbar.isThumbDetached()) {
122             mScrollbar.reattachThumbToScroll();
123         }
124         scrollToPosition(0);
125         if (mElevationController != null) {
126             mElevationController.reset();
127         }
128     }
129 
130     /**
131      * We need to override the draw to ensure that we don't draw the overscroll effect beyond the
132      * background bounds.
133      */
134     @Override
dispatchDraw(Canvas canvas)135     protected void dispatchDraw(Canvas canvas) {
136         // Clip to ensure that we don't draw the overscroll effect beyond the background bounds
137         canvas.clipRect(mBackgroundPadding.left, mBackgroundPadding.top,
138                 getWidth() - mBackgroundPadding.right,
139                 getHeight() - mBackgroundPadding.bottom);
140         super.dispatchDraw(canvas);
141     }
142 
143     @Override
onDraw(Canvas c)144     public void onDraw(Canvas c) {
145         // Draw the background
146         if (mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) {
147             c.clipRect(mBackgroundPadding.left, mBackgroundPadding.top,
148                     getWidth() - mBackgroundPadding.right,
149                     getHeight() - mBackgroundPadding.bottom);
150 
151             mEmptySearchBackground.draw(c);
152         }
153 
154         super.onDraw(c);
155     }
156 
157     @Override
verifyDrawable(Drawable who)158     protected boolean verifyDrawable(Drawable who) {
159         return who == mEmptySearchBackground || super.verifyDrawable(who);
160     }
161 
162     @Override
onSizeChanged(int w, int h, int oldw, int oldh)163     protected void onSizeChanged(int w, int h, int oldw, int oldh) {
164         updateEmptySearchBackgroundBounds();
165     }
166 
167     @Override
fillInLaunchSourceData(View v, Bundle sourceData)168     public void fillInLaunchSourceData(View v, Bundle sourceData) {
169         sourceData.putString(Stats.SOURCE_EXTRA_CONTAINER, Stats.CONTAINER_ALL_APPS);
170         if (mApps.hasFilter()) {
171             sourceData.putString(Stats.SOURCE_EXTRA_SUB_CONTAINER,
172                     Stats.SUB_CONTAINER_ALL_APPS_SEARCH);
173         } else {
174             if (v instanceof BubbleTextView) {
175                 BubbleTextView icon = (BubbleTextView) v;
176                 int position = getChildPosition(icon);
177                 if (position != NO_POSITION) {
178                     List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
179                     AlphabeticalAppsList.AdapterItem item = items.get(position);
180                     if (item.viewType == AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE) {
181                         sourceData.putString(Stats.SOURCE_EXTRA_SUB_CONTAINER,
182                                 Stats.SUB_CONTAINER_ALL_APPS_PREDICTION);
183                         return;
184                     }
185                 }
186             }
187             sourceData.putString(Stats.SOURCE_EXTRA_SUB_CONTAINER,
188                     Stats.SUB_CONTAINER_ALL_APPS_A_Z);
189         }
190     }
191 
onSearchResultsChanged()192     public void onSearchResultsChanged() {
193         // Always scroll the view to the top so the user can see the changed results
194         scrollToTop();
195 
196         if (mApps.hasNoFilteredResults()) {
197             if (mEmptySearchBackground == null) {
198                 mEmptySearchBackground = new AllAppsBackgroundDrawable(getContext());
199                 mEmptySearchBackground.setAlpha(0);
200                 mEmptySearchBackground.setCallback(this);
201                 updateEmptySearchBackgroundBounds();
202             }
203             mEmptySearchBackground.animateBgAlpha(1f, 150);
204         } else if (mEmptySearchBackground != null) {
205             // For the time being, we just immediately hide the background to ensure that it does
206             // not overlap with the results
207             mEmptySearchBackground.setBgAlpha(0f);
208         }
209     }
210 
211     /**
212      * Maps the touch (from 0..1) to the adapter position that should be visible.
213      */
214     @Override
scrollToPositionAtProgress(float touchFraction)215     public String scrollToPositionAtProgress(float touchFraction) {
216         int rowCount = mApps.getNumAppRows();
217         if (rowCount == 0) {
218             return "";
219         }
220 
221         // Stop the scroller if it is scrolling
222         stopScroll();
223 
224         // Find the fastscroll section that maps to this touch fraction
225         List<AlphabeticalAppsList.FastScrollSectionInfo> fastScrollSections =
226                 mApps.getFastScrollerSections();
227         AlphabeticalAppsList.FastScrollSectionInfo lastInfo = fastScrollSections.get(0);
228         for (int i = 1; i < fastScrollSections.size(); i++) {
229             AlphabeticalAppsList.FastScrollSectionInfo info = fastScrollSections.get(i);
230             if (info.touchFraction > touchFraction) {
231                 break;
232             }
233             lastInfo = info;
234         }
235 
236         // Update the fast scroll
237         int scrollY = getScrollTop(mScrollPosState);
238         int availableScrollHeight = getAvailableScrollHeight(mApps.getNumAppRows());
239         mFastScrollHelper.smoothScrollToSection(scrollY, availableScrollHeight, lastInfo);
240         return lastInfo.sectionName;
241     }
242 
243     @Override
onFastScrollCompleted()244     public void onFastScrollCompleted() {
245         super.onFastScrollCompleted();
246         mFastScrollHelper.onFastScrollCompleted();
247     }
248 
249     @Override
setAdapter(Adapter adapter)250     public void setAdapter(Adapter adapter) {
251         super.setAdapter(adapter);
252         mFastScrollHelper.onSetAdapter((AllAppsGridAdapter) adapter);
253     }
254 
255     /**
256      * Updates the bounds for the scrollbar.
257      */
258     @Override
onUpdateScrollbar(int dy)259     public void onUpdateScrollbar(int dy) {
260         List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
261 
262         // Skip early if there are no items or we haven't been measured
263         if (items.isEmpty() || mNumAppsPerRow == 0) {
264             mScrollbar.setThumbOffset(-1, -1);
265             return;
266         }
267 
268         // Find the index and height of the first visible row (all rows have the same height)
269         int rowCount = mApps.getNumAppRows();
270         getCurScrollState(mScrollPosState, -1);
271         if (mScrollPosState.rowIndex < 0) {
272             mScrollbar.setThumbOffset(-1, -1);
273             return;
274         }
275 
276         // Only show the scrollbar if there is height to be scrolled
277         int availableScrollBarHeight = getAvailableScrollBarHeight();
278         int availableScrollHeight = getAvailableScrollHeight(mApps.getNumAppRows());
279         if (availableScrollHeight <= 0) {
280             mScrollbar.setThumbOffset(-1, -1);
281             return;
282         }
283 
284         // Calculate the current scroll position, the scrollY of the recycler view accounts for the
285         // view padding, while the scrollBarY is drawn right up to the background padding (ignoring
286         // padding)
287         int scrollY = getScrollTop(mScrollPosState);
288         int scrollBarY = mBackgroundPadding.top +
289                 (int) (((float) scrollY / availableScrollHeight) * availableScrollBarHeight);
290 
291         if (mScrollbar.isThumbDetached()) {
292             int scrollBarX;
293             if (Utilities.isRtl(getResources())) {
294                 scrollBarX = mBackgroundPadding.left;
295             } else {
296                 scrollBarX = getWidth() - mBackgroundPadding.right - mScrollbar.getThumbWidth();
297             }
298 
299             if (mScrollbar.isDraggingThumb()) {
300                 // If the thumb is detached, then just update the thumb to the current
301                 // touch position
302                 mScrollbar.setThumbOffset(scrollBarX, (int) mScrollbar.getLastTouchY());
303             } else {
304                 int thumbScrollY = mScrollbar.getThumbOffset().y;
305                 int diffScrollY = scrollBarY - thumbScrollY;
306                 if (diffScrollY * dy > 0f) {
307                     // User is scrolling in the same direction the thumb needs to catch up to the
308                     // current scroll position.  We do this by mapping the difference in movement
309                     // from the original scroll bar position to the difference in movement necessary
310                     // in the detached thumb position to ensure that both speed towards the same
311                     // position at either end of the list.
312                     if (dy < 0) {
313                         int offset = (int) ((dy * thumbScrollY) / (float) scrollBarY);
314                         thumbScrollY += Math.max(offset, diffScrollY);
315                     } else {
316                         int offset = (int) ((dy * (availableScrollBarHeight - thumbScrollY)) /
317                                 (float) (availableScrollBarHeight - scrollBarY));
318                         thumbScrollY += Math.min(offset, diffScrollY);
319                     }
320                     thumbScrollY = Math.max(0, Math.min(availableScrollBarHeight, thumbScrollY));
321                     mScrollbar.setThumbOffset(scrollBarX, thumbScrollY);
322                     if (scrollBarY == thumbScrollY) {
323                         mScrollbar.reattachThumbToScroll();
324                     }
325                 } else {
326                     // User is scrolling in an opposite direction to the direction that the thumb
327                     // needs to catch up to the scroll position.  Do nothing except for updating
328                     // the scroll bar x to match the thumb width.
329                     mScrollbar.setThumbOffset(scrollBarX, thumbScrollY);
330                 }
331             }
332         } else {
333             synchronizeScrollBarThumbOffsetToViewScroll(mScrollPosState, rowCount);
334         }
335     }
336 
337     /**
338      * Returns the current scroll state of the apps rows.
339      */
getCurScrollState(ScrollPositionState stateOut, int viewTypeMask)340     protected void getCurScrollState(ScrollPositionState stateOut, int viewTypeMask) {
341         stateOut.rowIndex = -1;
342         stateOut.rowTopOffset = -1;
343         stateOut.itemPos = -1;
344 
345         // Return early if there are no items or we haven't been measured
346         List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
347         if (items.isEmpty() || mNumAppsPerRow == 0) {
348             return;
349         }
350 
351         int childCount = getChildCount();
352         for (int i = 0; i < childCount; i++) {
353             View child = getChildAt(i);
354             int position = getChildPosition(child);
355             if (position != NO_POSITION) {
356                 AlphabeticalAppsList.AdapterItem item = items.get(position);
357                 if ((item.viewType & viewTypeMask) != 0) {
358                     stateOut.rowIndex = item.rowIndex;
359                     stateOut.rowTopOffset = getLayoutManager().getDecoratedTop(child);
360                     stateOut.itemPos = position;
361                     return;
362                 }
363             }
364         }
365         return;
366     }
367 
368     @Override
supportsFastScrolling()369     protected boolean supportsFastScrolling() {
370         // Only allow fast scrolling when the user is not searching, since the results are not
371         // grouped in a meaningful order
372         return !mApps.hasFilter();
373     }
374 
getTop(int rowIndex)375     protected int getTop(int rowIndex) {
376         if (getChildCount() == 0 || rowIndex <= 0) {
377             return 0;
378         }
379 
380         // The prediction bar icons have more padding, so account for that in the row offset
381         return mPredictionIconHeight + (rowIndex - 1) * mIconHeight;
382     }
383 
384     /**
385      * Updates the bounds of the empty search background.
386      */
updateEmptySearchBackgroundBounds()387     private void updateEmptySearchBackgroundBounds() {
388         if (mEmptySearchBackground == null) {
389             return;
390         }
391 
392         // Center the empty search background on this new view bounds
393         int x = (getMeasuredWidth() - mEmptySearchBackground.getIntrinsicWidth()) / 2;
394         int y = mEmptySearchBackgroundTopOffset;
395         mEmptySearchBackground.setBounds(x, y,
396                 x + mEmptySearchBackground.getIntrinsicWidth(),
397                 y + mEmptySearchBackground.getIntrinsicHeight());
398     }
399 }
400