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.annotation.SuppressLint;
19import android.content.Context;
20import android.content.res.Resources;
21import android.graphics.Point;
22import android.graphics.Rect;
23import android.support.v7.widget.RecyclerView;
24import android.text.Selection;
25import android.text.SpannableStringBuilder;
26import android.text.method.TextKeyListener;
27import android.util.AttributeSet;
28import android.view.KeyEvent;
29import android.view.LayoutInflater;
30import android.view.MotionEvent;
31import android.view.View;
32import android.view.ViewConfiguration;
33
34import com.android.launcher3.AppInfo;
35import com.android.launcher3.BaseContainerView;
36import com.android.launcher3.BubbleTextView;
37import com.android.launcher3.CellLayout;
38import com.android.launcher3.DeleteDropTarget;
39import com.android.launcher3.DeviceProfile;
40import com.android.launcher3.DragSource;
41import com.android.launcher3.DropTarget;
42import com.android.launcher3.ExtendedEditText;
43import com.android.launcher3.Folder;
44import com.android.launcher3.ItemInfo;
45import com.android.launcher3.Launcher;
46import com.android.launcher3.LauncherTransitionable;
47import com.android.launcher3.R;
48import com.android.launcher3.Utilities;
49import com.android.launcher3.Workspace;
50import com.android.launcher3.util.ComponentKey;
51
52import java.nio.charset.Charset;
53import java.nio.charset.CharsetEncoder;
54import java.util.ArrayList;
55import java.util.List;
56
57
58
59/**
60 * A merge algorithm that merges every section indiscriminately.
61 */
62final class FullMergeAlgorithm implements AlphabeticalAppsList.MergeAlgorithm {
63
64    @Override
65    public boolean continueMerging(AlphabeticalAppsList.SectionInfo section,
66           AlphabeticalAppsList.SectionInfo withSection,
67           int sectionAppCount, int numAppsPerRow, int mergeCount) {
68        // Don't merge the predicted apps
69        if (section.firstAppItem.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE) {
70            return false;
71        }
72        // Otherwise, merge every other section
73        return true;
74    }
75}
76
77/**
78 * The logic we use to merge multiple sections.  We only merge sections when their final row
79 * contains less than a certain number of icons, and stop at a specified max number of merges.
80 * In addition, we will try and not merge sections that identify apps from different scripts.
81 */
82final class SimpleSectionMergeAlgorithm implements AlphabeticalAppsList.MergeAlgorithm {
83
84    private int mMinAppsPerRow;
85    private int mMinRowsInMergedSection;
86    private int mMaxAllowableMerges;
87    private CharsetEncoder mAsciiEncoder;
88
89    public SimpleSectionMergeAlgorithm(int minAppsPerRow, int minRowsInMergedSection, int maxNumMerges) {
90        mMinAppsPerRow = minAppsPerRow;
91        mMinRowsInMergedSection = minRowsInMergedSection;
92        mMaxAllowableMerges = maxNumMerges;
93        mAsciiEncoder = Charset.forName("US-ASCII").newEncoder();
94    }
95
96    @Override
97    public boolean continueMerging(AlphabeticalAppsList.SectionInfo section,
98           AlphabeticalAppsList.SectionInfo withSection,
99           int sectionAppCount, int numAppsPerRow, int mergeCount) {
100        // Don't merge the predicted apps
101        if (section.firstAppItem.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE) {
102            return false;
103        }
104
105        // Continue merging if the number of hanging apps on the final row is less than some
106        // fixed number (ragged), the merged rows has yet to exceed some minimum row count,
107        // and while the number of merged sections is less than some fixed number of merges
108        int rows = sectionAppCount / numAppsPerRow;
109        int cols = sectionAppCount % numAppsPerRow;
110
111        // Ensure that we do not merge across scripts, currently we only allow for english and
112        // native scripts so we can test if both can just be ascii encoded
113        boolean isCrossScript = false;
114        if (section.firstAppItem != null && withSection.firstAppItem != null) {
115            isCrossScript = mAsciiEncoder.canEncode(section.firstAppItem.sectionName) !=
116                    mAsciiEncoder.canEncode(withSection.firstAppItem.sectionName);
117        }
118        return (0 < cols && cols < mMinAppsPerRow) &&
119                rows < mMinRowsInMergedSection &&
120                mergeCount < mMaxAllowableMerges &&
121                !isCrossScript;
122    }
123}
124
125/**
126 * The all apps view container.
127 */
128public class AllAppsContainerView extends BaseContainerView implements DragSource,
129        LauncherTransitionable, View.OnTouchListener, View.OnLongClickListener,
130        AllAppsSearchBarController.Callbacks {
131
132    private static final int MIN_ROWS_IN_MERGED_SECTION_PHONE = 3;
133    private static final int MAX_NUM_MERGES_PHONE = 2;
134
135    private final Launcher mLauncher;
136    private final AlphabeticalAppsList mApps;
137    private final AllAppsGridAdapter mAdapter;
138    private final RecyclerView.LayoutManager mLayoutManager;
139    private final RecyclerView.ItemDecoration mItemDecoration;
140
141    // The computed bounds of the container
142    private final Rect mContentBounds = new Rect();
143
144    private AllAppsRecyclerView mAppsRecyclerView;
145    private AllAppsSearchBarController mSearchBarController;
146
147    private View mSearchContainer;
148    private ExtendedEditText mSearchInput;
149    private HeaderElevationController mElevationController;
150
151    private SpannableStringBuilder mSearchQueryBuilder = null;
152
153    private int mSectionNamesMargin;
154    private int mNumAppsPerRow;
155    private int mNumPredictedAppsPerRow;
156    private int mRecyclerViewTopBottomPadding;
157    // This coordinate is relative to this container view
158    private final Point mBoundsCheckLastTouchDownPos = new Point(-1, -1);
159    // This coordinate is relative to its parent
160    private final Point mIconLastTouchPos = new Point();
161
162    public AllAppsContainerView(Context context) {
163        this(context, null);
164    }
165
166    public AllAppsContainerView(Context context, AttributeSet attrs) {
167        this(context, attrs, 0);
168    }
169
170    public AllAppsContainerView(Context context, AttributeSet attrs, int defStyleAttr) {
171        super(context, attrs, defStyleAttr);
172        Resources res = context.getResources();
173
174        mLauncher = (Launcher) context;
175        mSectionNamesMargin = res.getDimensionPixelSize(R.dimen.all_apps_grid_view_start_margin);
176        mApps = new AlphabeticalAppsList(context);
177        mAdapter = new AllAppsGridAdapter(mLauncher, mApps, this, mLauncher, this);
178        mApps.setAdapter(mAdapter);
179        mLayoutManager = mAdapter.getLayoutManager();
180        mItemDecoration = mAdapter.getItemDecoration();
181        mRecyclerViewTopBottomPadding =
182                res.getDimensionPixelSize(R.dimen.all_apps_list_top_bottom_padding);
183
184        mSearchQueryBuilder = new SpannableStringBuilder();
185        Selection.setSelection(mSearchQueryBuilder, 0);
186    }
187
188    /**
189     * Sets the current set of predicted apps.
190     */
191    public void setPredictedApps(List<ComponentKey> apps) {
192        mApps.setPredictedApps(apps);
193    }
194
195    /**
196     * Sets the current set of apps.
197     */
198    public void setApps(List<AppInfo> apps) {
199        mApps.setApps(apps);
200    }
201
202    /**
203     * Adds new apps to the list.
204     */
205    public void addApps(List<AppInfo> apps) {
206        mApps.addApps(apps);
207    }
208
209    /**
210     * Updates existing apps in the list
211     */
212    public void updateApps(List<AppInfo> apps) {
213        mApps.updateApps(apps);
214    }
215
216    /**
217     * Removes some apps from the list.
218     */
219    public void removeApps(List<AppInfo> apps) {
220        mApps.removeApps(apps);
221    }
222
223    /**
224     * Sets the search bar that shows above the a-z list.
225     */
226    public void setSearchBarController(AllAppsSearchBarController searchController) {
227        if (mSearchBarController != null) {
228            throw new RuntimeException("Expected search bar controller to only be set once");
229        }
230        mSearchBarController = searchController;
231        mSearchBarController.initialize(mApps, mSearchInput, mLauncher, this);
232        mAdapter.setSearchController(mSearchBarController);
233
234        updateBackgroundAndPaddings();
235    }
236
237    /**
238     * Scrolls this list view to the top.
239     */
240    public void scrollToTop() {
241        mAppsRecyclerView.scrollToTop();
242    }
243
244    /**
245     * Focuses the search field and begins an app search.
246     */
247    public void startAppsSearch() {
248        if (mSearchBarController != null) {
249            mSearchBarController.focusSearchField();
250        }
251    }
252
253    /**
254     * Resets the state of AllApps.
255     */
256    public void reset() {
257        // Reset the search bar and base recycler view after transitioning home
258        mSearchBarController.reset();
259        mAppsRecyclerView.reset();
260    }
261
262    @Override
263    protected void onFinishInflate() {
264        super.onFinishInflate();
265
266        // This is a focus listener that proxies focus from a view into the list view.  This is to
267        // work around the search box from getting first focus and showing the cursor.
268        getContentView().setOnFocusChangeListener(new View.OnFocusChangeListener() {
269            @Override
270            public void onFocusChange(View v, boolean hasFocus) {
271                if (hasFocus) {
272                    mAppsRecyclerView.requestFocus();
273                }
274            }
275        });
276
277        mSearchContainer = findViewById(R.id.search_container);
278        mSearchInput = (ExtendedEditText) findViewById(R.id.search_box_input);
279        mElevationController = Utilities.ATLEAST_LOLLIPOP
280                ? new HeaderElevationController.ControllerVL(mSearchContainer)
281                : new HeaderElevationController.ControllerV16(mSearchContainer);
282
283        // Load the all apps recycler view
284        mAppsRecyclerView = (AllAppsRecyclerView) findViewById(R.id.apps_list_view);
285        mAppsRecyclerView.setApps(mApps);
286        mAppsRecyclerView.setLayoutManager(mLayoutManager);
287        mAppsRecyclerView.setAdapter(mAdapter);
288        mAppsRecyclerView.setHasFixedSize(true);
289        mAppsRecyclerView.addOnScrollListener(mElevationController);
290        mAppsRecyclerView.setElevationController(mElevationController);
291
292        if (mItemDecoration != null) {
293            mAppsRecyclerView.addItemDecoration(mItemDecoration);
294        }
295
296        // Precalculate the prediction icon and normal icon sizes
297        LayoutInflater layoutInflater = LayoutInflater.from(getContext());
298        final int widthMeasureSpec = MeasureSpec.makeMeasureSpec(
299                getResources().getDisplayMetrics().widthPixels, MeasureSpec.AT_MOST);
300        final int heightMeasureSpec = MeasureSpec.makeMeasureSpec(
301                getResources().getDisplayMetrics().heightPixels, MeasureSpec.AT_MOST);
302
303        BubbleTextView icon = (BubbleTextView) layoutInflater.inflate(
304                R.layout.all_apps_icon, this, false);
305        icon.applyDummyInfo();
306        icon.measure(widthMeasureSpec, heightMeasureSpec);
307        BubbleTextView predIcon = (BubbleTextView) layoutInflater.inflate(
308                R.layout.all_apps_prediction_bar_icon, this, false);
309        predIcon.applyDummyInfo();
310        predIcon.measure(widthMeasureSpec, heightMeasureSpec);
311        mAppsRecyclerView.setPremeasuredIconHeights(predIcon.getMeasuredHeight(),
312                icon.getMeasuredHeight());
313
314        updateBackgroundAndPaddings();
315    }
316
317    @Override
318    public void onBoundsChanged(Rect newBounds) {
319        mLauncher.updateOverlayBounds(newBounds);
320    }
321
322    @Override
323    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
324        mContentBounds.set(mContentPadding.left, mContentPadding.top,
325                MeasureSpec.getSize(widthMeasureSpec) - mContentPadding.right,
326                MeasureSpec.getSize(heightMeasureSpec) - mContentPadding.bottom);
327
328        // Update the number of items in the grid before we measure the view
329        // TODO: mSectionNamesMargin is currently 0, but also account for it,
330        // if it's enabled in the future.
331        int availableWidth = (!mContentBounds.isEmpty() ? mContentBounds.width() :
332                MeasureSpec.getSize(widthMeasureSpec))
333                    - 2 * mAppsRecyclerView.getMaxScrollbarWidth();
334        DeviceProfile grid = mLauncher.getDeviceProfile();
335        grid.updateAppsViewNumCols(getResources(), availableWidth);
336        if (mNumAppsPerRow != grid.allAppsNumCols ||
337                mNumPredictedAppsPerRow != grid.allAppsNumPredictiveCols) {
338            mNumAppsPerRow = grid.allAppsNumCols;
339            mNumPredictedAppsPerRow = grid.allAppsNumPredictiveCols;
340
341            // If there is a start margin to draw section names, determine how we are going to merge
342            // app sections
343            boolean mergeSectionsFully = mSectionNamesMargin == 0 || !grid.isPhone;
344            AlphabeticalAppsList.MergeAlgorithm mergeAlgorithm = mergeSectionsFully ?
345                    new FullMergeAlgorithm() :
346                    new SimpleSectionMergeAlgorithm((int) Math.ceil(mNumAppsPerRow / 2f),
347                            MIN_ROWS_IN_MERGED_SECTION_PHONE, MAX_NUM_MERGES_PHONE);
348
349            mAppsRecyclerView.setNumAppsPerRow(grid, mNumAppsPerRow);
350            mAdapter.setNumAppsPerRow(mNumAppsPerRow);
351            mApps.setNumAppsPerRow(mNumAppsPerRow, mNumPredictedAppsPerRow, mergeAlgorithm);
352
353            if (mNumAppsPerRow > 0) {
354                int iconSize = availableWidth / mNumAppsPerRow;
355                int iconSpacing = (iconSize - grid.allAppsIconSizePx) / 2;
356                mSearchInput.setPaddingRelative(iconSpacing, 0, iconSpacing, 0);
357            }
358        }
359
360        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
361    }
362
363    /**
364     * Update the background and padding of the Apps view and children.  Instead of insetting the
365     * container view, we inset the background and padding of the recycler view to allow for the
366     * recycler view to handle touch events (for fast scrolling) all the way to the edge.
367     */
368    @Override
369    protected void onUpdateBgPadding(Rect padding, Rect bgPadding) {
370        mAppsRecyclerView.updateBackgroundPadding(bgPadding);
371        mAdapter.updateBackgroundPadding(bgPadding);
372        mElevationController.updateBackgroundPadding(bgPadding);
373
374        // Pad the recycler view by the background padding plus the start margin (for the section
375        // names)
376        int maxScrollBarWidth = mAppsRecyclerView.getMaxScrollbarWidth();
377        int startInset = Math.max(mSectionNamesMargin, maxScrollBarWidth);
378        int topBottomPadding = mRecyclerViewTopBottomPadding;
379        if (Utilities.isRtl(getResources())) {
380            mAppsRecyclerView.setPadding(padding.left + maxScrollBarWidth,
381                    topBottomPadding, padding.right + startInset, topBottomPadding);
382        } else {
383            mAppsRecyclerView.setPadding(padding.left + startInset, topBottomPadding,
384                    padding.right + maxScrollBarWidth, topBottomPadding);
385        }
386
387        MarginLayoutParams lp = (MarginLayoutParams) mSearchContainer.getLayoutParams();
388        lp.leftMargin = padding.left;
389        lp.rightMargin = padding.right;
390        mSearchContainer.setLayoutParams(lp);
391    }
392
393    @Override
394    public boolean dispatchKeyEvent(KeyEvent event) {
395        // Determine if the key event was actual text, if so, focus the search bar and then dispatch
396        // the key normally so that it can process this key event
397        if (!mSearchBarController.isSearchFieldFocused() &&
398                event.getAction() == KeyEvent.ACTION_DOWN) {
399            final int unicodeChar = event.getUnicodeChar();
400            final boolean isKeyNotWhitespace = unicodeChar > 0 &&
401                    !Character.isWhitespace(unicodeChar) && !Character.isSpaceChar(unicodeChar);
402            if (isKeyNotWhitespace) {
403                boolean gotKey = TextKeyListener.getInstance().onKeyDown(this, mSearchQueryBuilder,
404                        event.getKeyCode(), event);
405                if (gotKey && mSearchQueryBuilder.length() > 0) {
406                    mSearchBarController.focusSearchField();
407                }
408            }
409        }
410
411        return super.dispatchKeyEvent(event);
412    }
413
414    @Override
415    public boolean onInterceptTouchEvent(MotionEvent ev) {
416        return handleTouchEvent(ev);
417    }
418
419    @SuppressLint("ClickableViewAccessibility")
420    @Override
421    public boolean onTouchEvent(MotionEvent ev) {
422        return handleTouchEvent(ev);
423    }
424
425    @SuppressLint("ClickableViewAccessibility")
426    @Override
427    public boolean onTouch(View v, MotionEvent ev) {
428        switch (ev.getAction()) {
429            case MotionEvent.ACTION_DOWN:
430            case MotionEvent.ACTION_MOVE:
431                mIconLastTouchPos.set((int) ev.getX(), (int) ev.getY());
432                break;
433        }
434        return false;
435    }
436
437    @Override
438    public boolean onLongClick(View v) {
439        // Return early if this is not initiated from a touch
440        if (!v.isInTouchMode()) return false;
441        // When we have exited all apps or are in transition, disregard long clicks
442        if (!mLauncher.isAppsViewVisible() ||
443                mLauncher.getWorkspace().isSwitchingState()) return false;
444        // Return if global dragging is not enabled
445        if (!mLauncher.isDraggingEnabled()) return false;
446
447        // Start the drag
448        mLauncher.getWorkspace().beginDragShared(v, mIconLastTouchPos, this, false);
449        // Enter spring loaded mode
450        mLauncher.enterSpringLoadedDragMode();
451
452        return false;
453    }
454
455    @Override
456    public boolean supportsFlingToDelete() {
457        return true;
458    }
459
460    @Override
461    public boolean supportsAppInfoDropTarget() {
462        return true;
463    }
464
465    @Override
466    public boolean supportsDeleteDropTarget() {
467        return false;
468    }
469
470    @Override
471    public float getIntrinsicIconScaleFactor() {
472        DeviceProfile grid = mLauncher.getDeviceProfile();
473        return (float) grid.allAppsIconSizePx / grid.iconSizePx;
474    }
475
476    @Override
477    public void onFlingToDeleteCompleted() {
478        // We just dismiss the drag when we fling, so cleanup here
479        mLauncher.exitSpringLoadedDragModeDelayed(true,
480                Launcher.EXIT_SPRINGLOADED_MODE_SHORT_TIMEOUT, null);
481        mLauncher.unlockScreenOrientation(false);
482    }
483
484    @Override
485    public void onDropCompleted(View target, DropTarget.DragObject d, boolean isFlingToDelete,
486            boolean success) {
487        if (isFlingToDelete || !success || (target != mLauncher.getWorkspace() &&
488                !(target instanceof DeleteDropTarget) && !(target instanceof Folder))) {
489            // Exit spring loaded mode if we have not successfully dropped or have not handled the
490            // drop in Workspace
491            mLauncher.exitSpringLoadedDragModeDelayed(true,
492                    Launcher.EXIT_SPRINGLOADED_MODE_SHORT_TIMEOUT, null);
493        }
494        mLauncher.unlockScreenOrientation(false);
495
496        // Display an error message if the drag failed due to there not being enough space on the
497        // target layout we were dropping on.
498        if (!success) {
499            boolean showOutOfSpaceMessage = false;
500            if (target instanceof Workspace) {
501                int currentScreen = mLauncher.getCurrentWorkspaceScreen();
502                Workspace workspace = (Workspace) target;
503                CellLayout layout = (CellLayout) workspace.getChildAt(currentScreen);
504                ItemInfo itemInfo = (ItemInfo) d.dragInfo;
505                if (layout != null) {
506                    showOutOfSpaceMessage =
507                            !layout.findCellForSpan(null, itemInfo.spanX, itemInfo.spanY);
508                }
509            }
510            if (showOutOfSpaceMessage) {
511                mLauncher.showOutOfSpaceMessage(false);
512            }
513
514            d.deferDragViewCleanupPostAnimation = false;
515        }
516    }
517
518    @Override
519    public void onLauncherTransitionPrepare(Launcher l, boolean animated, boolean toWorkspace) {
520        // Do nothing
521    }
522
523    @Override
524    public void onLauncherTransitionStart(Launcher l, boolean animated, boolean toWorkspace) {
525        // Do nothing
526    }
527
528    @Override
529    public void onLauncherTransitionStep(Launcher l, float t) {
530        // Do nothing
531    }
532
533    @Override
534    public void onLauncherTransitionEnd(Launcher l, boolean animated, boolean toWorkspace) {
535        if (toWorkspace) {
536            reset();
537        }
538    }
539
540    /**
541     * Handles the touch events to dismiss all apps when clicking outside the bounds of the
542     * recycler view.
543     */
544    private boolean handleTouchEvent(MotionEvent ev) {
545        DeviceProfile grid = mLauncher.getDeviceProfile();
546        int x = (int) ev.getX();
547        int y = (int) ev.getY();
548
549        switch (ev.getAction()) {
550            case MotionEvent.ACTION_DOWN:
551                if (!mContentBounds.isEmpty()) {
552                    // Outset the fixed bounds and check if the touch is outside all apps
553                    Rect tmpRect = new Rect(mContentBounds);
554                    tmpRect.inset(-grid.allAppsIconSizePx / 2, 0);
555                    if (ev.getX() < tmpRect.left || ev.getX() > tmpRect.right) {
556                        mBoundsCheckLastTouchDownPos.set(x, y);
557                        return true;
558                    }
559                } else {
560                    // Check if the touch is outside all apps
561                    if (ev.getX() < getPaddingLeft() ||
562                            ev.getX() > (getWidth() - getPaddingRight())) {
563                        mBoundsCheckLastTouchDownPos.set(x, y);
564                        return true;
565                    }
566                }
567                break;
568            case MotionEvent.ACTION_UP:
569                if (mBoundsCheckLastTouchDownPos.x > -1) {
570                    ViewConfiguration viewConfig = ViewConfiguration.get(getContext());
571                    float dx = ev.getX() - mBoundsCheckLastTouchDownPos.x;
572                    float dy = ev.getY() - mBoundsCheckLastTouchDownPos.y;
573                    float distance = (float) Math.hypot(dx, dy);
574                    if (distance < viewConfig.getScaledTouchSlop()) {
575                        // The background was clicked, so just go home
576                        Launcher launcher = (Launcher) getContext();
577                        launcher.showWorkspace(true);
578                        return true;
579                    }
580                }
581                // Fall through
582            case MotionEvent.ACTION_CANCEL:
583                mBoundsCheckLastTouchDownPos.set(-1, -1);
584                break;
585        }
586        return false;
587    }
588
589    @Override
590    public void onSearchResult(String query, ArrayList<ComponentKey> apps) {
591        if (apps != null) {
592            if (mApps.setOrderedFilter(apps)) {
593                mAppsRecyclerView.onSearchResultsChanged();
594            }
595            mAdapter.setLastSearchQuery(query);
596        }
597    }
598
599    @Override
600    public void clearSearchResult() {
601        if (mApps.setOrderedFilter(null)) {
602            mAppsRecyclerView.onSearchResultsChanged();
603        }
604
605        // Clear the search query
606        mSearchQueryBuilder.clear();
607        mSearchQueryBuilder.clearSpans();
608        Selection.setSelection(mSearchQueryBuilder, 0);
609    }
610}
611