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 static android.view.View.MeasureSpec.EXACTLY;
19 import static android.view.View.MeasureSpec.UNSPECIFIED;
20 import static android.view.View.MeasureSpec.makeMeasureSpec;
21 
22 import static com.android.launcher3.logging.LoggerUtils.newContainerTarget;
23 
24 import android.content.Context;
25 import android.content.res.Resources;
26 import android.graphics.Canvas;
27 import android.graphics.drawable.Drawable;
28 import android.util.AttributeSet;
29 import android.util.SparseIntArray;
30 import android.view.MotionEvent;
31 import android.view.View;
32 
33 import androidx.recyclerview.widget.RecyclerView;
34 
35 import com.android.launcher3.BaseDraggingActivity;
36 import com.android.launcher3.BaseRecyclerView;
37 import com.android.launcher3.DeviceProfile;
38 import com.android.launcher3.LauncherAppState;
39 import com.android.launcher3.R;
40 import com.android.launcher3.allapps.AllAppsGridAdapter.AppsGridLayoutManager;
41 import com.android.launcher3.logging.StatsLogUtils.LogContainerProvider;
42 import com.android.launcher3.model.data.ItemInfo;
43 import com.android.launcher3.userevent.nano.LauncherLogProto.ContainerType;
44 import com.android.launcher3.userevent.nano.LauncherLogProto.Target;
45 import com.android.launcher3.views.RecyclerViewFastScroller;
46 
47 import java.util.ArrayList;
48 import java.util.List;
49 
50 /**
51  * A RecyclerView with custom fast scroll support for the all apps view.
52  */
53 public class AllAppsRecyclerView extends BaseRecyclerView implements LogContainerProvider {
54 
55     private AlphabeticalAppsList mApps;
56     private final int mNumAppsPerRow;
57 
58     // The specific view heights that we use to calculate scroll
59     private final SparseIntArray mViewHeights = new SparseIntArray();
60     private final SparseIntArray mCachedScrollPositions = new SparseIntArray();
61     private final AllAppsFastScrollHelper mFastScrollHelper;
62 
63     // The empty-search result background
64     private AllAppsBackgroundDrawable mEmptySearchBackground;
65     private int mEmptySearchBackgroundTopOffset;
66 
67     private ArrayList<View> mAutoSizedOverlays = new ArrayList<>();
68 
AllAppsRecyclerView(Context context)69     public AllAppsRecyclerView(Context context) {
70         this(context, null);
71     }
72 
AllAppsRecyclerView(Context context, AttributeSet attrs)73     public AllAppsRecyclerView(Context context, AttributeSet attrs) {
74         this(context, attrs, 0);
75     }
76 
AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr)77     public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) {
78         this(context, attrs, defStyleAttr, 0);
79     }
80 
AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)81     public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr,
82             int defStyleRes) {
83         super(context, attrs, defStyleAttr);
84         Resources res = getResources();
85         mEmptySearchBackgroundTopOffset = res.getDimensionPixelSize(
86                 R.dimen.all_apps_empty_search_bg_top_offset);
87         mNumAppsPerRow = LauncherAppState.getIDP(context).numColumns;
88         mFastScrollHelper = new AllAppsFastScrollHelper(this);
89     }
90 
91     /**
92      * Sets the list of apps in this view, used to determine the fastscroll position.
93      */
setApps(AlphabeticalAppsList apps)94     public void setApps(AlphabeticalAppsList apps) {
95         mApps = apps;
96     }
97 
getApps()98     public AlphabeticalAppsList getApps() {
99         return mApps;
100     }
101 
updatePoolSize()102     private void updatePoolSize() {
103         DeviceProfile grid = BaseDraggingActivity.fromContext(getContext()).getDeviceProfile();
104         RecyclerView.RecycledViewPool pool = getRecycledViewPool();
105         int approxRows = (int) Math.ceil(grid.availableHeightPx / grid.allAppsIconSizePx);
106         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_EMPTY_SEARCH, 1);
107         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_ALL_APPS_DIVIDER, 1);
108         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET, 1);
109         pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_ICON, approxRows * mNumAppsPerRow);
110 
111         mViewHeights.clear();
112         mViewHeights.put(AllAppsGridAdapter.VIEW_TYPE_ICON, grid.allAppsCellHeightPx);
113     }
114 
115     /**
116      * Scrolls this recycler view to the top.
117      */
scrollToTop()118     public void scrollToTop() {
119         // Ensure we reattach the scrollbar if it was previously detached while fast-scrolling
120         if (mScrollbar != null) {
121             mScrollbar.reattachThumbToScroll();
122         }
123         if (getLayoutManager() instanceof AppsGridLayoutManager) {
124             AppsGridLayoutManager layoutManager = (AppsGridLayoutManager) getLayoutManager();
125             if (layoutManager.findFirstCompletelyVisibleItemPosition() == 0) {
126                 // We are at the top, so don't scrollToPosition (would cause unnecessary relayout).
127                 return;
128             }
129         }
130         scrollToPosition(0);
131     }
132 
133     @Override
onDraw(Canvas c)134     public void onDraw(Canvas c) {
135         // Draw the background
136         if (mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) {
137             mEmptySearchBackground.draw(c);
138         }
139 
140         super.onDraw(c);
141     }
142 
143     @Override
verifyDrawable(Drawable who)144     protected boolean verifyDrawable(Drawable who) {
145         return who == mEmptySearchBackground || super.verifyDrawable(who);
146     }
147 
148     @Override
onSizeChanged(int w, int h, int oldw, int oldh)149     protected void onSizeChanged(int w, int h, int oldw, int oldh) {
150         updateEmptySearchBackgroundBounds();
151         updatePoolSize();
152         for (int i = 0; i < mAutoSizedOverlays.size(); i++) {
153             View overlay = mAutoSizedOverlays.get(i);
154             overlay.measure(makeMeasureSpec(w, EXACTLY), makeMeasureSpec(w, EXACTLY));
155             overlay.layout(0, 0, w, h);
156         }
157     }
158 
159     /**
160      * Adds an overlay that automatically rescales with the recyclerview.
161      */
addAutoSizedOverlay(View overlay)162     public void addAutoSizedOverlay(View overlay) {
163         mAutoSizedOverlays.add(overlay);
164         getOverlay().add(overlay);
165         onSizeChanged(getWidth(), getHeight(), getWidth(), getHeight());
166     }
167 
168     /**
169      * Clears auto scaling overlay views added by #addAutoSizedOverlay
170      */
clearAutoSizedOverlays()171     public void clearAutoSizedOverlays() {
172         for (View v : mAutoSizedOverlays) {
173             getOverlay().remove(v);
174         }
175         mAutoSizedOverlays.clear();
176     }
177 
178     @Override
fillInLogContainerData(ItemInfo childInfo, Target child, ArrayList<Target> parents)179     public void fillInLogContainerData(ItemInfo childInfo, Target child,
180             ArrayList<Target> parents) {
181         parents.add(newContainerTarget(
182                 getApps().hasFilter() ? ContainerType.SEARCHRESULT : ContainerType.ALLAPPS));
183     }
184 
onSearchResultsChanged()185     public void onSearchResultsChanged() {
186         // Always scroll the view to the top so the user can see the changed results
187         scrollToTop();
188 
189         if (mApps.hasNoFilteredResults()) {
190             if (mEmptySearchBackground == null) {
191                 mEmptySearchBackground = new AllAppsBackgroundDrawable(getContext());
192                 mEmptySearchBackground.setAlpha(0);
193                 mEmptySearchBackground.setCallback(this);
194                 updateEmptySearchBackgroundBounds();
195             }
196             mEmptySearchBackground.animateBgAlpha(1f, 150);
197         } else if (mEmptySearchBackground != null) {
198             // For the time being, we just immediately hide the background to ensure that it does
199             // not overlap with the results
200             mEmptySearchBackground.setBgAlpha(0f);
201         }
202     }
203 
204     @Override
onInterceptTouchEvent(MotionEvent e)205     public boolean onInterceptTouchEvent(MotionEvent e) {
206         boolean result = super.onInterceptTouchEvent(e);
207         if (!result && e.getAction() == MotionEvent.ACTION_DOWN
208                 && mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) {
209             mEmptySearchBackground.setHotspot(e.getX(), e.getY());
210         }
211         return result;
212     }
213 
214     /**
215      * Maps the touch (from 0..1) to the adapter position that should be visible.
216      */
217     @Override
scrollToPositionAtProgress(float touchFraction)218     public String scrollToPositionAtProgress(float touchFraction) {
219         int rowCount = mApps.getNumAppRows();
220         if (rowCount == 0) {
221             return "";
222         }
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         mFastScrollHelper.smoothScrollToSection(lastInfo);
237         return lastInfo.sectionName;
238     }
239 
240     @Override
onFastScrollCompleted()241     public void onFastScrollCompleted() {
242         super.onFastScrollCompleted();
243         mFastScrollHelper.onFastScrollCompleted();
244     }
245 
246     @Override
setAdapter(Adapter adapter)247     public void setAdapter(Adapter adapter) {
248         super.setAdapter(adapter);
249         adapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
250             public void onChanged() {
251                 mCachedScrollPositions.clear();
252             }
253         });
254     }
255 
256     @Override
getBottomFadingEdgeStrength()257     protected float getBottomFadingEdgeStrength() {
258         // No bottom fading edge.
259         return 0;
260     }
261 
262     @Override
isPaddingOffsetRequired()263     protected boolean isPaddingOffsetRequired() {
264         return true;
265     }
266 
267     @Override
getTopPaddingOffset()268     protected int getTopPaddingOffset() {
269         return -getPaddingTop();
270     }
271 
272     /**
273      * Updates the bounds for the scrollbar.
274      */
275     @Override
onUpdateScrollbar(int dy)276     public void onUpdateScrollbar(int dy) {
277         if (mApps == null) {
278             return;
279         }
280         List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
281 
282         // Skip early if there are no items or we haven't been measured
283         if (items.isEmpty() || mNumAppsPerRow == 0) {
284             mScrollbar.setThumbOffsetY(-1);
285             return;
286         }
287 
288         // Skip early if, there no child laid out in the container.
289         int scrollY = getCurrentScrollY();
290         if (scrollY < 0) {
291             mScrollbar.setThumbOffsetY(-1);
292             return;
293         }
294 
295         // Only show the scrollbar if there is height to be scrolled
296         int availableScrollBarHeight = getAvailableScrollBarHeight();
297         int availableScrollHeight = getAvailableScrollHeight();
298         if (availableScrollHeight <= 0) {
299             mScrollbar.setThumbOffsetY(-1);
300             return;
301         }
302 
303         if (mScrollbar.isThumbDetached()) {
304             if (!mScrollbar.isDraggingThumb()) {
305                 // Calculate the current scroll position, the scrollY of the recycler view accounts
306                 // for the view padding, while the scrollBarY is drawn right up to the background
307                 // padding (ignoring padding)
308                 int scrollBarY = (int)
309                         (((float) scrollY / availableScrollHeight) * availableScrollBarHeight);
310 
311                 int thumbScrollY = mScrollbar.getThumbOffsetY();
312                 int diffScrollY = scrollBarY - thumbScrollY;
313                 if (diffScrollY * dy > 0f) {
314                     // User is scrolling in the same direction the thumb needs to catch up to the
315                     // current scroll position.  We do this by mapping the difference in movement
316                     // from the original scroll bar position to the difference in movement necessary
317                     // in the detached thumb position to ensure that both speed towards the same
318                     // position at either end of the list.
319                     if (dy < 0) {
320                         int offset = (int) ((dy * thumbScrollY) / (float) scrollBarY);
321                         thumbScrollY += Math.max(offset, diffScrollY);
322                     } else {
323                         int offset = (int) ((dy * (availableScrollBarHeight - thumbScrollY)) /
324                                 (float) (availableScrollBarHeight - scrollBarY));
325                         thumbScrollY += Math.min(offset, diffScrollY);
326                     }
327                     thumbScrollY = Math.max(0, Math.min(availableScrollBarHeight, thumbScrollY));
328                     mScrollbar.setThumbOffsetY(thumbScrollY);
329                     if (scrollBarY == thumbScrollY) {
330                         mScrollbar.reattachThumbToScroll();
331                     }
332                 } else {
333                     // User is scrolling in an opposite direction to the direction that the thumb
334                     // needs to catch up to the scroll position.  Do nothing except for updating
335                     // the scroll bar x to match the thumb width.
336                     mScrollbar.setThumbOffsetY(thumbScrollY);
337                 }
338             }
339         } else {
340             synchronizeScrollBarThumbOffsetToViewScroll(scrollY, availableScrollHeight);
341         }
342     }
343 
344     @Override
supportsFastScrolling()345     public boolean supportsFastScrolling() {
346         // Only allow fast scrolling when the user is not searching, since the results are not
347         // grouped in a meaningful order
348         return !mApps.hasFilter();
349     }
350 
351     @Override
getCurrentScrollY()352     public int getCurrentScrollY() {
353         // Return early if there are no items or we haven't been measured
354         List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
355         if (items.isEmpty() || mNumAppsPerRow == 0 || getChildCount() == 0) {
356             return -1;
357         }
358 
359         // Calculate the y and offset for the item
360         View child = getChildAt(0);
361         int position = getChildPosition(child);
362         if (position == NO_POSITION) {
363             return -1;
364         }
365         return getPaddingTop() +
366                 getCurrentScrollY(position, getLayoutManager().getDecoratedTop(child));
367     }
368 
getCurrentScrollY(int position, int offset)369     public int getCurrentScrollY(int position, int offset) {
370         List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems();
371         AlphabeticalAppsList.AdapterItem posItem = position < items.size() ?
372                 items.get(position) : null;
373         int y = mCachedScrollPositions.get(position, -1);
374         if (y < 0) {
375             y = 0;
376             for (int i = 0; i < position; i++) {
377                 AlphabeticalAppsList.AdapterItem item = items.get(i);
378                 if (AllAppsGridAdapter.isIconViewType(item.viewType)) {
379                     // Break once we reach the desired row
380                     if (posItem != null && posItem.viewType == item.viewType &&
381                             posItem.rowIndex == item.rowIndex) {
382                         break;
383                     }
384                     // Otherwise, only account for the first icon in the row since they are the same
385                     // size within a row
386                     if (item.rowAppIndex == 0) {
387                         y += mViewHeights.get(item.viewType, 0);
388                     }
389                 } else {
390                     // Rest of the views span the full width
391                     int elHeight = mViewHeights.get(item.viewType);
392                     if (elHeight == 0) {
393                         ViewHolder holder = findViewHolderForAdapterPosition(i);
394                         if (holder == null) {
395                             holder = getAdapter().createViewHolder(this, item.viewType);
396                             getAdapter().onBindViewHolder(holder, i);
397                             holder.itemView.measure(UNSPECIFIED, UNSPECIFIED);
398                             elHeight = holder.itemView.getMeasuredHeight();
399 
400                             getRecycledViewPool().putRecycledView(holder);
401                         } else {
402                             elHeight = holder.itemView.getMeasuredHeight();
403                         }
404                     }
405                     y += elHeight;
406                 }
407             }
408             mCachedScrollPositions.put(position, y);
409         }
410         return y - offset;
411     }
412 
413     /**
414      * Returns the available scroll height:
415      *   AvailableScrollHeight = Total height of the all items - last page height
416      */
417     @Override
418     protected int getAvailableScrollHeight() {
419         return getPaddingTop() + getCurrentScrollY(getAdapter().getItemCount(), 0)
420                 - getHeight() + getPaddingBottom();
421     }
422 
423     public int getScrollBarTop() {
424         return getResources().getDimensionPixelOffset(R.dimen.all_apps_header_top_padding);
425     }
426 
427     public RecyclerViewFastScroller getScrollbar() {
428         return mScrollbar;
429     }
430 
431     /**
432      * Updates the bounds of the empty search background.
433      */
434     private void updateEmptySearchBackgroundBounds() {
435         if (mEmptySearchBackground == null) {
436             return;
437         }
438 
439         // Center the empty search background on this new view bounds
440         int x = (getMeasuredWidth() - mEmptySearchBackground.getIntrinsicWidth()) / 2;
441         int y = mEmptySearchBackgroundTopOffset;
442         mEmptySearchBackground.setBounds(x, y,
443                 x + mEmptySearchBackground.getIntrinsicWidth(),
444                 y + mEmptySearchBackground.getIntrinsicHeight());
445     }
446 
447     @Override
448     public boolean hasOverlappingRendering() {
449         return false;
450     }
451 }
452