diff options
Diffstat (limited to 'src/com/android/launcher3/allapps')
9 files changed, 3031 insertions, 0 deletions
diff --git a/src/com/android/launcher3/allapps/AllAppsBackgroundDrawable.java b/src/com/android/launcher3/allapps/AllAppsBackgroundDrawable.java new file mode 100644 index 000000000..117aca921 --- /dev/null +++ b/src/com/android/launcher3/allapps/AllAppsBackgroundDrawable.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; + +import android.view.Gravity; +import com.android.launcher3.R; + +/** + * A helper class to positon and orient a drawable to be drawn. + */ +class TransformedImageDrawable { + private Drawable mImage; + private float mXPercent; + private float mYPercent; + private int mGravity; + + /** + * @param gravity If one of the Gravity center values, the x and y offset will take the width + * and height of the image into account to center the image to the offset. + */ + public TransformedImageDrawable(Resources res, int resourceId, float xPct, float yPct, + int gravity) { + mImage = res.getDrawable(resourceId); + mXPercent = xPct; + mYPercent = yPct; + mGravity = gravity; + } + + public void setAlpha(int alpha) { + mImage.setAlpha(alpha); + } + + public int getAlpha() { + return mImage.getAlpha(); + } + + public void updateBounds(Rect bounds) { + int width = mImage.getIntrinsicWidth(); + int height = mImage.getIntrinsicHeight(); + int left = bounds.left + (int) (mXPercent * bounds.width()); + int top = bounds.top + (int) (mYPercent * bounds.height()); + if ((mGravity & Gravity.CENTER_HORIZONTAL) == Gravity.CENTER_HORIZONTAL) { + left -= (width / 2); + } + if ((mGravity & Gravity.CENTER_VERTICAL) == Gravity.CENTER_VERTICAL) { + top -= (height / 2); + } + mImage.setBounds(left, top, left + width, top + height); + } + + public void draw(Canvas canvas) { + int c = canvas.save(Canvas.MATRIX_SAVE_FLAG); + mImage.draw(canvas); + canvas.restoreToCount(c); + } +} + +/** + * This is a custom composite drawable that has a fixed virtual size and dynamically lays out its + * children images relatively within its bounds. This way, we can reduce the memory usage of a + * single, large sparsely populated image. + */ +public class AllAppsBackgroundDrawable extends Drawable { + + private final TransformedImageDrawable mHand; + private final TransformedImageDrawable[] mIcons; + private final int mWidth; + private final int mHeight; + + private ObjectAnimator mBackgroundAnim; + + public AllAppsBackgroundDrawable(Context context) { + Resources res = context.getResources(); + mHand = new TransformedImageDrawable(res, R.drawable.ic_all_apps_bg_hand, + 0.575f, 0.1f, Gravity.CENTER_HORIZONTAL); + mIcons = new TransformedImageDrawable[4]; + mIcons[0] = new TransformedImageDrawable(res, R.drawable.ic_all_apps_bg_icon_1, + 0.375f, 0, Gravity.CENTER_HORIZONTAL); + mIcons[1] = new TransformedImageDrawable(res, R.drawable.ic_all_apps_bg_icon_2, + 0.3125f, 0.25f, Gravity.CENTER_HORIZONTAL); + mIcons[2] = new TransformedImageDrawable(res, R.drawable.ic_all_apps_bg_icon_3, + 0.475f, 0.4f, Gravity.CENTER_HORIZONTAL); + mIcons[3] = new TransformedImageDrawable(res, R.drawable.ic_all_apps_bg_icon_4, + 0.7f, 0.125f, Gravity.CENTER_HORIZONTAL); + mWidth = res.getDimensionPixelSize(R.dimen.all_apps_background_canvas_width); + mHeight = res.getDimensionPixelSize(R.dimen.all_apps_background_canvas_height); + } + + /** + * Animates the background alpha. + */ + public void animateBgAlpha(float finalAlpha, int duration) { + int finalAlphaI = (int) (finalAlpha * 255f); + if (getAlpha() != finalAlphaI) { + mBackgroundAnim = cancelAnimator(mBackgroundAnim); + mBackgroundAnim = ObjectAnimator.ofInt(this, "alpha", finalAlphaI); + mBackgroundAnim.setDuration(duration); + mBackgroundAnim.start(); + } + } + + /** + * Sets the background alpha immediately. + */ + public void setBgAlpha(float finalAlpha) { + int finalAlphaI = (int) (finalAlpha * 255f); + if (getAlpha() != finalAlphaI) { + mBackgroundAnim = cancelAnimator(mBackgroundAnim); + setAlpha(finalAlphaI); + } + } + + @Override + public int getIntrinsicWidth() { + return mWidth; + } + + @Override + public int getIntrinsicHeight() { + return mHeight; + } + + @Override + public void draw(Canvas canvas) { + mHand.draw(canvas); + for (int i = 0; i < mIcons.length; i++) { + mIcons[i].draw(canvas); + } + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + mHand.updateBounds(bounds); + for (int i = 0; i < mIcons.length; i++) { + mIcons[i].updateBounds(bounds); + } + invalidateSelf(); + } + + @Override + public void setAlpha(int alpha) { + mHand.setAlpha(alpha); + for (int i = 0; i < mIcons.length; i++) { + mIcons[i].setAlpha(alpha); + } + invalidateSelf(); + } + + @Override + public int getAlpha() { + return mHand.getAlpha(); + } + + @Override + public void setColorFilter(ColorFilter colorFilter) { + // Do nothing + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSLUCENT; + } + + private ObjectAnimator cancelAnimator(ObjectAnimator animator) { + if (animator != null) { + animator.removeAllListeners(); + animator.cancel(); + } + return null; + } +} diff --git a/src/com/android/launcher3/allapps/AllAppsContainerView.java b/src/com/android/launcher3/allapps/AllAppsContainerView.java new file mode 100644 index 000000000..88c6acada --- /dev/null +++ b/src/com/android/launcher3/allapps/AllAppsContainerView.java @@ -0,0 +1,632 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.InsetDrawable; +import android.support.v7.widget.RecyclerView; +import android.text.Selection; +import android.text.SpannableStringBuilder; +import android.text.method.TextKeyListener; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.LinearLayout; +import com.android.launcher3.AppInfo; +import com.android.launcher3.BaseContainerView; +import com.android.launcher3.CellLayout; +import com.android.launcher3.DeleteDropTarget; +import com.android.launcher3.DeviceProfile; +import com.android.launcher3.DragSource; +import com.android.launcher3.DropTarget; +import com.android.launcher3.Folder; +import com.android.launcher3.ItemInfo; +import com.android.launcher3.Launcher; +import com.android.launcher3.LauncherTransitionable; +import com.android.launcher3.R; +import com.android.launcher3.Utilities; +import com.android.launcher3.Workspace; +import com.android.launcher3.util.ComponentKey; +import com.android.launcher3.util.Thunk; + +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.util.ArrayList; +import java.util.List; + + + +/** + * A merge algorithm that merges every section indiscriminately. + */ +final class FullMergeAlgorithm implements AlphabeticalAppsList.MergeAlgorithm { + + @Override + public boolean continueMerging(AlphabeticalAppsList.SectionInfo section, + AlphabeticalAppsList.SectionInfo withSection, + int sectionAppCount, int numAppsPerRow, int mergeCount) { + // Don't merge the predicted apps + if (section.firstAppItem.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE) { + return false; + } + // Otherwise, merge every other section + return true; + } +} + +/** + * The logic we use to merge multiple sections. We only merge sections when their final row + * contains less than a certain number of icons, and stop at a specified max number of merges. + * In addition, we will try and not merge sections that identify apps from different scripts. + */ +final class SimpleSectionMergeAlgorithm implements AlphabeticalAppsList.MergeAlgorithm { + + private int mMinAppsPerRow; + private int mMinRowsInMergedSection; + private int mMaxAllowableMerges; + private CharsetEncoder mAsciiEncoder; + + public SimpleSectionMergeAlgorithm(int minAppsPerRow, int minRowsInMergedSection, int maxNumMerges) { + mMinAppsPerRow = minAppsPerRow; + mMinRowsInMergedSection = minRowsInMergedSection; + mMaxAllowableMerges = maxNumMerges; + mAsciiEncoder = Charset.forName("US-ASCII").newEncoder(); + } + + @Override + public boolean continueMerging(AlphabeticalAppsList.SectionInfo section, + AlphabeticalAppsList.SectionInfo withSection, + int sectionAppCount, int numAppsPerRow, int mergeCount) { + // Don't merge the predicted apps + if (section.firstAppItem.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE) { + return false; + } + + // Continue merging if the number of hanging apps on the final row is less than some + // fixed number (ragged), the merged rows has yet to exceed some minimum row count, + // and while the number of merged sections is less than some fixed number of merges + int rows = sectionAppCount / numAppsPerRow; + int cols = sectionAppCount % numAppsPerRow; + + // Ensure that we do not merge across scripts, currently we only allow for english and + // native scripts so we can test if both can just be ascii encoded + boolean isCrossScript = false; + if (section.firstAppItem != null && withSection.firstAppItem != null) { + isCrossScript = mAsciiEncoder.canEncode(section.firstAppItem.sectionName) != + mAsciiEncoder.canEncode(withSection.firstAppItem.sectionName); + } + return (0 < cols && cols < mMinAppsPerRow) && + rows < mMinRowsInMergedSection && + mergeCount < mMaxAllowableMerges && + !isCrossScript; + } +} + +/** + * The all apps view container. + */ +public class AllAppsContainerView extends BaseContainerView implements DragSource, + LauncherTransitionable, View.OnTouchListener, View.OnLongClickListener, + AllAppsSearchBarController.Callbacks { + + private static final int MIN_ROWS_IN_MERGED_SECTION_PHONE = 3; + private static final int MAX_NUM_MERGES_PHONE = 2; + + @Thunk Launcher mLauncher; + @Thunk AlphabeticalAppsList mApps; + private AllAppsGridAdapter mAdapter; + private RecyclerView.LayoutManager mLayoutManager; + private RecyclerView.ItemDecoration mItemDecoration; + + @Thunk View mContent; + @Thunk View mContainerView; + @Thunk View mRevealView; + @Thunk AllAppsRecyclerView mAppsRecyclerView; + @Thunk AllAppsSearchBarController mSearchBarController; + private ViewGroup mSearchBarContainerView; + private View mSearchBarView; + private SpannableStringBuilder mSearchQueryBuilder = null; + + private int mSectionNamesMargin; + private int mNumAppsPerRow; + private int mNumPredictedAppsPerRow; + private int mRecyclerViewTopBottomPadding; + // This coordinate is relative to this container view + private final Point mBoundsCheckLastTouchDownPos = new Point(-1, -1); + // This coordinate is relative to its parent + private final Point mIconLastTouchPos = new Point(); + + private View.OnClickListener mSearchClickListener = new View.OnClickListener() { + @Override + public void onClick(View v) { + Intent searchIntent = (Intent) v.getTag(); + mLauncher.startActivitySafely(v, searchIntent, null); + } + }; + + public AllAppsContainerView(Context context) { + this(context, null); + } + + public AllAppsContainerView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AllAppsContainerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + Resources res = context.getResources(); + + mLauncher = (Launcher) context; + mSectionNamesMargin = res.getDimensionPixelSize(R.dimen.all_apps_grid_view_start_margin); + mApps = new AlphabeticalAppsList(context); + mAdapter = new AllAppsGridAdapter(mLauncher, mApps, this, mLauncher, this); + mApps.setAdapter(mAdapter); + mLayoutManager = mAdapter.getLayoutManager(); + mItemDecoration = mAdapter.getItemDecoration(); + mRecyclerViewTopBottomPadding = + res.getDimensionPixelSize(R.dimen.all_apps_list_top_bottom_padding); + + mSearchQueryBuilder = new SpannableStringBuilder(); + Selection.setSelection(mSearchQueryBuilder, 0); + } + + /** + * Sets the current set of predicted apps. + */ + public void setPredictedApps(List<ComponentKey> apps) { + mApps.setPredictedApps(apps); + } + + /** + * Sets the current set of apps. + */ + public void setApps(List<AppInfo> apps) { + mApps.setApps(apps); + } + + /** + * Adds new apps to the list. + */ + public void addApps(List<AppInfo> apps) { + mApps.addApps(apps); + } + + /** + * Updates existing apps in the list + */ + public void updateApps(List<AppInfo> apps) { + mApps.updateApps(apps); + } + + /** + * Removes some apps from the list. + */ + public void removeApps(List<AppInfo> apps) { + mApps.removeApps(apps); + } + + /** + * Sets the search bar that shows above the a-z list. + */ + public void setSearchBarController(AllAppsSearchBarController searchController) { + if (mSearchBarController != null) { + throw new RuntimeException("Expected search bar controller to only be set once"); + } + mSearchBarController = searchController; + mSearchBarController.initialize(mApps, this); + + // Add the new search view to the layout + View searchBarView = searchController.getView(mSearchBarContainerView); + mSearchBarContainerView.addView(searchBarView); + mSearchBarContainerView.setVisibility(View.VISIBLE); + mSearchBarView = searchBarView; + setHasSearchBar(); + + updateBackgroundAndPaddings(); + } + + /** + * Scrolls this list view to the top. + */ + public void scrollToTop() { + mAppsRecyclerView.scrollToTop(); + } + + /** + * Returns the content view used for the launcher transitions. + */ + public View getContentView() { + return mContainerView; + } + + /** + * Returns the all apps search view. + */ + public View getSearchBarView() { + return mSearchBarView; + } + + /** + * Returns the reveal view used for the launcher transitions. + */ + public View getRevealView() { + return mRevealView; + } + + /** + * Returns an new instance of the default app search controller. + */ + public AllAppsSearchBarController newDefaultAppSearchController() { + return new DefaultAppSearchController(getContext(), this, mAppsRecyclerView); + } + + /** + * Focuses the search field and begins an app search. + */ + public void startAppsSearch() { + if (mSearchBarController != null) { + mSearchBarController.focusSearchField(); + } + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + boolean isRtl = Utilities.isRtl(getResources()); + mAdapter.setRtl(isRtl); + mContent = findViewById(R.id.content); + + // This is a focus listener that proxies focus from a view into the list view. This is to + // work around the search box from getting first focus and showing the cursor. + View.OnFocusChangeListener focusProxyListener = new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View v, boolean hasFocus) { + if (hasFocus) { + mAppsRecyclerView.requestFocus(); + } + } + }; + mSearchBarContainerView = (ViewGroup) findViewById(R.id.search_box_container); + mSearchBarContainerView.setOnFocusChangeListener(focusProxyListener); + mContainerView = findViewById(R.id.all_apps_container); + mContainerView.setOnFocusChangeListener(focusProxyListener); + mRevealView = findViewById(R.id.all_apps_reveal); + + // Load the all apps recycler view + mAppsRecyclerView = (AllAppsRecyclerView) findViewById(R.id.apps_list_view); + mAppsRecyclerView.setApps(mApps); + mAppsRecyclerView.setLayoutManager(mLayoutManager); + mAppsRecyclerView.setAdapter(mAdapter); + mAppsRecyclerView.setHasFixedSize(true); + if (mItemDecoration != null) { + mAppsRecyclerView.addItemDecoration(mItemDecoration); + } + + updateBackgroundAndPaddings(); + } + + @Override + public void onBoundsChanged(Rect newBounds) { + mLauncher.updateOverlayBounds(newBounds); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + // Update the number of items in the grid before we measure the view + int availableWidth = !mContentBounds.isEmpty() ? mContentBounds.width() : + MeasureSpec.getSize(widthMeasureSpec); + DeviceProfile grid = mLauncher.getDeviceProfile(); + grid.updateAppsViewNumCols(getResources(), availableWidth); + if (mNumAppsPerRow != grid.allAppsNumCols || + mNumPredictedAppsPerRow != grid.allAppsNumPredictiveCols) { + mNumAppsPerRow = grid.allAppsNumCols; + mNumPredictedAppsPerRow = grid.allAppsNumPredictiveCols; + + // If there is a start margin to draw section names, determine how we are going to merge + // app sections + boolean mergeSectionsFully = mSectionNamesMargin == 0 || !grid.isPhone; + AlphabeticalAppsList.MergeAlgorithm mergeAlgorithm = mergeSectionsFully ? + new FullMergeAlgorithm() : + new SimpleSectionMergeAlgorithm((int) Math.ceil(mNumAppsPerRow / 2f), + MIN_ROWS_IN_MERGED_SECTION_PHONE, MAX_NUM_MERGES_PHONE); + + mAppsRecyclerView.setNumAppsPerRow(grid, mNumAppsPerRow); + mAdapter.setNumAppsPerRow(mNumAppsPerRow); + mApps.setNumAppsPerRow(mNumAppsPerRow, mNumPredictedAppsPerRow, mergeAlgorithm); + } + + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + /** + * Update the background and padding of the Apps view and children. Instead of insetting the + * container view, we inset the background and padding of the recycler view to allow for the + * recycler view to handle touch events (for fast scrolling) all the way to the edge. + */ + @Override + protected void onUpdateBackgroundAndPaddings(Rect searchBarBounds, Rect padding) { + boolean isRtl = Utilities.isRtl(getResources()); + + // TODO: Use quantum_panel instead of quantum_panel_shape + InsetDrawable background = new InsetDrawable( + getResources().getDrawable(R.drawable.quantum_panel_shape), padding.left, 0, + padding.right, 0); + Rect bgPadding = new Rect(); + background.getPadding(bgPadding); + mContainerView.setBackground(background); + mRevealView.setBackground(background.getConstantState().newDrawable()); + mAppsRecyclerView.updateBackgroundPadding(bgPadding); + mAdapter.updateBackgroundPadding(bgPadding); + + // Hack: We are going to let the recycler view take the full width, so reset the padding on + // the container to zero after setting the background and apply the top-bottom padding to + // the content view instead so that the launcher transition clips correctly. + mContent.setPadding(0, padding.top, 0, padding.bottom); + mContainerView.setPadding(0, 0, 0, 0); + + // Pad the recycler view by the background padding plus the start margin (for the section + // names) + int startInset = Math.max(mSectionNamesMargin, mAppsRecyclerView.getMaxScrollbarWidth()); + int topBottomPadding = mRecyclerViewTopBottomPadding; + if (isRtl) { + mAppsRecyclerView.setPadding(padding.left + mAppsRecyclerView.getMaxScrollbarWidth(), + topBottomPadding, padding.right + startInset, topBottomPadding); + } else { + mAppsRecyclerView.setPadding(padding.left + startInset, topBottomPadding, + padding.right + mAppsRecyclerView.getMaxScrollbarWidth(), topBottomPadding); + } + + // Inset the search bar to fit its bounds above the container + if (mSearchBarView != null) { + Rect backgroundPadding = new Rect(); + if (mSearchBarView.getBackground() != null) { + mSearchBarView.getBackground().getPadding(backgroundPadding); + } + LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) + mSearchBarContainerView.getLayoutParams(); + lp.leftMargin = searchBarBounds.left - backgroundPadding.left; + lp.topMargin = searchBarBounds.top - backgroundPadding.top; + lp.rightMargin = (getMeasuredWidth() - searchBarBounds.right) - backgroundPadding.right; + mSearchBarContainerView.requestLayout(); + } + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Determine if the key event was actual text, if so, focus the search bar and then dispatch + // the key normally so that it can process this key event + if (!mSearchBarController.isSearchFieldFocused() && + event.getAction() == KeyEvent.ACTION_DOWN) { + final int unicodeChar = event.getUnicodeChar(); + final boolean isKeyNotWhitespace = unicodeChar > 0 && + !Character.isWhitespace(unicodeChar) && !Character.isSpaceChar(unicodeChar); + if (isKeyNotWhitespace) { + boolean gotKey = TextKeyListener.getInstance().onKeyDown(this, mSearchQueryBuilder, + event.getKeyCode(), event); + if (gotKey && mSearchQueryBuilder.length() > 0) { + mSearchBarController.focusSearchField(); + } + } + } + + return super.dispatchKeyEvent(event); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + return handleTouchEvent(ev); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(MotionEvent ev) { + return handleTouchEvent(ev); + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouch(View v, MotionEvent ev) { + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_MOVE: + mIconLastTouchPos.set((int) ev.getX(), (int) ev.getY()); + break; + } + return false; + } + + @Override + public boolean onLongClick(View v) { + // Return early if this is not initiated from a touch + if (!v.isInTouchMode()) return false; + // When we have exited all apps or are in transition, disregard long clicks + if (!mLauncher.isAppsViewVisible() || + mLauncher.getWorkspace().isSwitchingState()) return false; + // Return if global dragging is not enabled + if (!mLauncher.isDraggingEnabled()) return false; + + // Start the drag + mLauncher.getWorkspace().beginDragShared(v, mIconLastTouchPos, this, false); + // Enter spring loaded mode + mLauncher.enterSpringLoadedDragMode(); + + return false; + } + + @Override + public boolean supportsFlingToDelete() { + return true; + } + + @Override + public boolean supportsAppInfoDropTarget() { + return true; + } + + @Override + public boolean supportsDeleteDropTarget() { + return false; + } + + @Override + public float getIntrinsicIconScaleFactor() { + DeviceProfile grid = mLauncher.getDeviceProfile(); + return (float) grid.allAppsIconSizePx / grid.iconSizePx; + } + + @Override + public void onFlingToDeleteCompleted() { + // We just dismiss the drag when we fling, so cleanup here + mLauncher.exitSpringLoadedDragModeDelayed(true, + Launcher.EXIT_SPRINGLOADED_MODE_SHORT_TIMEOUT, null); + mLauncher.unlockScreenOrientation(false); + } + + @Override + public void onDropCompleted(View target, DropTarget.DragObject d, boolean isFlingToDelete, + boolean success) { + if (isFlingToDelete || !success || (target != mLauncher.getWorkspace() && + !(target instanceof DeleteDropTarget) && !(target instanceof Folder))) { + // Exit spring loaded mode if we have not successfully dropped or have not handled the + // drop in Workspace + mLauncher.exitSpringLoadedDragModeDelayed(true, + Launcher.EXIT_SPRINGLOADED_MODE_SHORT_TIMEOUT, null); + } + mLauncher.unlockScreenOrientation(false); + + // Display an error message if the drag failed due to there not being enough space on the + // target layout we were dropping on. + if (!success) { + boolean showOutOfSpaceMessage = false; + if (target instanceof Workspace) { + int currentScreen = mLauncher.getCurrentWorkspaceScreen(); + Workspace workspace = (Workspace) target; + CellLayout layout = (CellLayout) workspace.getChildAt(currentScreen); + ItemInfo itemInfo = (ItemInfo) d.dragInfo; + if (layout != null) { + showOutOfSpaceMessage = + !layout.findCellForSpan(null, itemInfo.spanX, itemInfo.spanY); + } + } + if (showOutOfSpaceMessage) { + mLauncher.showOutOfSpaceMessage(false); + } + + d.deferDragViewCleanupPostAnimation = false; + } + } + + @Override + public void onLauncherTransitionPrepare(Launcher l, boolean animated, boolean toWorkspace) { + // Do nothing + } + + @Override + public void onLauncherTransitionStart(Launcher l, boolean animated, boolean toWorkspace) { + // Do nothing + } + + @Override + public void onLauncherTransitionStep(Launcher l, float t) { + // Do nothing + } + + @Override + public void onLauncherTransitionEnd(Launcher l, boolean animated, boolean toWorkspace) { + if (toWorkspace) { + // Reset the search bar and base recycler view after transitioning home + mSearchBarController.reset(); + mAppsRecyclerView.reset(); + } + } + + /** + * Handles the touch events to dismiss all apps when clicking outside the bounds of the + * recycler view. + */ + private boolean handleTouchEvent(MotionEvent ev) { + DeviceProfile grid = mLauncher.getDeviceProfile(); + int x = (int) ev.getX(); + int y = (int) ev.getY(); + + switch (ev.getAction()) { + case MotionEvent.ACTION_DOWN: + if (!mContentBounds.isEmpty()) { + // Outset the fixed bounds and check if the touch is outside all apps + Rect tmpRect = new Rect(mContentBounds); + tmpRect.inset(-grid.allAppsIconSizePx / 2, 0); + if (ev.getX() < tmpRect.left || ev.getX() > tmpRect.right) { + mBoundsCheckLastTouchDownPos.set(x, y); + return true; + } + } else { + // Check if the touch is outside all apps + if (ev.getX() < getPaddingLeft() || + ev.getX() > (getWidth() - getPaddingRight())) { + mBoundsCheckLastTouchDownPos.set(x, y); + return true; + } + } + break; + case MotionEvent.ACTION_UP: + if (mBoundsCheckLastTouchDownPos.x > -1) { + ViewConfiguration viewConfig = ViewConfiguration.get(getContext()); + float dx = ev.getX() - mBoundsCheckLastTouchDownPos.x; + float dy = ev.getY() - mBoundsCheckLastTouchDownPos.y; + float distance = (float) Math.hypot(dx, dy); + if (distance < viewConfig.getScaledTouchSlop()) { + // The background was clicked, so just go home + Launcher launcher = (Launcher) getContext(); + launcher.showWorkspace(true); + return true; + } + } + // Fall through + case MotionEvent.ACTION_CANCEL: + mBoundsCheckLastTouchDownPos.set(-1, -1); + break; + } + return false; + } + + @Override + public void onSearchResult(String query, ArrayList<ComponentKey> apps) { + if (apps != null) { + mApps.setOrderedFilter(apps); + mAdapter.setLastSearchQuery(query); + mAppsRecyclerView.onSearchResultsChanged(); + } + } + + @Override + public void clearSearchResult() { + mApps.setOrderedFilter(null); + mAppsRecyclerView.onSearchResultsChanged(); + + // Clear the search query + mSearchQueryBuilder.clear(); + mSearchQueryBuilder.clearSpans(); + Selection.setSelection(mSearchQueryBuilder, 0); + } +} diff --git a/src/com/android/launcher3/allapps/AllAppsGridAdapter.java b/src/com/android/launcher3/allapps/AllAppsGridAdapter.java new file mode 100644 index 000000000..1f95133d4 --- /dev/null +++ b/src/com/android/launcher3/allapps/AllAppsGridAdapter.java @@ -0,0 +1,557 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.support.v4.view.accessibility.AccessibilityRecordCompat; +import android.support.v4.view.accessibility.AccessibilityEventCompat; +import android.net.Uri; +import android.support.v7.widget.GridLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; +import android.widget.TextView; +import com.android.launcher3.AppInfo; +import com.android.launcher3.BubbleTextView; +import com.android.launcher3.Launcher; +import com.android.launcher3.R; +import com.android.launcher3.Utilities; +import com.android.launcher3.util.Thunk; + +import java.util.HashMap; +import java.util.List; + + +/** + * The grid view adapter of all the apps. + */ +public class AllAppsGridAdapter extends RecyclerView.Adapter<AllAppsGridAdapter.ViewHolder> { + + public static final String TAG = "AppsGridAdapter"; + private static final boolean DEBUG = false; + + // A section break in the grid + public static final int SECTION_BREAK_VIEW_TYPE = 0; + // A normal icon + public static final int ICON_VIEW_TYPE = 1; + // A prediction icon + public static final int PREDICTION_ICON_VIEW_TYPE = 2; + // The message shown when there are no filtered results + public static final int EMPTY_SEARCH_VIEW_TYPE = 3; + // A divider that separates the apps list and the search market button + public static final int SEARCH_MARKET_DIVIDER_VIEW_TYPE = 4; + // The message to continue to a market search when there are no filtered results + public static final int SEARCH_MARKET_VIEW_TYPE = 5; + + /** + * ViewHolder for each icon. + */ + public static class ViewHolder extends RecyclerView.ViewHolder { + public View mContent; + + public ViewHolder(View v) { + super(v); + mContent = v; + } + } + + /** + * A subclass of GridLayoutManager that overrides accessibility values during app search. + */ + public class AppsGridLayoutManager extends GridLayoutManager { + + public AppsGridLayoutManager(Context context) { + super(context, 1, GridLayoutManager.VERTICAL, false); + } + + @Override + public void onInitializeAccessibilityEvent(AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(event); + + // Ensure that we only report the number apps for accessibility not including other + // adapter views + final AccessibilityRecordCompat record = AccessibilityEventCompat + .asRecord(event); + record.setItemCount(mApps.getNumFilteredApps()); + } + + @Override + public int getRowCountForAccessibility(RecyclerView.Recycler recycler, + RecyclerView.State state) { + if (mApps.hasNoFilteredResults()) { + // Disregard the no-search-results text as a list item for accessibility + return 0; + } else { + return super.getRowCountForAccessibility(recycler, state); + } + } + } + + /** + * Helper class to size the grid items. + */ + public class GridSpanSizer extends GridLayoutManager.SpanSizeLookup { + + public GridSpanSizer() { + super(); + setSpanIndexCacheEnabled(true); + } + + @Override + public int getSpanSize(int position) { + switch (mApps.getAdapterItems().get(position).viewType) { + case AllAppsGridAdapter.ICON_VIEW_TYPE: + case AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE: + return 1; + default: + // Section breaks span the full width + return mAppsPerRow; + } + } + } + + /** + * Helper class to draw the section headers + */ + public class GridItemDecoration extends RecyclerView.ItemDecoration { + + private static final boolean DEBUG_SECTION_MARGIN = false; + private static final boolean FADE_OUT_SECTIONS = false; + + private HashMap<String, PointF> mCachedSectionBounds = new HashMap<>(); + private Rect mTmpBounds = new Rect(); + + @Override + public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) { + if (mApps.hasFilter() || mAppsPerRow == 0) { + return; + } + + if (DEBUG_SECTION_MARGIN) { + Paint p = new Paint(); + p.setColor(0x33ff0000); + c.drawRect(mBackgroundPadding.left, 0, mBackgroundPadding.left + mSectionNamesMargin, + parent.getMeasuredHeight(), p); + } + + List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems(); + boolean hasDrawnPredictedAppsDivider = false; + boolean showSectionNames = mSectionNamesMargin > 0; + int childCount = parent.getChildCount(); + int lastSectionTop = 0; + int lastSectionHeight = 0; + for (int i = 0; i < childCount; i++) { + View child = parent.getChildAt(i); + ViewHolder holder = (ViewHolder) parent.getChildViewHolder(child); + if (!isValidHolderAndChild(holder, child, items)) { + continue; + } + + if (shouldDrawItemDivider(holder, items) && !hasDrawnPredictedAppsDivider) { + // Draw the divider under the predicted apps + int top = child.getTop() + child.getHeight() + mPredictionBarDividerOffset; + c.drawLine(mBackgroundPadding.left, top, + parent.getWidth() - mBackgroundPadding.right, top, + mPredictedAppsDividerPaint); + hasDrawnPredictedAppsDivider = true; + + } else if (showSectionNames && shouldDrawItemSection(holder, i, items)) { + // At this point, we only draw sections for each section break; + int viewTopOffset = (2 * child.getPaddingTop()); + int pos = holder.getPosition(); + AlphabeticalAppsList.AdapterItem item = items.get(pos); + AlphabeticalAppsList.SectionInfo sectionInfo = item.sectionInfo; + + // Draw all the sections for this index + String lastSectionName = item.sectionName; + for (int j = item.sectionAppIndex; j < sectionInfo.numApps; j++, pos++) { + AlphabeticalAppsList.AdapterItem nextItem = items.get(pos); + String sectionName = nextItem.sectionName; + if (nextItem.sectionInfo != sectionInfo) { + break; + } + if (j > item.sectionAppIndex && sectionName.equals(lastSectionName)) { + continue; + } + + + // Find the section name bounds + PointF sectionBounds = getAndCacheSectionBounds(sectionName); + + // Calculate where to draw the section + int sectionBaseline = (int) (viewTopOffset + sectionBounds.y); + int x = mIsRtl ? + parent.getWidth() - mBackgroundPadding.left - mSectionNamesMargin : + mBackgroundPadding.left; + x += (int) ((mSectionNamesMargin - sectionBounds.x) / 2f); + int y = child.getTop() + sectionBaseline; + + // Determine whether this is the last row with apps in that section, if + // so, then fix the section to the row allowing it to scroll past the + // baseline, otherwise, bound it to the baseline so it's in the viewport + int appIndexInSection = items.get(pos).sectionAppIndex; + int nextRowPos = Math.min(items.size() - 1, + pos + mAppsPerRow - (appIndexInSection % mAppsPerRow)); + AlphabeticalAppsList.AdapterItem nextRowItem = items.get(nextRowPos); + boolean fixedToRow = !sectionName.equals(nextRowItem.sectionName); + if (!fixedToRow) { + y = Math.max(sectionBaseline, y); + } + + // In addition, if it overlaps with the last section that was drawn, then + // offset it so that it does not overlap + if (lastSectionHeight > 0 && y <= (lastSectionTop + lastSectionHeight)) { + y += lastSectionTop - y + lastSectionHeight; + } + + // Draw the section header + if (FADE_OUT_SECTIONS) { + int alpha = 255; + if (fixedToRow) { + alpha = Math.min(255, + (int) (255 * (Math.max(0, y) / (float) sectionBaseline))); + } + mSectionTextPaint.setAlpha(alpha); + } + c.drawText(sectionName, x, y, mSectionTextPaint); + + lastSectionTop = y; + lastSectionHeight = (int) (sectionBounds.y + mSectionHeaderOffset); + lastSectionName = sectionName; + } + i += (sectionInfo.numApps - item.sectionAppIndex); + } + } + } + + @Override + public void getItemOffsets(Rect outRect, View view, RecyclerView parent, + RecyclerView.State state) { + // Do nothing + } + + /** + * Given a section name, return the bounds of the given section name. + */ + private PointF getAndCacheSectionBounds(String sectionName) { + PointF bounds = mCachedSectionBounds.get(sectionName); + if (bounds == null) { + mSectionTextPaint.getTextBounds(sectionName, 0, sectionName.length(), mTmpBounds); + bounds = new PointF(mSectionTextPaint.measureText(sectionName), mTmpBounds.height()); + mCachedSectionBounds.put(sectionName, bounds); + } + return bounds; + } + + /** + * Returns whether we consider this a valid view holder for us to draw a divider or section for. + */ + private boolean isValidHolderAndChild(ViewHolder holder, View child, + List<AlphabeticalAppsList.AdapterItem> items) { + // Ensure item is not already removed + GridLayoutManager.LayoutParams lp = (GridLayoutManager.LayoutParams) + child.getLayoutParams(); + if (lp.isItemRemoved()) { + return false; + } + // Ensure we have a valid holder + if (holder == null) { + return false; + } + // Ensure we have a holder position + int pos = holder.getPosition(); + if (pos < 0 || pos >= items.size()) { + return false; + } + return true; + } + + /** + * Returns whether to draw the divider for a given child. + */ + private boolean shouldDrawItemDivider(ViewHolder holder, + List<AlphabeticalAppsList.AdapterItem> items) { + int pos = holder.getPosition(); + return items.get(pos).viewType == AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE; + } + + /** + * Returns whether to draw the section for the given child. + */ + private boolean shouldDrawItemSection(ViewHolder holder, int childIndex, + List<AlphabeticalAppsList.AdapterItem> items) { + int pos = holder.getPosition(); + AlphabeticalAppsList.AdapterItem item = items.get(pos); + + // Ensure it's an icon + if (item.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE) { + return false; + } + // Draw the section header for the first item in each section + return (childIndex == 0) || + (items.get(pos - 1).viewType == AllAppsGridAdapter.SECTION_BREAK_VIEW_TYPE); + } + } + + private Launcher mLauncher; + private LayoutInflater mLayoutInflater; + @Thunk AlphabeticalAppsList mApps; + private GridLayoutManager mGridLayoutMgr; + private GridSpanSizer mGridSizer; + private GridItemDecoration mItemDecoration; + private View.OnTouchListener mTouchListener; + private View.OnClickListener mIconClickListener; + private View.OnLongClickListener mIconLongClickListener; + @Thunk final Rect mBackgroundPadding = new Rect(); + @Thunk int mPredictionBarDividerOffset; + @Thunk int mAppsPerRow; + @Thunk boolean mIsRtl; + + // The text to show when there are no search results and no market search handler. + private String mEmptySearchMessage; + // The name of the market app which handles searches, to be used in the format str + // below when updating the search-market view. Only needs to be loaded once. + private String mMarketAppName; + // The text to show when there is a market app which can handle a specific query, updated + // each time the search query changes. + private String mMarketSearchMessage; + // The intent to send off to the market app, updated each time the search query changes. + private Intent mMarketSearchIntent; + // The last query that the user entered into the search field + private String mLastSearchQuery; + + // Section drawing + @Thunk int mSectionNamesMargin; + @Thunk int mSectionHeaderOffset; + @Thunk Paint mSectionTextPaint; + @Thunk Paint mPredictedAppsDividerPaint; + + public AllAppsGridAdapter(Launcher launcher, AlphabeticalAppsList apps, + View.OnTouchListener touchListener, View.OnClickListener iconClickListener, + View.OnLongClickListener iconLongClickListener) { + Resources res = launcher.getResources(); + mLauncher = launcher; + mApps = apps; + mEmptySearchMessage = res.getString(R.string.all_apps_loading_message); + mGridSizer = new GridSpanSizer(); + mGridLayoutMgr = new AppsGridLayoutManager(launcher); + mGridLayoutMgr.setSpanSizeLookup(mGridSizer); + mItemDecoration = new GridItemDecoration(); + mLayoutInflater = LayoutInflater.from(launcher); + mTouchListener = touchListener; + mIconClickListener = iconClickListener; + mIconLongClickListener = iconLongClickListener; + mSectionNamesMargin = res.getDimensionPixelSize(R.dimen.all_apps_grid_view_start_margin); + mSectionHeaderOffset = res.getDimensionPixelSize(R.dimen.all_apps_grid_section_y_offset); + + mSectionTextPaint = new Paint(); + mSectionTextPaint.setTextSize(res.getDimensionPixelSize( + R.dimen.all_apps_grid_section_text_size)); + mSectionTextPaint.setColor(res.getColor(R.color.all_apps_grid_section_text_color)); + mSectionTextPaint.setAntiAlias(true); + + mPredictedAppsDividerPaint = new Paint(); + mPredictedAppsDividerPaint.setStrokeWidth(Utilities.pxFromDp(1f, res.getDisplayMetrics())); + mPredictedAppsDividerPaint.setColor(0x1E000000); + mPredictedAppsDividerPaint.setAntiAlias(true); + mPredictionBarDividerOffset = + (-res.getDimensionPixelSize(R.dimen.all_apps_prediction_icon_bottom_padding) + + res.getDimensionPixelSize(R.dimen.all_apps_icon_top_bottom_padding)) / 2; + + // Resolve the market app handling additional searches + PackageManager pm = launcher.getPackageManager(); + ResolveInfo marketInfo = pm.resolveActivity(createMarketSearchIntent(""), + PackageManager.MATCH_DEFAULT_ONLY); + if (marketInfo != null) { + mMarketAppName = marketInfo.loadLabel(pm).toString(); + } + } + + /** + * Sets the number of apps per row. + */ + public void setNumAppsPerRow(int appsPerRow) { + mAppsPerRow = appsPerRow; + mGridLayoutMgr.setSpanCount(appsPerRow); + } + + /** + * Sets whether we are in RTL mode. + */ + public void setRtl(boolean rtl) { + mIsRtl = rtl; + } + + /** + * Sets the last search query that was made, used to show when there are no results and to also + * seed the intent for searching the market. + */ + public void setLastSearchQuery(String query) { + Resources res = mLauncher.getResources(); + String formatStr = res.getString(R.string.all_apps_no_search_results); + mLastSearchQuery = query; + mEmptySearchMessage = String.format(formatStr, query); + if (mMarketAppName != null) { + mMarketSearchMessage = String.format(res.getString(R.string.all_apps_search_market_message), + mMarketAppName); + mMarketSearchIntent = createMarketSearchIntent(query); + } + } + + /** + * Notifies the adapter of the background padding so that it can draw things correctly in the + * item decorator. + */ + public void updateBackgroundPadding(Rect padding) { + mBackgroundPadding.set(padding); + } + + /** + * Returns the grid layout manager. + */ + public GridLayoutManager getLayoutManager() { + return mGridLayoutMgr; + } + + /** + * Returns the item decoration for the recycler view. + */ + public RecyclerView.ItemDecoration getItemDecoration() { + // We don't draw any headers when we are uncomfortably dense + return mItemDecoration; + } + + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + switch (viewType) { + case SECTION_BREAK_VIEW_TYPE: + return new ViewHolder(new View(parent.getContext())); + case ICON_VIEW_TYPE: { + BubbleTextView icon = (BubbleTextView) mLayoutInflater.inflate( + R.layout.all_apps_icon, parent, false); + icon.setOnTouchListener(mTouchListener); + icon.setOnClickListener(mIconClickListener); + icon.setOnLongClickListener(mIconLongClickListener); + icon.setLongPressTimeout(ViewConfiguration.get(parent.getContext()) + .getLongPressTimeout()); + icon.setFocusable(true); + return new ViewHolder(icon); + } + case PREDICTION_ICON_VIEW_TYPE: { + BubbleTextView icon = (BubbleTextView) mLayoutInflater.inflate( + R.layout.all_apps_prediction_bar_icon, parent, false); + icon.setOnTouchListener(mTouchListener); + icon.setOnClickListener(mIconClickListener); + icon.setOnLongClickListener(mIconLongClickListener); + icon.setLongPressTimeout(ViewConfiguration.get(parent.getContext()) + .getLongPressTimeout()); + icon.setFocusable(true); + return new ViewHolder(icon); + } + case EMPTY_SEARCH_VIEW_TYPE: + return new ViewHolder(mLayoutInflater.inflate(R.layout.all_apps_empty_search, + parent, false)); + case SEARCH_MARKET_DIVIDER_VIEW_TYPE: + return new ViewHolder(mLayoutInflater.inflate(R.layout.all_apps_search_market_divider, + parent, false)); + case SEARCH_MARKET_VIEW_TYPE: + View searchMarketView = mLayoutInflater.inflate(R.layout.all_apps_search_market, + parent, false); + searchMarketView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + mLauncher.startSearchFromAllApps(v, mMarketSearchIntent, mLastSearchQuery); + } + }); + return new ViewHolder(searchMarketView); + default: + throw new RuntimeException("Unexpected view type"); + } + } + + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + switch (holder.getItemViewType()) { + case ICON_VIEW_TYPE: { + AppInfo info = mApps.getAdapterItems().get(position).appInfo; + BubbleTextView icon = (BubbleTextView) holder.mContent; + icon.applyFromApplicationInfo(info); + break; + } + case PREDICTION_ICON_VIEW_TYPE: { + AppInfo info = mApps.getAdapterItems().get(position).appInfo; + BubbleTextView icon = (BubbleTextView) holder.mContent; + icon.applyFromApplicationInfo(info); + break; + } + case EMPTY_SEARCH_VIEW_TYPE: + TextView emptyViewText = (TextView) holder.mContent; + emptyViewText.setText(mEmptySearchMessage); + emptyViewText.setGravity(mApps.hasNoFilteredResults() ? Gravity.CENTER : + Gravity.START | Gravity.CENTER_VERTICAL); + break; + case SEARCH_MARKET_VIEW_TYPE: + TextView searchView = (TextView) holder.mContent; + if (mMarketSearchIntent != null) { + searchView.setVisibility(View.VISIBLE); + searchView.setContentDescription(mMarketSearchMessage); + searchView.setGravity(mApps.hasNoFilteredResults() ? Gravity.CENTER : + Gravity.START | Gravity.CENTER_VERTICAL); + searchView.setText(mMarketSearchMessage); + } else { + searchView.setVisibility(View.GONE); + } + break; + } + } + + @Override + public int getItemCount() { + return mApps.getAdapterItems().size(); + } + + @Override + public int getItemViewType(int position) { + AlphabeticalAppsList.AdapterItem item = mApps.getAdapterItems().get(position); + return item.viewType; + } + + /** + * Creates a new market search intent. + */ + private Intent createMarketSearchIntent(String query) { + Uri marketSearchUri = Uri.parse("market://search") + .buildUpon() + .appendQueryParameter("q", query) + .build(); + Intent marketSearchIntent = new Intent(Intent.ACTION_VIEW); + marketSearchIntent.setData(marketSearchUri); + return marketSearchIntent; + } +} diff --git a/src/com/android/launcher3/allapps/AllAppsRecyclerView.java b/src/com/android/launcher3/allapps/AllAppsRecyclerView.java new file mode 100644 index 000000000..2f66e2cad --- /dev/null +++ b/src/com/android/launcher3/allapps/AllAppsRecyclerView.java @@ -0,0 +1,463 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.util.AttributeSet; +import android.view.View; + +import com.android.launcher3.BaseRecyclerView; +import com.android.launcher3.BaseRecyclerViewFastScrollBar; +import com.android.launcher3.DeviceProfile; +import com.android.launcher3.R; +import com.android.launcher3.Stats; +import com.android.launcher3.Utilities; +import com.android.launcher3.util.Thunk; + +import java.util.List; + +/** + * A RecyclerView with custom fast scroll support for the all apps view. + */ +public class AllAppsRecyclerView extends BaseRecyclerView + implements Stats.LaunchSourceProvider { + + private static final int FAST_SCROLL_MODE_JUMP_TO_FIRST_ICON = 0; + private static final int FAST_SCROLL_MODE_FREE_SCROLL = 1; + + private static final int FAST_SCROLL_BAR_MODE_DISTRIBUTE_BY_ROW = 0; + private static final int FAST_SCROLL_BAR_MODE_DISTRIBUTE_BY_SECTIONS = 1; + + private AlphabeticalAppsList mApps; + private int mNumAppsPerRow; + + @Thunk BaseRecyclerViewFastScrollBar.FastScrollFocusableView mLastFastScrollFocusedView; + @Thunk int mPrevFastScrollFocusedPosition; + @Thunk int mFastScrollFrameIndex; + @Thunk final int[] mFastScrollFrames = new int[10]; + + private final int mFastScrollMode = FAST_SCROLL_MODE_JUMP_TO_FIRST_ICON; + private final int mScrollBarMode = FAST_SCROLL_BAR_MODE_DISTRIBUTE_BY_ROW; + + private ScrollPositionState mScrollPosState = new ScrollPositionState(); + + private AllAppsBackgroundDrawable mEmptySearchBackground; + private int mEmptySearchBackgroundTopOffset; + + public AllAppsRecyclerView(Context context) { + this(context, null); + } + + public AllAppsRecyclerView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + super(context, attrs, defStyleAttr); + + Resources res = getResources(); + mScrollbar.setDetachThumbOnFastScroll(); + mEmptySearchBackgroundTopOffset = res.getDimensionPixelSize( + R.dimen.all_apps_empty_search_bg_top_offset); + } + + /** + * Sets the list of apps in this view, used to determine the fastscroll position. + */ + public void setApps(AlphabeticalAppsList apps) { + mApps = apps; + } + + /** + * Sets the number of apps per row in this recycler view. + */ + public void setNumAppsPerRow(DeviceProfile grid, int numAppsPerRow) { + mNumAppsPerRow = numAppsPerRow; + + RecyclerView.RecycledViewPool pool = getRecycledViewPool(); + int approxRows = (int) Math.ceil(grid.availableHeightPx / grid.allAppsIconSizePx); + pool.setMaxRecycledViews(AllAppsGridAdapter.EMPTY_SEARCH_VIEW_TYPE, 1); + pool.setMaxRecycledViews(AllAppsGridAdapter.SEARCH_MARKET_DIVIDER_VIEW_TYPE, 1); + pool.setMaxRecycledViews(AllAppsGridAdapter.SEARCH_MARKET_VIEW_TYPE, 1); + pool.setMaxRecycledViews(AllAppsGridAdapter.ICON_VIEW_TYPE, approxRows * mNumAppsPerRow); + pool.setMaxRecycledViews(AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE, mNumAppsPerRow); + pool.setMaxRecycledViews(AllAppsGridAdapter.SECTION_BREAK_VIEW_TYPE, approxRows); + } + + /** + * Scrolls this recycler view to the top. + */ + public void scrollToTop() { + // Ensure we reattach the scrollbar if it was previously detached while fast-scrolling + if (mScrollbar.isThumbDetached()) { + mScrollbar.reattachThumbToScroll(); + } + scrollToPosition(0); + } + + /** + * We need to override the draw to ensure that we don't draw the overscroll effect beyond the + * background bounds. + */ + @Override + protected void dispatchDraw(Canvas canvas) { + canvas.clipRect(mBackgroundPadding.left, mBackgroundPadding.top, + getWidth() - mBackgroundPadding.right, + getHeight() - mBackgroundPadding.bottom); + super.dispatchDraw(canvas); + } + + @Override + public void onDraw(Canvas c) { + // Draw the background + if (mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) { + c.clipRect(mBackgroundPadding.left, mBackgroundPadding.top, + getWidth() - mBackgroundPadding.right, + getHeight() - mBackgroundPadding.bottom); + + mEmptySearchBackground.draw(c); + } + + super.onDraw(c); + } + + @Override + protected boolean verifyDrawable(Drawable who) { + return who == mEmptySearchBackground || super.verifyDrawable(who); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + updateEmptySearchBackgroundBounds(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + // Bind event handlers + addOnItemTouchListener(this); + } + + @Override + public void fillInLaunchSourceData(Bundle sourceData) { + sourceData.putString(Stats.SOURCE_EXTRA_CONTAINER, Stats.CONTAINER_ALL_APPS); + if (mApps.hasFilter()) { + sourceData.putString(Stats.SOURCE_EXTRA_SUB_CONTAINER, + Stats.SUB_CONTAINER_ALL_APPS_SEARCH); + } else { + sourceData.putString(Stats.SOURCE_EXTRA_SUB_CONTAINER, + Stats.SUB_CONTAINER_ALL_APPS_A_Z); + } + } + + public void onSearchResultsChanged() { + // Always scroll the view to the top so the user can see the changed results + scrollToTop(); + + if (mApps.hasNoFilteredResults()) { + if (mEmptySearchBackground == null) { + mEmptySearchBackground = new AllAppsBackgroundDrawable(getContext()); + mEmptySearchBackground.setAlpha(0); + mEmptySearchBackground.setCallback(this); + updateEmptySearchBackgroundBounds(); + } + mEmptySearchBackground.animateBgAlpha(1f, 150); + } else if (mEmptySearchBackground != null) { + // For the time being, we just immediately hide the background to ensure that it does + // not overlap with the results + mEmptySearchBackground.setBgAlpha(0f); + } + } + + /** + * Maps the touch (from 0..1) to the adapter position that should be visible. + */ + @Override + public String scrollToPositionAtProgress(float touchFraction) { + int rowCount = mApps.getNumAppRows(); + if (rowCount == 0) { + return ""; + } + + // Stop the scroller if it is scrolling + stopScroll(); + + // Find the fastscroll section that maps to this touch fraction + List<AlphabeticalAppsList.FastScrollSectionInfo> fastScrollSections = + mApps.getFastScrollerSections(); + AlphabeticalAppsList.FastScrollSectionInfo lastInfo = fastScrollSections.get(0); + if (mScrollBarMode == FAST_SCROLL_BAR_MODE_DISTRIBUTE_BY_ROW) { + for (int i = 1; i < fastScrollSections.size(); i++) { + AlphabeticalAppsList.FastScrollSectionInfo info = fastScrollSections.get(i); + if (info.touchFraction > touchFraction) { + break; + } + lastInfo = info; + } + } else if (mScrollBarMode == FAST_SCROLL_BAR_MODE_DISTRIBUTE_BY_SECTIONS){ + lastInfo = fastScrollSections.get((int) (touchFraction * (fastScrollSections.size() - 1))); + } else { + throw new RuntimeException("Unexpected scroll bar mode"); + } + + // Map the touch position back to the scroll of the recycler view + getCurScrollState(mScrollPosState); + int availableScrollHeight = getAvailableScrollHeight(rowCount, mScrollPosState.rowHeight); + LinearLayoutManager layoutManager = (LinearLayoutManager) getLayoutManager(); + if (mFastScrollMode == FAST_SCROLL_MODE_FREE_SCROLL) { + layoutManager.scrollToPositionWithOffset(0, (int) -(availableScrollHeight * touchFraction)); + } + + if (mPrevFastScrollFocusedPosition != lastInfo.fastScrollToItem.position) { + mPrevFastScrollFocusedPosition = lastInfo.fastScrollToItem.position; + + // Reset the last focused view + if (mLastFastScrollFocusedView != null) { + mLastFastScrollFocusedView.setFastScrollFocused(false, true); + mLastFastScrollFocusedView = null; + } + + if (mFastScrollMode == FAST_SCROLL_MODE_JUMP_TO_FIRST_ICON) { + smoothSnapToPosition(mPrevFastScrollFocusedPosition, mScrollPosState); + } else if (mFastScrollMode == FAST_SCROLL_MODE_FREE_SCROLL) { + final ViewHolder vh = findViewHolderForPosition(mPrevFastScrollFocusedPosition); + if (vh != null && + vh.itemView instanceof BaseRecyclerViewFastScrollBar.FastScrollFocusableView) { + mLastFastScrollFocusedView = + (BaseRecyclerViewFastScrollBar.FastScrollFocusableView) vh.itemView; + mLastFastScrollFocusedView.setFastScrollFocused(true, true); + } + } else { + throw new RuntimeException("Unexpected fast scroll mode"); + } + } + return lastInfo.sectionName; + } + + @Override + public void onFastScrollCompleted() { + super.onFastScrollCompleted(); + // Reset and clean up the last focused view + if (mLastFastScrollFocusedView != null) { + mLastFastScrollFocusedView.setFastScrollFocused(false, true); + mLastFastScrollFocusedView = null; + } + mPrevFastScrollFocusedPosition = -1; + } + + /** + * Updates the bounds for the scrollbar. + */ + @Override + public void onUpdateScrollbar(int dy) { + List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems(); + + // Skip early if there are no items or we haven't been measured + if (items.isEmpty() || mNumAppsPerRow == 0) { + mScrollbar.setThumbOffset(-1, -1); + return; + } + + // Find the index and height of the first visible row (all rows have the same height) + int rowCount = mApps.getNumAppRows(); + getCurScrollState(mScrollPosState); + if (mScrollPosState.rowIndex < 0) { + mScrollbar.setThumbOffset(-1, -1); + return; + } + + // Only show the scrollbar if there is height to be scrolled + int availableScrollBarHeight = getAvailableScrollBarHeight(); + int availableScrollHeight = getAvailableScrollHeight(mApps.getNumAppRows(), mScrollPosState.rowHeight); + if (availableScrollHeight <= 0) { + mScrollbar.setThumbOffset(-1, -1); + return; + } + + // Calculate the current scroll position, the scrollY of the recycler view accounts for the + // view padding, while the scrollBarY is drawn right up to the background padding (ignoring + // padding) + int scrollY = getPaddingTop() + + (mScrollPosState.rowIndex * mScrollPosState.rowHeight) - mScrollPosState.rowTopOffset; + int scrollBarY = mBackgroundPadding.top + + (int) (((float) scrollY / availableScrollHeight) * availableScrollBarHeight); + + if (mScrollbar.isThumbDetached()) { + int scrollBarX; + if (Utilities.isRtl(getResources())) { + scrollBarX = mBackgroundPadding.left; + } else { + scrollBarX = getWidth() - mBackgroundPadding.right - mScrollbar.getThumbWidth(); + } + + if (mScrollbar.isDraggingThumb()) { + // If the thumb is detached, then just update the thumb to the current + // touch position + mScrollbar.setThumbOffset(scrollBarX, (int) mScrollbar.getLastTouchY()); + } else { + int thumbScrollY = mScrollbar.getThumbOffset().y; + int diffScrollY = scrollBarY - thumbScrollY; + if (diffScrollY * dy > 0f) { + // User is scrolling in the same direction the thumb needs to catch up to the + // current scroll position. We do this by mapping the difference in movement + // from the original scroll bar position to the difference in movement necessary + // in the detached thumb position to ensure that both speed towards the same + // position at either end of the list. + if (dy < 0) { + int offset = (int) ((dy * thumbScrollY) / (float) scrollBarY); + thumbScrollY += Math.max(offset, diffScrollY); + } else { + int offset = (int) ((dy * (availableScrollBarHeight - thumbScrollY)) / + (float) (availableScrollBarHeight - scrollBarY)); + thumbScrollY += Math.min(offset, diffScrollY); + } + thumbScrollY = Math.max(0, Math.min(availableScrollBarHeight, thumbScrollY)); + mScrollbar.setThumbOffset(scrollBarX, thumbScrollY); + if (scrollBarY == thumbScrollY) { + mScrollbar.reattachThumbToScroll(); + } + } else { + // User is scrolling in an opposite direction to the direction that the thumb + // needs to catch up to the scroll position. Do nothing except for updating + // the scroll bar x to match the thumb width. + mScrollbar.setThumbOffset(scrollBarX, thumbScrollY); + } + } + } else { + synchronizeScrollBarThumbOffsetToViewScroll(mScrollPosState, rowCount); + } + } + + /** + * This runnable runs a single frame of the smooth scroll animation and posts the next frame + * if necessary. + */ + @Thunk Runnable mSmoothSnapNextFrameRunnable = new Runnable() { + @Override + public void run() { + if (mFastScrollFrameIndex < mFastScrollFrames.length) { + scrollBy(0, mFastScrollFrames[mFastScrollFrameIndex]); + mFastScrollFrameIndex++; + postOnAnimation(mSmoothSnapNextFrameRunnable); + } else { + // Animation completed, set the fast scroll state on the target view + final ViewHolder vh = findViewHolderForPosition(mPrevFastScrollFocusedPosition); + if (vh != null && + vh.itemView instanceof BaseRecyclerViewFastScrollBar.FastScrollFocusableView && + mLastFastScrollFocusedView != vh.itemView) { + mLastFastScrollFocusedView = + (BaseRecyclerViewFastScrollBar.FastScrollFocusableView) vh.itemView; + mLastFastScrollFocusedView.setFastScrollFocused(true, true); + } + } + } + }; + + /** + * Smoothly snaps to a given position. We do this manually by calculating the keyframes + * ourselves and animating the scroll on the recycler view. + */ + private void smoothSnapToPosition(final int position, ScrollPositionState scrollPosState) { + removeCallbacks(mSmoothSnapNextFrameRunnable); + + // Calculate the full animation from the current scroll position to the final scroll + // position, and then run the animation for the duration. + int curScrollY = getPaddingTop() + + (scrollPosState.rowIndex * scrollPosState.rowHeight) - scrollPosState.rowTopOffset; + int newScrollY = getScrollAtPosition(position, scrollPosState.rowHeight); + int numFrames = mFastScrollFrames.length; + for (int i = 0; i < numFrames; i++) { + // TODO(winsonc): We can interpolate this as well. + mFastScrollFrames[i] = (newScrollY - curScrollY) / numFrames; + } + mFastScrollFrameIndex = 0; + postOnAnimation(mSmoothSnapNextFrameRunnable); + } + + /** + * Returns the current scroll state of the apps rows. + */ + protected void getCurScrollState(ScrollPositionState stateOut) { + stateOut.rowIndex = -1; + stateOut.rowTopOffset = -1; + stateOut.rowHeight = -1; + + // Return early if there are no items or we haven't been measured + List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems(); + if (items.isEmpty() || mNumAppsPerRow == 0) { + return; + } + + int childCount = getChildCount(); + for (int i = 0; i < childCount; i++) { + View child = getChildAt(i); + int position = getChildPosition(child); + if (position != NO_POSITION) { + AlphabeticalAppsList.AdapterItem item = items.get(position); + if (item.viewType == AllAppsGridAdapter.ICON_VIEW_TYPE || + item.viewType == AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE) { + stateOut.rowIndex = item.rowIndex; + stateOut.rowTopOffset = getLayoutManager().getDecoratedTop(child); + stateOut.rowHeight = child.getHeight(); + break; + } + } + } + } + + /** + * Returns the scrollY for the given position in the adapter. + */ + private int getScrollAtPosition(int position, int rowHeight) { + AlphabeticalAppsList.AdapterItem item = mApps.getAdapterItems().get(position); + if (item.viewType == AllAppsGridAdapter.ICON_VIEW_TYPE || + item.viewType == AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE) { + int offset = item.rowIndex > 0 ? getPaddingTop() : 0; + return offset + item.rowIndex * rowHeight; + } else { + return 0; + } + } + + /** + * Updates the bounds of the empty search background. + */ + private void updateEmptySearchBackgroundBounds() { + if (mEmptySearchBackground == null) { + return; + } + + // Center the empty search background on this new view bounds + int x = (getMeasuredWidth() - mEmptySearchBackground.getIntrinsicWidth()) / 2; + int y = mEmptySearchBackgroundTopOffset; + mEmptySearchBackground.setBounds(x, y, + x + mEmptySearchBackground.getIntrinsicWidth(), + y + mEmptySearchBackground.getIntrinsicHeight()); + } +} diff --git a/src/com/android/launcher3/allapps/AllAppsRecyclerViewContainerView.java b/src/com/android/launcher3/allapps/AllAppsRecyclerViewContainerView.java new file mode 100644 index 000000000..14e2a1863 --- /dev/null +++ b/src/com/android/launcher3/allapps/AllAppsRecyclerViewContainerView.java @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import com.android.launcher3.BubbleTextView; +import com.android.launcher3.BubbleTextView.BubbleTextShadowHandler; +import com.android.launcher3.ClickShadowView; +import com.android.launcher3.DeviceProfile; +import com.android.launcher3.Launcher; +import com.android.launcher3.R; + +/** + * A container for RecyclerView to allow for the click shadow view to be shown behind an icon that + * is launching. + */ +public class AllAppsRecyclerViewContainerView extends FrameLayout + implements BubbleTextShadowHandler { + + private final ClickShadowView mTouchFeedbackView; + + public AllAppsRecyclerViewContainerView(Context context) { + this(context, null); + } + + public AllAppsRecyclerViewContainerView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public AllAppsRecyclerViewContainerView(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + Launcher launcher = (Launcher) context; + DeviceProfile grid = launcher.getDeviceProfile(); + + mTouchFeedbackView = new ClickShadowView(context); + + // Make the feedback view large enough to hold the blur bitmap. + int size = grid.allAppsIconSizePx + mTouchFeedbackView.getExtraSize(); + addView(mTouchFeedbackView, size, size); + } + + @Override + public void setPressedIcon(BubbleTextView icon, Bitmap background) { + if (icon == null || background == null) { + mTouchFeedbackView.setBitmap(null); + mTouchFeedbackView.animate().cancel(); + } else if (mTouchFeedbackView.setBitmap(background)) { + mTouchFeedbackView.alignWithIconView(icon, (ViewGroup) icon.getParent()); + mTouchFeedbackView.animateShadow(); + } + } +} diff --git a/src/com/android/launcher3/allapps/AllAppsSearchBarController.java b/src/com/android/launcher3/allapps/AllAppsSearchBarController.java new file mode 100644 index 000000000..2b363c0cb --- /dev/null +++ b/src/com/android/launcher3/allapps/AllAppsSearchBarController.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.content.ComponentName; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import com.android.launcher3.util.ComponentKey; + +import java.util.ArrayList; + +/** + * An interface to a search box that AllApps can command. + */ +public abstract class AllAppsSearchBarController { + + protected AlphabeticalAppsList mApps; + protected Callbacks mCb; + + /** + * Sets the references to the apps model and the search result callback. + */ + public final void initialize(AlphabeticalAppsList apps, Callbacks cb) { + mApps = apps; + mCb = cb; + onInitialize(); + } + + /** + * To be overridden by subclasses. This method will get called when the controller is set, + * before getView(). + */ + protected abstract void onInitialize(); + + /** + * Returns the search bar view. + * @param parent the parent to attach the search bar view to. + */ + public abstract View getView(ViewGroup parent); + + /** + * Focuses the search field to handle key events. + */ + public abstract void focusSearchField(); + + /** + * Returns whether the search field is focused. + */ + public abstract boolean isSearchFieldFocused(); + + /** + * Resets the search bar state. + */ + public abstract void reset(); + + /** + * Returns whether the prediction bar should currently be visible depending on the state of + * the search bar. + */ + @Deprecated + public abstract boolean shouldShowPredictionBar(); + + /** + * Callback for getting search results. + */ + public interface Callbacks { + + /** + * Called when the bounds of the search bar has changed. + */ + void onBoundsChanged(Rect newBounds); + + /** + * Called when the search is complete. + * + * @param apps sorted list of matching components or null if in case of failure. + */ + void onSearchResult(String query, ArrayList<ComponentKey> apps); + + /** + * Called when the search results should be cleared. + */ + void clearSearchResult(); + } +}
\ No newline at end of file diff --git a/src/com/android/launcher3/allapps/AlphabeticalAppsList.java b/src/com/android/launcher3/allapps/AlphabeticalAppsList.java new file mode 100644 index 000000000..dac0df12a --- /dev/null +++ b/src/com/android/launcher3/allapps/AlphabeticalAppsList.java @@ -0,0 +1,644 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.content.Context; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import com.android.launcher3.AppInfo; +import com.android.launcher3.Launcher; +import com.android.launcher3.LauncherAppState; +import com.android.launcher3.compat.AlphabeticIndexCompat; +import com.android.launcher3.compat.UserHandleCompat; +import com.android.launcher3.model.AppNameComparator; +import com.android.launcher3.util.ComponentKey; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; + +/** + * The alphabetically sorted list of applications. + */ +public class AlphabeticalAppsList { + + public static final String TAG = "AlphabeticalAppsList"; + private static final boolean DEBUG = false; + private static final boolean DEBUG_PREDICTIONS = false; + + private static final int FAST_SCROLL_FRACTION_DISTRIBUTE_BY_ROWS_FRACTION = 0; + private static final int FAST_SCROLL_FRACTION_DISTRIBUTE_BY_NUM_SECTIONS = 1; + + private final int mFastScrollDistributionMode = FAST_SCROLL_FRACTION_DISTRIBUTE_BY_NUM_SECTIONS; + + /** + * Info about a section in the alphabetic list + */ + public static class SectionInfo { + // The number of applications in this section + public int numApps; + // The section break AdapterItem for this section + public AdapterItem sectionBreakItem; + // The first app AdapterItem for this section + public AdapterItem firstAppItem; + } + + /** + * Info about a fast scroller section, depending if sections are merged, the fast scroller + * sections will not be the same set as the section headers. + */ + public static class FastScrollSectionInfo { + // The section name + public String sectionName; + // The AdapterItem to scroll to for this section + public AdapterItem fastScrollToItem; + // The touch fraction that should map to this fast scroll section info + public float touchFraction; + + public FastScrollSectionInfo(String sectionName) { + this.sectionName = sectionName; + } + } + + /** + * Info about a particular adapter item (can be either section or app) + */ + public static class AdapterItem { + /** Common properties */ + // The index of this adapter item in the list + public int position; + // The type of this item + public int viewType; + + /** Section & App properties */ + // The section for this item + public SectionInfo sectionInfo; + + /** App-only properties */ + // The section name of this app. Note that there can be multiple items with different + // sectionNames in the same section + public String sectionName = null; + // The index of this app in the section + public int sectionAppIndex = -1; + // The row that this item shows up on + public int rowIndex; + // The index of this app in the row + public int rowAppIndex; + // The associated AppInfo for the app + public AppInfo appInfo = null; + // The index of this app not including sections + public int appIndex = -1; + + public static AdapterItem asSectionBreak(int pos, SectionInfo section) { + AdapterItem item = new AdapterItem(); + item.viewType = AllAppsGridAdapter.SECTION_BREAK_VIEW_TYPE; + item.position = pos; + item.sectionInfo = section; + section.sectionBreakItem = item; + return item; + } + + public static AdapterItem asPredictedApp(int pos, SectionInfo section, String sectionName, + int sectionAppIndex, AppInfo appInfo, int appIndex) { + AdapterItem item = asApp(pos, section, sectionName, sectionAppIndex, appInfo, appIndex); + item.viewType = AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE; + return item; + } + + public static AdapterItem asApp(int pos, SectionInfo section, String sectionName, + int sectionAppIndex, AppInfo appInfo, int appIndex) { + AdapterItem item = new AdapterItem(); + item.viewType = AllAppsGridAdapter.ICON_VIEW_TYPE; + item.position = pos; + item.sectionInfo = section; + item.sectionName = sectionName; + item.sectionAppIndex = sectionAppIndex; + item.appInfo = appInfo; + item.appIndex = appIndex; + return item; + } + + public static AdapterItem asEmptySearch(int pos) { + AdapterItem item = new AdapterItem(); + item.viewType = AllAppsGridAdapter.EMPTY_SEARCH_VIEW_TYPE; + item.position = pos; + return item; + } + + public static AdapterItem asDivider(int pos) { + AdapterItem item = new AdapterItem(); + item.viewType = AllAppsGridAdapter.SEARCH_MARKET_DIVIDER_VIEW_TYPE; + item.position = pos; + return item; + } + + public static AdapterItem asMarketSearch(int pos) { + AdapterItem item = new AdapterItem(); + item.viewType = AllAppsGridAdapter.SEARCH_MARKET_VIEW_TYPE; + item.position = pos; + return item; + } + } + + /** + * Common interface for different merging strategies. + */ + public interface MergeAlgorithm { + boolean continueMerging(SectionInfo section, SectionInfo withSection, + int sectionAppCount, int numAppsPerRow, int mergeCount); + } + + private Launcher mLauncher; + + // The set of apps from the system not including predictions + private final List<AppInfo> mApps = new ArrayList<>(); + private final HashMap<ComponentKey, AppInfo> mComponentToAppMap = new HashMap<>(); + + // The set of filtered apps with the current filter + private List<AppInfo> mFilteredApps = new ArrayList<>(); + // The current set of adapter items + private List<AdapterItem> mAdapterItems = new ArrayList<>(); + // The set of sections for the apps with the current filter + private List<SectionInfo> mSections = new ArrayList<>(); + // The set of sections that we allow fast-scrolling to (includes non-merged sections) + private List<FastScrollSectionInfo> mFastScrollerSections = new ArrayList<>(); + // The set of predicted app component names + private List<ComponentKey> mPredictedAppComponents = new ArrayList<>(); + // The set of predicted apps resolved from the component names and the current set of apps + private List<AppInfo> mPredictedApps = new ArrayList<>(); + // The of ordered component names as a result of a search query + private ArrayList<ComponentKey> mSearchResults; + private HashMap<CharSequence, String> mCachedSectionNames = new HashMap<>(); + private RecyclerView.Adapter mAdapter; + private AlphabeticIndexCompat mIndexer; + private AppNameComparator mAppNameComparator; + private MergeAlgorithm mMergeAlgorithm; + private int mNumAppsPerRow; + private int mNumPredictedAppsPerRow; + private int mNumAppRowsInAdapter; + + public AlphabeticalAppsList(Context context) { + mLauncher = (Launcher) context; + mIndexer = new AlphabeticIndexCompat(context); + mAppNameComparator = new AppNameComparator(context); + } + + /** + * Sets the number of apps per row. + */ + public void setNumAppsPerRow(int numAppsPerRow, int numPredictedAppsPerRow, + MergeAlgorithm mergeAlgorithm) { + mNumAppsPerRow = numAppsPerRow; + mNumPredictedAppsPerRow = numPredictedAppsPerRow; + mMergeAlgorithm = mergeAlgorithm; + + updateAdapterItems(); + } + + /** + * Sets the adapter to notify when this dataset changes. + */ + public void setAdapter(RecyclerView.Adapter adapter) { + mAdapter = adapter; + } + + /** + * Returns all the apps. + */ + public List<AppInfo> getApps() { + return mApps; + } + + /** + * Returns sections of all the current filtered applications. + */ + public List<SectionInfo> getSections() { + return mSections; + } + + /** + * Returns fast scroller sections of all the current filtered applications. + */ + public List<FastScrollSectionInfo> getFastScrollerSections() { + return mFastScrollerSections; + } + + /** + * Returns the current filtered list of applications broken down into their sections. + */ + public List<AdapterItem> getAdapterItems() { + return mAdapterItems; + } + + /** + * Returns the number of rows of applications (not including predictions) + */ + public int getNumAppRows() { + return mNumAppRowsInAdapter; + } + + /** + * Returns the number of applications in this list. + */ + public int getNumFilteredApps() { + return mFilteredApps.size(); + } + + /** + * Returns whether there are is a filter set. + */ + public boolean hasFilter() { + return (mSearchResults != null); + } + + /** + * Returns whether there are no filtered results. + */ + public boolean hasNoFilteredResults() { + return (mSearchResults != null) && mFilteredApps.isEmpty(); + } + + /** + * Sets the sorted list of filtered components. + */ + public void setOrderedFilter(ArrayList<ComponentKey> f) { + if (mSearchResults != f) { + mSearchResults = f; + updateAdapterItems(); + } + } + + /** + * Sets the current set of predicted apps. Since this can be called before we get the full set + * of applications, we should merge the results only in onAppsUpdated() which is idempotent. + */ + public void setPredictedApps(List<ComponentKey> apps) { + mPredictedAppComponents.clear(); + mPredictedAppComponents.addAll(apps); + onAppsUpdated(); + } + + /** + * Sets the current set of apps. + */ + public void setApps(List<AppInfo> apps) { + mComponentToAppMap.clear(); + addApps(apps); + } + + /** + * Adds new apps to the list. + */ + public void addApps(List<AppInfo> apps) { + updateApps(apps); + } + + /** + * Updates existing apps in the list + */ + public void updateApps(List<AppInfo> apps) { + for (AppInfo app : apps) { + mComponentToAppMap.put(app.toComponentKey(), app); + } + onAppsUpdated(); + } + + /** + * Removes some apps from the list. + */ + public void removeApps(List<AppInfo> apps) { + for (AppInfo app : apps) { + mComponentToAppMap.remove(app.toComponentKey()); + } + onAppsUpdated(); + } + + /** + * Updates internals when the set of apps are updated. + */ + private void onAppsUpdated() { + // Sort the list of apps + mApps.clear(); + mApps.addAll(mComponentToAppMap.values()); + Collections.sort(mApps, mAppNameComparator.getAppInfoComparator()); + + // As a special case for some languages (currently only Simplified Chinese), we may need to + // coalesce sections + Locale curLocale = mLauncher.getResources().getConfiguration().locale; + TreeMap<String, ArrayList<AppInfo>> sectionMap = null; + boolean localeRequiresSectionSorting = curLocale.equals(Locale.SIMPLIFIED_CHINESE); + if (localeRequiresSectionSorting) { + // Compute the section headers. We use a TreeMap with the section name comparator to + // ensure that the sections are ordered when we iterate over it later + sectionMap = new TreeMap<>(mAppNameComparator.getSectionNameComparator()); + for (AppInfo info : mApps) { + // Add the section to the cache + String sectionName = getAndUpdateCachedSectionName(info.title); + + // Add it to the mapping + ArrayList<AppInfo> sectionApps = sectionMap.get(sectionName); + if (sectionApps == null) { + sectionApps = new ArrayList<>(); + sectionMap.put(sectionName, sectionApps); + } + sectionApps.add(info); + } + + // Add each of the section apps to the list in order + List<AppInfo> allApps = new ArrayList<>(mApps.size()); + for (Map.Entry<String, ArrayList<AppInfo>> entry : sectionMap.entrySet()) { + allApps.addAll(entry.getValue()); + } + + mApps.clear(); + mApps.addAll(allApps); + } else { + // Just compute the section headers for use below + for (AppInfo info : mApps) { + // Add the section to the cache + getAndUpdateCachedSectionName(info.title); + } + } + + // Recompose the set of adapter items from the current set of apps + updateAdapterItems(); + } + + /** + * Updates the set of filtered apps with the current filter. At this point, we expect + * mCachedSectionNames to have been calculated for the set of all apps in mApps. + */ + private void updateAdapterItems() { + SectionInfo lastSectionInfo = null; + String lastSectionName = null; + FastScrollSectionInfo lastFastScrollerSectionInfo = null; + int position = 0; + int appIndex = 0; + + // Prepare to update the list of sections, filtered apps, etc. + mFilteredApps.clear(); + mFastScrollerSections.clear(); + mAdapterItems.clear(); + mSections.clear(); + + if (DEBUG_PREDICTIONS) { + if (mPredictedAppComponents.isEmpty() && !mApps.isEmpty()) { + mPredictedAppComponents.add(new ComponentKey(mApps.get(0).componentName, + UserHandleCompat.myUserHandle())); + mPredictedAppComponents.add(new ComponentKey(mApps.get(0).componentName, + UserHandleCompat.myUserHandle())); + mPredictedAppComponents.add(new ComponentKey(mApps.get(0).componentName, + UserHandleCompat.myUserHandle())); + mPredictedAppComponents.add(new ComponentKey(mApps.get(0).componentName, + UserHandleCompat.myUserHandle())); + } + } + + // Process the predicted app components + mPredictedApps.clear(); + if (mPredictedAppComponents != null && !mPredictedAppComponents.isEmpty() && !hasFilter()) { + for (ComponentKey ck : mPredictedAppComponents) { + AppInfo info = mComponentToAppMap.get(ck); + if (info != null) { + mPredictedApps.add(info); + } else { + if (LauncherAppState.isDogfoodBuild()) { + Log.e(TAG, "Predicted app not found: " + ck.flattenToString(mLauncher)); + } + } + // Stop at the number of predicted apps + if (mPredictedApps.size() == mNumPredictedAppsPerRow) { + break; + } + } + + if (!mPredictedApps.isEmpty()) { + // Add a section for the predictions + lastSectionInfo = new SectionInfo(); + lastFastScrollerSectionInfo = new FastScrollSectionInfo(""); + AdapterItem sectionItem = AdapterItem.asSectionBreak(position++, lastSectionInfo); + mSections.add(lastSectionInfo); + mFastScrollerSections.add(lastFastScrollerSectionInfo); + mAdapterItems.add(sectionItem); + + // Add the predicted app items + for (AppInfo info : mPredictedApps) { + AdapterItem appItem = AdapterItem.asPredictedApp(position++, lastSectionInfo, + "", lastSectionInfo.numApps++, info, appIndex++); + if (lastSectionInfo.firstAppItem == null) { + lastSectionInfo.firstAppItem = appItem; + lastFastScrollerSectionInfo.fastScrollToItem = appItem; + } + mAdapterItems.add(appItem); + mFilteredApps.add(info); + } + } + } + + // Recreate the filtered and sectioned apps (for convenience for the grid layout) from the + // ordered set of sections + for (AppInfo info : getFiltersAppInfos()) { + String sectionName = getAndUpdateCachedSectionName(info.title); + + // Create a new section if the section names do not match + if (lastSectionInfo == null || !sectionName.equals(lastSectionName)) { + lastSectionName = sectionName; + lastSectionInfo = new SectionInfo(); + lastFastScrollerSectionInfo = new FastScrollSectionInfo(sectionName); + mSections.add(lastSectionInfo); + mFastScrollerSections.add(lastFastScrollerSectionInfo); + + // Create a new section item to break the flow of items in the list + if (!hasFilter()) { + AdapterItem sectionItem = AdapterItem.asSectionBreak(position++, lastSectionInfo); + mAdapterItems.add(sectionItem); + } + } + + // Create an app item + AdapterItem appItem = AdapterItem.asApp(position++, lastSectionInfo, sectionName, + lastSectionInfo.numApps++, info, appIndex++); + if (lastSectionInfo.firstAppItem == null) { + lastSectionInfo.firstAppItem = appItem; + lastFastScrollerSectionInfo.fastScrollToItem = appItem; + } + mAdapterItems.add(appItem); + mFilteredApps.add(info); + } + + // Append the search market item if we are currently searching + if (hasFilter()) { + if (hasNoFilteredResults()) { + mAdapterItems.add(AdapterItem.asEmptySearch(position++)); + } else { + mAdapterItems.add(AdapterItem.asDivider(position++)); + } + mAdapterItems.add(AdapterItem.asMarketSearch(position++)); + } + + // Merge multiple sections together as requested by the merge strategy for this device + mergeSections(); + + if (mNumAppsPerRow != 0) { + // Update the number of rows in the adapter after we do all the merging (otherwise, we + // would have to shift the values again) + int numAppsInSection = 0; + int numAppsInRow = 0; + int rowIndex = -1; + for (AdapterItem item : mAdapterItems) { + item.rowIndex = 0; + if (item.viewType == AllAppsGridAdapter.SECTION_BREAK_VIEW_TYPE) { + numAppsInSection = 0; + } else if (item.viewType == AllAppsGridAdapter.ICON_VIEW_TYPE || + item.viewType == AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE) { + if (numAppsInSection % mNumAppsPerRow == 0) { + numAppsInRow = 0; + rowIndex++; + } + item.rowIndex = rowIndex; + item.rowAppIndex = numAppsInRow; + numAppsInSection++; + numAppsInRow++; + } + } + mNumAppRowsInAdapter = rowIndex + 1; + + // Pre-calculate all the fast scroller fractions + switch (mFastScrollDistributionMode) { + case FAST_SCROLL_FRACTION_DISTRIBUTE_BY_ROWS_FRACTION: + float rowFraction = 1f / mNumAppRowsInAdapter; + for (FastScrollSectionInfo info : mFastScrollerSections) { + AdapterItem item = info.fastScrollToItem; + if (item.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE && + item.viewType != AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE) { + info.touchFraction = 0f; + continue; + } + + float subRowFraction = item.rowAppIndex * (rowFraction / mNumAppsPerRow); + info.touchFraction = item.rowIndex * rowFraction + subRowFraction; + } + break; + case FAST_SCROLL_FRACTION_DISTRIBUTE_BY_NUM_SECTIONS: + float perSectionTouchFraction = 1f / mFastScrollerSections.size(); + float cumulativeTouchFraction = 0f; + for (FastScrollSectionInfo info : mFastScrollerSections) { + AdapterItem item = info.fastScrollToItem; + if (item.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE && + item.viewType != AllAppsGridAdapter.PREDICTION_ICON_VIEW_TYPE) { + info.touchFraction = 0f; + continue; + } + info.touchFraction = cumulativeTouchFraction; + cumulativeTouchFraction += perSectionTouchFraction; + } + break; + } + } + + // Refresh the recycler view + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + } + + private List<AppInfo> getFiltersAppInfos() { + if (mSearchResults == null) { + return mApps; + } + + ArrayList<AppInfo> result = new ArrayList<>(); + for (ComponentKey key : mSearchResults) { + AppInfo match = mComponentToAppMap.get(key); + if (match != null) { + result.add(match); + } + } + return result; + } + + /** + * Merges multiple sections to reduce visual raggedness. + */ + private void mergeSections() { + // Ignore merging until we have an algorithm and a valid row size + if (mMergeAlgorithm == null || mNumAppsPerRow == 0) { + return; + } + + // Go through each section and try and merge some of the sections + if (!hasFilter()) { + int sectionAppCount = 0; + for (int i = 0; i < mSections.size() - 1; i++) { + SectionInfo section = mSections.get(i); + sectionAppCount = section.numApps; + int mergeCount = 1; + + // Merge rows based on the current strategy + while (i < (mSections.size() - 1) && + mMergeAlgorithm.continueMerging(section, mSections.get(i + 1), + sectionAppCount, mNumAppsPerRow, mergeCount)) { + SectionInfo nextSection = mSections.remove(i + 1); + + // Remove the next section break + mAdapterItems.remove(nextSection.sectionBreakItem); + int pos = mAdapterItems.indexOf(section.firstAppItem); + + // Point the section for these new apps to the merged section + int nextPos = pos + section.numApps; + for (int j = nextPos; j < (nextPos + nextSection.numApps); j++) { + AdapterItem item = mAdapterItems.get(j); + item.sectionInfo = section; + item.sectionAppIndex += section.numApps; + } + + // Update the following adapter items of the removed section item + pos = mAdapterItems.indexOf(nextSection.firstAppItem); + for (int j = pos; j < mAdapterItems.size(); j++) { + AdapterItem item = mAdapterItems.get(j); + item.position--; + } + section.numApps += nextSection.numApps; + sectionAppCount += nextSection.numApps; + + if (DEBUG) { + Log.d(TAG, "Merging: " + nextSection.firstAppItem.sectionName + + " to " + section.firstAppItem.sectionName + + " mergedNumRows: " + (sectionAppCount / mNumAppsPerRow)); + } + mergeCount++; + } + } + } + } + + /** + * Returns the cached section name for the given title, recomputing and updating the cache if + * the title has no cached section name. + */ + private String getAndUpdateCachedSectionName(CharSequence title) { + String sectionName = mCachedSectionNames.get(title); + if (sectionName == null) { + sectionName = mIndexer.computeSectionName(title); + mCachedSectionNames.put(title, sectionName); + } + return sectionName; + } +} diff --git a/src/com/android/launcher3/allapps/DefaultAppSearchAlgorithm.java b/src/com/android/launcher3/allapps/DefaultAppSearchAlgorithm.java new file mode 100644 index 000000000..10740ec77 --- /dev/null +++ b/src/com/android/launcher3/allapps/DefaultAppSearchAlgorithm.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.os.Handler; + +import com.android.launcher3.AppInfo; +import com.android.launcher3.util.ComponentKey; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * The default search implementation. + */ +public class DefaultAppSearchAlgorithm { + + private static final Pattern SPLIT_PATTERN = Pattern.compile("[\\s|\\p{javaSpaceChar}]+"); + + private final List<AppInfo> mApps; + protected final Handler mResultHandler; + + public DefaultAppSearchAlgorithm(List<AppInfo> apps) { + mApps = apps; + mResultHandler = new Handler(); + } + + public void cancel(boolean interruptActiveRequests) { + if (interruptActiveRequests) { + mResultHandler.removeCallbacksAndMessages(null); + } + } + + public void doSearch(final String query, + final AllAppsSearchBarController.Callbacks callback) { + final ArrayList<ComponentKey> result = getTitleMatchResult(query); + mResultHandler.post(new Runnable() { + + @Override + public void run() { + callback.onSearchResult(query, result); + } + }); + } + + protected ArrayList<ComponentKey> getTitleMatchResult(String query) { + // Do an intersection of the words in the query and each title, and filter out all the + // apps that don't match all of the words in the query. + final String queryTextLower = query.toLowerCase(); + final String[] queryWords = SPLIT_PATTERN.split(queryTextLower); + + final ArrayList<ComponentKey> result = new ArrayList<>(); + for (AppInfo info : mApps) { + if (matches(info, queryWords)) { + result.add(info.toComponentKey()); + } + } + return result; + } + + protected boolean matches(AppInfo info, String[] queryWords) { + String title = info.title.toString(); + String[] words = SPLIT_PATTERN.split(title.toLowerCase()); + for (int qi = 0; qi < queryWords.length; qi++) { + boolean foundMatch = false; + for (int i = 0; i < words.length; i++) { + if (words[i].startsWith(queryWords[qi])) { + foundMatch = true; + break; + } + } + if (!foundMatch) { + // If there is a word in the query that does not match any words in this + // title, so skip it. + return false; + } + } + return true; + } +} diff --git a/src/com/android/launcher3/allapps/DefaultAppSearchController.java b/src/com/android/launcher3/allapps/DefaultAppSearchController.java new file mode 100644 index 000000000..3169f842a --- /dev/null +++ b/src/com/android/launcher3/allapps/DefaultAppSearchController.java @@ -0,0 +1,275 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.launcher3.allapps; + +import android.content.Context; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.TextView; +import com.android.launcher3.ExtendedEditText; +import com.android.launcher3.R; +import com.android.launcher3.Utilities; +import com.android.launcher3.util.Thunk; + +import java.util.List; + + +/** + * The default search controller. + */ +final class DefaultAppSearchController extends AllAppsSearchBarController + implements TextWatcher, TextView.OnEditorActionListener, View.OnClickListener { + + private static final boolean ALLOW_SINGLE_APP_LAUNCH = true; + + private static final int FADE_IN_DURATION = 175; + private static final int FADE_OUT_DURATION = 100; + private static final int SEARCH_TRANSLATION_X_DP = 18; + + private final Context mContext; + @Thunk final InputMethodManager mInputMethodManager; + + private DefaultAppSearchAlgorithm mSearchManager; + + private ViewGroup mContainerView; + private View mSearchView; + @Thunk View mSearchBarContainerView; + private View mSearchButtonView; + private View mDismissSearchButtonView; + @Thunk + ExtendedEditText mSearchBarEditView; + @Thunk AllAppsRecyclerView mAppsRecyclerView; + @Thunk Runnable mFocusRecyclerViewRunnable = new Runnable() { + @Override + public void run() { + mAppsRecyclerView.requestFocus(); + } + }; + + public DefaultAppSearchController(Context context, ViewGroup containerView, + AllAppsRecyclerView appsRecyclerView) { + mContext = context; + mInputMethodManager = (InputMethodManager) + mContext.getSystemService(Context.INPUT_METHOD_SERVICE); + mContainerView = containerView; + mAppsRecyclerView = appsRecyclerView; + } + + @Override + public View getView(ViewGroup parent) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + mSearchView = inflater.inflate(R.layout.all_apps_search_bar, parent, false); + mSearchView.setOnClickListener(this); + + mSearchButtonView = mSearchView.findViewById(R.id.search_button); + mSearchBarContainerView = mSearchView.findViewById(R.id.search_container); + mDismissSearchButtonView = mSearchBarContainerView.findViewById(R.id.dismiss_search_button); + mDismissSearchButtonView.setOnClickListener(this); + mSearchBarEditView = (ExtendedEditText) + mSearchBarContainerView.findViewById(R.id.search_box_input); + mSearchBarEditView.addTextChangedListener(this); + mSearchBarEditView.setOnEditorActionListener(this); + mSearchBarEditView.setOnBackKeyListener( + new ExtendedEditText.OnBackKeyListener() { + @Override + public boolean onBackKey() { + // Only hide the search field if there is no query, or if there + // are no filtered results + String query = Utilities.trim( + mSearchBarEditView.getEditableText().toString()); + if (query.isEmpty() || mApps.hasNoFilteredResults()) { + hideSearchField(true, mFocusRecyclerViewRunnable); + return true; + } + return false; + } + }); + return mSearchView; + } + + @Override + public void focusSearchField() { + mSearchBarEditView.requestFocus(); + showSearchField(); + } + + @Override + public boolean isSearchFieldFocused() { + return mSearchBarEditView.isFocused(); + } + + @Override + protected void onInitialize() { + mSearchManager = new DefaultAppSearchAlgorithm(mApps.getApps()); + } + + @Override + public void reset() { + hideSearchField(false, null); + } + + @Override + public boolean shouldShowPredictionBar() { + return false; + } + + @Override + public void onClick(View v) { + if (v == mSearchView) { + showSearchField(); + } else if (v == mDismissSearchButtonView) { + hideSearchField(true, mFocusRecyclerViewRunnable); + } + } + + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + // Do nothing + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + // Do nothing + } + + @Override + public void afterTextChanged(final Editable s) { + String query = s.toString(); + if (query.isEmpty()) { + mSearchManager.cancel(true); + mCb.clearSearchResult(); + } else { + mSearchManager.cancel(false); + mSearchManager.doSearch(query, mCb); + } + } + + @Override + public boolean onEditorAction(TextView v, int actionId, KeyEvent event) { + // Skip if we disallow app-launch-on-enter + if (!ALLOW_SINGLE_APP_LAUNCH) { + return false; + } + // Skip if it's not the right action + if (actionId != EditorInfo.IME_ACTION_SEARCH) { + return false; + } + // Skip if there are more than one icon + if (mApps.getNumFilteredApps() > 1) { + return false; + } + // Otherwise, find the first icon, or fallback to the search-market-view and launch it + List<AlphabeticalAppsList.AdapterItem> items = mApps.getAdapterItems(); + for (int i = 0; i < items.size(); i++) { + AlphabeticalAppsList.AdapterItem item = items.get(i); + switch (item.viewType) { + case AllAppsGridAdapter.ICON_VIEW_TYPE: + case AllAppsGridAdapter.SEARCH_MARKET_VIEW_TYPE: + mAppsRecyclerView.getChildAt(i).performClick(); + mInputMethodManager.hideSoftInputFromWindow( + mContainerView.getWindowToken(), 0); + return true; + } + } + return false; + } + + /** + * Focuses the search field. + */ + private void showSearchField() { + // Show the search bar and focus the search + final int translationX = Utilities.pxFromDp(SEARCH_TRANSLATION_X_DP, + mContext.getResources().getDisplayMetrics()); + mSearchBarContainerView.setVisibility(View.VISIBLE); + mSearchBarContainerView.setAlpha(0f); + mSearchBarContainerView.setTranslationX(translationX); + mSearchBarContainerView.animate() + .alpha(1f) + .translationX(0) + .setDuration(FADE_IN_DURATION) + .withLayer() + .withEndAction(new Runnable() { + @Override + public void run() { + mSearchBarEditView.requestFocus(); + mInputMethodManager.showSoftInput(mSearchBarEditView, + InputMethodManager.SHOW_IMPLICIT); + } + }); + mSearchButtonView.animate() + .alpha(0f) + .translationX(-translationX) + .setDuration(FADE_OUT_DURATION) + .withLayer(); + } + + /** + * Unfocuses the search field. + */ + @Thunk void hideSearchField(boolean animated, final Runnable postAnimationRunnable) { + mSearchManager.cancel(true); + + final boolean resetTextField = mSearchBarEditView.getText().toString().length() > 0; + final int translationX = Utilities.pxFromDp(SEARCH_TRANSLATION_X_DP, + mContext.getResources().getDisplayMetrics()); + if (animated) { + // Hide the search bar and focus the recycler view + mSearchBarContainerView.animate() + .alpha(0f) + .translationX(0) + .setDuration(FADE_IN_DURATION) + .withLayer() + .withEndAction(new Runnable() { + @Override + public void run() { + mSearchBarContainerView.setVisibility(View.INVISIBLE); + if (resetTextField) { + mSearchBarEditView.setText(""); + } + mCb.clearSearchResult(); + if (postAnimationRunnable != null) { + postAnimationRunnable.run(); + } + } + }); + mSearchButtonView.setTranslationX(-translationX); + mSearchButtonView.animate() + .alpha(1f) + .translationX(0) + .setDuration(FADE_OUT_DURATION) + .withLayer(); + } else { + mSearchBarContainerView.setVisibility(View.INVISIBLE); + if (resetTextField) { + mSearchBarEditView.setText(""); + } + mCb.clearSearchResult(); + mSearchButtonView.setAlpha(1f); + mSearchButtonView.setTranslationX(0f); + if (postAnimationRunnable != null) { + postAnimationRunnable.run(); + } + } + mInputMethodManager.hideSoftInputFromWindow(mContainerView.getWindowToken(), 0); + } +} |