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.support.v7.widget.RecyclerView;
23 import android.util.AttributeSet;
24 import android.util.SparseIntArray;
25 import android.view.MotionEvent;
26 import android.view.View;
27 
28 import com.android.launcher3.BaseRecyclerView;
29 import com.android.launcher3.BubbleTextView;
30 import com.android.launcher3.DeviceProfile;
31 import com.android.launcher3.Launcher;
32 import com.android.launcher3.R;
33 import com.android.launcher3.config.FeatureFlags;
34 import com.android.launcher3.graphics.DrawableFactory;
35 import com.android.launcher3.userevent.nano.LauncherLogProto.ContainerType;
36 
37 import java.util.List;
38 
39 /**
40  * A RecyclerView with custom fast scroll support for the all apps view.
41  */
42 public class AllAppsRecyclerView extends BaseRecyclerView {
43 
44     private AlphabeticalAppsList mApps;
45     private AllAppsFastScrollHelper mFastScrollHelper;
46     private int mNumAppsPerRow;
47 
48     // The specific view heights that we use to calculate scroll
49     private SparseIntArray mViewHeights = new SparseIntArray();
50     private SparseIntArray mCachedScrollPositions = new SparseIntArray();
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.VIEW_TYPE_EMPTY_SEARCH, 1);
101         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_SEARCH_DIVIDER, 1);
102         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET_DIVIDER, 1);
103         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET, 1);
104         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_ICON, approxRows * mNumAppsPerRow);
105         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_PREDICTION_ICON, mNumAppsPerRow);
106         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_PREDICTION_DIVIDER, 1);
107     }
108 
109     /**
110      * Ensures that we can present a stable scrollbar for views of varying types by pre-measuring
111      * all the different view types.
112      */
preMeasureViews(AllAppsGridAdapter adapter)113     public void preMeasureViews(AllAppsGridAdapter adapter) {
114         View icon = adapter.onCreateViewHolder(this, AllAppsGridAdapter.VIEW_TYPE_ICON).itemView;
115         final int iconHeight = icon.getLayoutParams().height;
116         mViewHeights.put(AllAppsGridAdapter.VIEW_TYPE_ICON, iconHeight);
117         mViewHeights.put(AllAppsGridAdapter.VIEW_TYPE_PREDICTION_ICON, iconHeight);
118 
119         final int widthMeasureSpec = View.MeasureSpec.makeMeasureSpec(
120                 getResources().getDisplayMetrics().widthPixels, View.MeasureSpec.AT_MOST);
121         final int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(
122                 getResources().getDisplayMetrics().heightPixels, View.MeasureSpec.AT_MOST);
123 
124         putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec,
125                 AllAppsGridAdapter.VIEW_TYPE_PREDICTION_DIVIDER,
126                 AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET_DIVIDER);
127         putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec,
128                 AllAppsGridAdapter.VIEW_TYPE_SEARCH_DIVIDER);
129         putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec,
130                 AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET);
131         putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec,
132                 AllAppsGridAdapter.VIEW_TYPE_EMPTY_SEARCH);
133 
134         if (FeatureFlags.DISCOVERY_ENABLED) {
135             putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec,
136                     AllAppsGridAdapter.VIEW_TYPE_APPS_LOADING_DIVIDER);
137             putSameHeightFor(adapter, widthMeasureSpec, heightMeasureSpec,
138                     AllAppsGridAdapter.VIEW_TYPE_DISCOVERY_ITEM);
139         }
140     }
141 
putSameHeightFor(AllAppsGridAdapter adapter, int w, int h, int... viewTypes)142     private void putSameHeightFor(AllAppsGridAdapter adapter, int w, int h, int... viewTypes) {
143         View view = adapter.onCreateViewHolder(this, viewTypes[0]).itemView;
144         view.measure(w, h);
145         for (int viewType : viewTypes) {
146             mViewHeights.put(viewType, view.getMeasuredHeight());
147         }
148     }
149 
150     /**
151      * Scrolls this recycler view to the top.
152      */
scrollToTop()153     public void scrollToTop() {
154         // Ensure we reattach the scrollbar if it was previously detached while fast-scrolling
155         if (mScrollbar.isThumbDetached()) {
156             mScrollbar.reattachThumbToScroll();
157         }
158         scrollToPosition(0);
159         if (mElevationController != null) {
160             mElevationController.reset();
161         }
162     }
163 
164     @Override
onDraw(Canvas c)165     public void onDraw(Canvas c) {
166         // Draw the background
167         if (mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) {
168             mEmptySearchBackground.draw(c);
169         }
170 
171         super.onDraw(c);
172     }
173 
174     @Override
verifyDrawable(Drawable who)175     protected boolean verifyDrawable(Drawable who) {
176         return who == mEmptySearchBackground || super.verifyDrawable(who);
177     }
178 
179     @Override
onSizeChanged(int w, int h, int oldw, int oldh)180     protected void onSizeChanged(int w, int h, int oldw, int oldh) {
181         updateEmptySearchBackgroundBounds();
182     }
183 
getContainerType(View v)184     public int getContainerType(View v) {
185         if (mApps.hasFilter()) {
186             return ContainerType.SEARCHRESULT;
187         } else {
188             if (v instanceof BubbleTextView) {
189                 BubbleTextView icon = (BubbleTextView) v;
190                 int position = getChildPosition(icon);
191                 if (position != NO_POSITION) {
192                     List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
193                     AlphabeticalAppsList.AdapterItem item = items.get(position);
194                     if (item.viewType == AllAppsGridAdapter.VIEW_TYPE_PREDICTION_ICON) {
195                         return ContainerType.PREDICTION;
196                     }
197                 }
198             }
199             return ContainerType.ALLAPPS;
200         }
201     }
202 
onSearchResultsChanged()203     public void onSearchResultsChanged() {
204         // Always scroll the view to the top so the user can see the changed results
205         scrollToTop();
206 
207         if (mApps.shouldShowEmptySearch()) {
208             if (mEmptySearchBackground == null) {
209                 mEmptySearchBackground = DrawableFactory.get(getContext())
210                         .getAllAppsBackground(getContext());
211                 mEmptySearchBackground.setAlpha(0);
212                 mEmptySearchBackground.setCallback(this);
213                 updateEmptySearchBackgroundBounds();
214             }
215             mEmptySearchBackground.animateBgAlpha(1f, 150);
216         } else if (mEmptySearchBackground != null) {
217             // For the time being, we just immediately hide the background to ensure that it does
218             // not overlap with the results
219             mEmptySearchBackground.setBgAlpha(0f);
220         }
221     }
222 
223     @Override
onInterceptTouchEvent(MotionEvent e)224     public boolean onInterceptTouchEvent(MotionEvent e) {
225         boolean result = super.onInterceptTouchEvent(e);
226         if (!result && e.getAction() == MotionEvent.ACTION_DOWN
227                 && mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) {
228             mEmptySearchBackground.setHotspot(e.getX(), e.getY());
229         }
230         return result;
231     }
232 
233     /**
234      * Maps the touch (from 0..1) to the adapter position that should be visible.
235      */
236     @Override
scrollToPositionAtProgress(float touchFraction)237     public String scrollToPositionAtProgress(float touchFraction) {
238         int rowCount = mApps.getNumAppRows();
239         if (rowCount == 0) {
240             return "";
241         }
242 
243         // Stop the scroller if it is scrolling
244         stopScroll();
245 
246         // Find the fastscroll section that maps to this touch fraction
247         List<AlphabeticalAppsList.FastScrollSectionInfo> fastScrollSections =
248                 mApps.getFastScrollerSections();
249         AlphabeticalAppsList.FastScrollSectionInfo lastInfo = fastScrollSections.get(0);
250         for (int i = 1; i < fastScrollSections.size(); i++) {
251             AlphabeticalAppsList.FastScrollSectionInfo info = fastScrollSections.get(i);
252             if (info.touchFraction > touchFraction) {
253                 break;
254             }
255             lastInfo = info;
256         }
257 
258         // Update the fast scroll
259         int scrollY = getCurrentScrollY();
260         int availableScrollHeight = getAvailableScrollHeight();
261         mFastScrollHelper.smoothScrollToSection(scrollY, availableScrollHeight, lastInfo);
262         return lastInfo.sectionName;
263     }
264 
265     @Override
onFastScrollCompleted()266     public void onFastScrollCompleted() {
267         super.onFastScrollCompleted();
268         mFastScrollHelper.onFastScrollCompleted();
269     }
270 
271     @Override
setAdapter(Adapter adapter)272     public void setAdapter(Adapter adapter) {
273         super.setAdapter(adapter);
274         adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
275             public void onChanged() {
276                 mCachedScrollPositions.clear();
277             }
278         });
279         mFastScrollHelper.onSetAdapter((AllAppsGridAdapter) adapter);
280     }
281 
282     /**
283      * Updates the bounds for the scrollbar.
284      */
285     @Override
onUpdateScrollbar(int dy)286     public void onUpdateScrollbar(int dy) {
287         List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
288 
289         // Skip early if there are no items or we haven't been measured
290         if (items.isEmpty() || mNumAppsPerRow == 0) {
291             mScrollbar.setThumbOffsetY(-1);
292             return;
293         }
294 
295         // Skip early if, there no child laid out in the container.
296         int scrollY = getCurrentScrollY();
297         if (scrollY < 0) {
298             mScrollbar.setThumbOffsetY(-1);
299             return;
300         }
301 
302         // Only show the scrollbar if there is height to be scrolled
303         int availableScrollBarHeight = getAvailableScrollBarHeight();
304         int availableScrollHeight = getAvailableScrollHeight();
305         if (availableScrollHeight <= 0) {
306             mScrollbar.setThumbOffsetY(-1);
307             return;
308         }
309 
310         if (mScrollbar.isThumbDetached()) {
311             if (!mScrollbar.isDraggingThumb()) {
312                 // Calculate the current scroll position, the scrollY of the recycler view accounts
313                 // for the view padding, while the scrollBarY is drawn right up to the background
314                 // padding (ignoring padding)
315                 int scrollBarY = (int)
316                         (((float) scrollY / availableScrollHeight) * availableScrollBarHeight);
317 
318                 int thumbScrollY = mScrollbar.getThumbOffsetY();
319                 int diffScrollY = scrollBarY - thumbScrollY;
320                 if (diffScrollY * dy > 0f) {
321                     // User is scrolling in the same direction the thumb needs to catch up to the
322                     // current scroll position.  We do this by mapping the difference in movement
323                     // from the original scroll bar position to the difference in movement necessary
324                     // in the detached thumb position to ensure that both speed towards the same
325                     // position at either end of the list.
326                     if (dy < 0) {
327                         int offset = (int) ((dy * thumbScrollY) / (float) scrollBarY);
328                         thumbScrollY += Math.max(offset, diffScrollY);
329                     } else {
330                         int offset = (int) ((dy * (availableScrollBarHeight - thumbScrollY)) /
331                                 (float) (availableScrollBarHeight - scrollBarY));
332                         thumbScrollY += Math.min(offset, diffScrollY);
333                     }
334                     thumbScrollY = Math.max(0, Math.min(availableScrollBarHeight, thumbScrollY));
335                     mScrollbar.setThumbOffsetY(thumbScrollY);
336                     if (scrollBarY == thumbScrollY) {
337                         mScrollbar.reattachThumbToScroll();
338                     }
339                 } else {
340                     // User is scrolling in an opposite direction to the direction that the thumb
341                     // needs to catch up to the scroll position.  Do nothing except for updating
342                     // the scroll bar x to match the thumb width.
343                     mScrollbar.setThumbOffsetY(thumbScrollY);
344                 }
345             }
346         } else {
347             synchronizeScrollBarThumbOffsetToViewScroll(scrollY, availableScrollHeight);
348         }
349     }
350 
351     @Override
supportsFastScrolling()352     protected boolean supportsFastScrolling() {
353         // Only allow fast scrolling when the user is not searching, since the results are not
354         // grouped in a meaningful order
355         return !mApps.hasFilter();
356     }
357 
358     @Override
getCurrentScrollY()359     public int getCurrentScrollY() {
360         // Return early if there are no items or we haven't been measured
361         List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
362         if (items.isEmpty() || mNumAppsPerRow == 0 || getChildCount() == 0) {
363             return -1;
364         }
365 
366         // Calculate the y and offset for the item
367         View child = getChildAt(0);
368         int position = getChildPosition(child);
369         if (position == NO_POSITION) {
370             return -1;
371         }
372         return getCurrentScrollY(position, getLayoutManager().getDecoratedTop(child));
373     }
374 
getCurrentScrollY(int position, int offset)375     public int getCurrentScrollY(int position, int offset) {
376         List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
377         AlphabeticalAppsList.AdapterItem posItem = position < items.size() ?
378                 items.get(position) : null;
379         int y = mCachedScrollPositions.get(position, -1);
380         if (y < 0) {
381             y = 0;
382             for (int i = 0; i < position; i++) {
383                 AlphabeticalAppsList.AdapterItem item = items.get(i);
384                 if (AllAppsGridAdapter.isIconViewType(item.viewType)) {
385                     // Break once we reach the desired row
386                     if (posItem != null && posItem.viewType == item.viewType &&
387                             posItem.rowIndex == item.rowIndex) {
388                         break;
389                     }
390                     // Otherwise, only account for the first icon in the row since they are the same
391                     // size within a row
392                     if (item.rowAppIndex == 0) {
393                         y += mViewHeights.get(item.viewType, 0);
394                     }
395                 } else {
396                     // Rest of the views span the full width
397                     y += mViewHeights.get(item.viewType, 0);
398                 }
399             }
400             mCachedScrollPositions.put(position, y);
401         }
402 
403         return getPaddingTop() + y - offset;
404     }
405 
406     @Override
407     protected int getScrollbarTrackHeight() {
408         return super.getScrollbarTrackHeight()
409                 - Launcher.getLauncher(getContext()).getDragLayer().getInsets().bottom;
410     }
411 
412     /**
413      * Returns the available scroll height:
414      *   AvailableScrollHeight = Total height of the all items - last page height
415      */
416     @Override
417     protected int getAvailableScrollHeight() {
418         int paddedHeight = getCurrentScrollY(mApps.getAdapterItems().size(), 0);
419         int totalHeight = paddedHeight + getPaddingBottom();
420         return totalHeight - getScrollbarTrackHeight();
421     }
422 
423     /**
424      * Updates the bounds of the empty search background.
425      */
426     private void updateEmptySearchBackgroundBounds() {
427         if (mEmptySearchBackground == null) {
428             return;
429         }
430 
431         // Center the empty search background on this new view bounds
432         int x = (getMeasuredWidth() - mEmptySearchBackground.getIntrinsicWidth()) / 2;
433         int y = mEmptySearchBackgroundTopOffset;
434         mEmptySearchBackground.setBounds(x, y,
435                 x + mEmptySearchBackground.getIntrinsicWidth(),
436                 y + mEmptySearchBackground.getIntrinsicHeight());
437     }
438 
439 }
440