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