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