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 */
16package com.android.launcher3.allapps;
17
18import android.content.Context;
19import android.content.res.Resources;
20import android.graphics.Canvas;
21import android.graphics.drawable.Drawable;
22import android.support.v7.widget.RecyclerView;
23import android.util.AttributeSet;
24import android.util.SparseIntArray;
25import android.view.MotionEvent;
26import android.view.View;
27
28import com.android.launcher3.BaseRecyclerView;
29import com.android.launcher3.BubbleTextView;
30import com.android.launcher3.DeviceProfile;
31import com.android.launcher3.Launcher;
32import com.android.launcher3.R;
33import com.android.launcher3.config.FeatureFlags;
34import com.android.launcher3.graphics.DrawableFactory;
35import com.android.launcher3.userevent.nano.LauncherLogProto.ContainerType;
36
37import java.util.List;
38
39/**
40 * A RecyclerView with custom fast scroll support for the all apps view.
41 */
42public 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
58    public AllAppsRecyclerView(Context context) {
59        this(context, null);
60    }
61
62    public AllAppsRecyclerView(Context context, AttributeSet attrs) {
63        this(context, attrs, 0);
64    }
65
66    public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) {
67        this(context, attrs, defStyleAttr, 0);
68    }
69
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     */
83    public void setApps(AlphabeticalAppsList apps) {
84        mApps = apps;
85        mFastScrollHelper = new AllAppsFastScrollHelper(this, apps);
86    }
87
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     */
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     */
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
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     */
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
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
175    protected boolean verifyDrawable(Drawable who) {
176        return who == mEmptySearchBackground || super.verifyDrawable(who);
177    }
178
179    @Override
180    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
181        updateEmptySearchBackgroundBounds();
182    }
183
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
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
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
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
266    public void onFastScrollCompleted() {
267        super.onFastScrollCompleted();
268        mFastScrollHelper.onFastScrollCompleted();
269    }
270
271    @Override
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
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
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
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
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