diff --git a/packages/DocumentsUI/res/values/colors.xml b/packages/DocumentsUI/res/values/colors.xml
index f7f7cc064a662..3785adf7f8787 100644
--- a/packages/DocumentsUI/res/values/colors.xml
+++ b/packages/DocumentsUI/res/values/colors.xml
@@ -30,6 +30,7 @@
@*android:color/primary_dark_material_dark
@*android:color/material_blue_grey_900
@*android:color/accent_material_light
+ @*android:color/accent_material_dark
@color/material_grey_400
#88ffffff
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
index 07515ef6da222..0a9789f54e318 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java
@@ -268,13 +268,13 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
mSelectionManager.addCallback(selectionListener);
- // Make sure this is done after the RecyclerView is set up.
- mFocusManager = new FocusManager(mRecView);
-
mModel = new Model();
mModel.addUpdateListener(mAdapter);
mModel.addUpdateListener(mModelUpdateListener);
+ // Make sure this is done after the RecyclerView is set up.
+ mFocusManager = new FocusManager(context, mRecView, mModel);
+
mType = getArguments().getInt(EXTRA_TYPE);
mTuner = FragmentTuner.pick(getContext(), state);
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java
index e90a4475d5d56..7f867d521c43f 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java
@@ -16,13 +16,28 @@
package com.android.documentsui.dirlist;
+import static com.android.documentsui.model.DocumentInfo.getCursorString;
+
+import android.content.Context;
+import android.provider.DocumentsContract.Document;
import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.RecyclerView;
+import android.text.Editable;
+import android.text.Spannable;
+import android.text.method.KeyListener;
+import android.text.method.TextKeyListener;
+import android.text.method.TextKeyListener.Capitalize;
+import android.text.style.BackgroundColorSpan;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
+import android.widget.TextView;
import com.android.documentsui.Events;
+import com.android.documentsui.R;
+
+import java.util.ArrayList;
+import java.util.List;
/**
* A class that handles navigation and focus within the DirectoryFragment.
@@ -31,15 +46,21 @@ class FocusManager implements View.OnFocusChangeListener {
private static final String TAG = "FocusManager";
private RecyclerView mView;
- private RecyclerView.Adapter> mAdapter;
+ private DocumentsAdapter mAdapter;
private GridLayoutManager mLayout;
+ private TitleSearchHelper mSearchHelper;
+ private Model mModel;
+
private int mLastFocusPosition = RecyclerView.NO_POSITION;
- public FocusManager(RecyclerView view) {
+ public FocusManager(Context context, RecyclerView view, Model model) {
mView = view;
- mAdapter = view.getAdapter();
+ mAdapter = (DocumentsAdapter) view.getAdapter();
mLayout = (GridLayoutManager) view.getLayoutManager();
+ mModel = model;
+
+ mSearchHelper = new TitleSearchHelper(context);
}
/**
@@ -52,7 +73,11 @@ class FocusManager implements View.OnFocusChangeListener {
* @return Whether the event was handled.
*/
public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
- boolean extendSelection = false;
+ // Search helper gets first crack, for doing type-to-focus.
+ if (mSearchHelper.handleKey(doc, keyCode, event)) {
+ return true;
+ }
+
// Translate space/shift-space into PgDn/PgUp
if (keyCode == KeyEvent.KEYCODE_SPACE) {
if (event.isShiftPressed()) {
@@ -60,8 +85,6 @@ class FocusManager implements View.OnFocusChangeListener {
} else {
keyCode = KeyEvent.KEYCODE_PAGE_DOWN;
}
- } else {
- extendSelection = event.isShiftPressed();
}
if (Events.isNavigationKeyCode(keyCode)) {
@@ -236,7 +259,6 @@ class FocusManager implements View.OnFocusChangeListener {
if (vh != null) {
vh.itemView.requestFocus();
} else {
- mView.smoothScrollToPosition(pos);
// Set a one-time listener to request focus when the scroll has completed.
mView.addOnScrollListener(
new RecyclerView.OnScrollListener() {
@@ -258,6 +280,7 @@ class FocusManager implements View.OnFocusChangeListener {
}
}
});
+ mView.smoothScrollToPosition(pos);
}
}
@@ -267,4 +290,239 @@ class FocusManager implements View.OnFocusChangeListener {
private boolean inGridMode() {
return mLayout.getSpanCount() > 1;
}
+
+ /**
+ * A helper class for handling type-to-focus. Instantiate this class, and pass it KeyEvents via
+ * the {@link #handleKey(DocumentHolder, int, KeyEvent)} method. The class internally will build
+ * up a string from individual key events, and perform searching based on that string. When an
+ * item is found that matches the search term, that item will be focused. This class also
+ * highlights instances of the search term found in the view.
+ */
+ private class TitleSearchHelper {
+ final private KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false);
+ final private Editable mSearchString = Editable.Factory.getInstance().newEditable("");
+ final private Highlighter mHighlighter = new Highlighter();
+ final private BackgroundColorSpan mSpan;
+ private List mIndex;
+ private boolean mActive;
+
+ public TitleSearchHelper(Context context) {
+ mSpan = new BackgroundColorSpan(context.getColor(R.color.accent_dark));
+ }
+
+ /**
+ * Handles alphanumeric keystrokes for type-to-focus. This method builds a search term out
+ * of individual key events, and then performs a search for the given string.
+ *
+ * @param doc The document holder receiving the key event.
+ * @param keyCode
+ * @param event
+ * @return Whether the event was handled.
+ */
+ public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_ESCAPE:
+ case KeyEvent.KEYCODE_ENTER:
+ if (mActive) {
+ // These keys end any active searches.
+ deactivate();
+ return true;
+ } else {
+ // Don't handle these key events if there is no active search.
+ return false;
+ }
+ case KeyEvent.KEYCODE_SPACE:
+ // This allows users to search for files with spaces in their names, but ignores
+ // spacebar events when a text search is not active.
+ if (!mActive) {
+ return false;
+ }
+ }
+
+ // Navigation keys also end active searches.
+ if (Events.isNavigationKeyCode(keyCode)) {
+ deactivate();
+ // Don't handle the keycode, so navigation still occurs.
+ return false;
+ }
+
+ // Build up the search string, and perform the search.
+ boolean handled = mTextListener.onKeyDown(doc.itemView, mSearchString, keyCode, event);
+
+ // Delete is processed by the text listener, but not "handled". Check separately for it.
+ if (handled || keyCode == KeyEvent.KEYCODE_DEL) {
+ String searchString = mSearchString.toString();
+ if (searchString.length() == 0) {
+ // Don't perform empty searches.
+ return false;
+ }
+ activate();
+ for (int pos = 0; pos < mIndex.size(); pos++) {
+ String title = mIndex.get(pos);
+ if (title != null && title.startsWith(searchString)) {
+ focusItem(pos);
+ break;
+ }
+ }
+ }
+
+ return handled;
+ }
+
+ /**
+ * Activates the search helper, which changes its key handling and updates the search index
+ * and highlights if necessary. Call this each time the search term is updated.
+ */
+ private void activate() {
+ if (!mActive) {
+ // Install listeners.
+ mModel.addUpdateListener(mModelListener);
+ }
+
+ // If the search index was invalidated, rebuild it
+ if (mIndex == null) {
+ buildIndex();
+ }
+
+ // TODO: Uncomment this to enable search term highlighting in the UI.
+// mHighlighter.activate();
+
+ mActive = true;
+ }
+
+ /**
+ * Deactivates the search helper (see {@link #activate()}). Call this when a search ends.
+ */
+ private void deactivate() {
+ if (mActive) {
+ // Remove listeners.
+ mModel.removeUpdateListener(mModelListener);
+ }
+
+ // TODO: Uncomment this when search-term highlighting is enabled in the UI.
+// mHighlighter.deactivate();
+
+ mIndex = null;
+ mSearchString.clear();
+ mActive = false;
+ }
+
+ /**
+ * Applies title highlights to the given view. The view must have a title field that is a
+ * spannable text field. If this condition is not met, this function does nothing.
+ *
+ * @param view
+ */
+ private void applyHighlight(View view) {
+ TextView titleView = (TextView) view.findViewById(android.R.id.title);
+ if (titleView == null) {
+ return;
+ }
+
+ String searchString = mSearchString.toString();
+ CharSequence tmpText = titleView.getText();
+ if (tmpText instanceof Spannable) {
+ Spannable title = (Spannable) tmpText;
+ String titleString = title.toString();
+ if (titleString.startsWith(searchString)) {
+ title.setSpan(mSpan, 0, searchString.length(),
+ Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+ } else {
+ title.removeSpan(mSpan);
+ }
+ }
+ }
+
+ /**
+ * Removes title highlights from the given view. The view must have a title field that is a
+ * spannable text field. If this condition is not met, this function does nothing.
+ *
+ * @param view
+ */
+ private void removeHighlight(View view) {
+ TextView titleView = (TextView) view.findViewById(android.R.id.title);
+ if (titleView == null) {
+ return;
+ }
+
+ CharSequence tmpText = titleView.getText();
+ if (tmpText instanceof Spannable) {
+ ((Spannable) tmpText).removeSpan(mSpan);
+ }
+ }
+
+ /**
+ * Builds a search index for finding items by title. Queries the model and adapter, so both
+ * must be set up before calling this method.
+ */
+ private void buildIndex() {
+ int itemCount = mAdapter.getItemCount();
+ List index = new ArrayList<>(itemCount);
+ for (int i = 0; i < itemCount; i++) {
+ String modelId = mAdapter.getModelId(i);
+ if (modelId != null) {
+ index.add(
+ getCursorString(mModel.getItem(modelId), Document.COLUMN_DISPLAY_NAME));
+ } else {
+ index.add("");
+ }
+ }
+ mIndex = index;
+ }
+
+ private Model.UpdateListener mModelListener = new Model.UpdateListener() {
+ @Override
+ public void onModelUpdate(Model model) {
+ // Invalidate the search index when the model updates.
+ mIndex = null;
+ }
+
+ @Override
+ public void onModelUpdateFailed(Exception e) {
+ // Invalidate the search index when the model updates.
+ mIndex = null;
+ }
+ };
+
+ private class Highlighter implements RecyclerView.OnChildAttachStateChangeListener {
+ /**
+ * Starts highlighting instances of the current search term in the UI.
+ */
+ public void activate() {
+ // Update highlights on all views
+ int itemCount = mView.getChildCount();
+ for (int i = 0; i < itemCount; i++) {
+ applyHighlight(mView.getChildAt(i));
+ }
+ // Keep highlights up-to-date as items come in and out of view.
+ mView.addOnChildAttachStateChangeListener(this);
+ }
+
+ /**
+ * Stops highlighting instances of the current search term in the UI.
+ */
+ public void deactivate() {
+ // Remove highlights on all views
+ int itemCount = mView.getChildCount();
+ for (int i = 0; i < itemCount; i++) {
+ removeHighlight(mView.getChildAt(i));
+ }
+ // Stop updating highlights.
+ mView.removeOnChildAttachStateChangeListener(this);
+ }
+
+ @Override
+ public void onChildViewAttachedToWindow(View view) {
+ applyHighlight(view);
+ }
+
+ @Override
+ public void onChildViewDetachedFromWindow(View view) {
+ TextView titleView = (TextView) view.findViewById(android.R.id.title);
+ if (titleView != null) {
+ removeHighlight(titleView);
+ }
+ }
+ };
+ }
}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/GridDocumentHolder.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/GridDocumentHolder.java
index 055adc6a2fb1f..8eaed17e86768 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/GridDocumentHolder.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/GridDocumentHolder.java
@@ -32,7 +32,6 @@ import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
-import com.android.documentsui.IconUtils;
import com.android.documentsui.R;
import com.android.documentsui.RootCursorWrapper;
import com.android.documentsui.Shared;
@@ -107,7 +106,7 @@ final class GridDocumentHolder extends DocumentHolder {
if (mHideTitles) {
mTitle.setVisibility(View.GONE);
} else {
- mTitle.setText(docDisplayName);
+ mTitle.setText(docDisplayName, TextView.BufferType.SPANNABLE);
mTitle.setVisibility(View.VISIBLE);
}
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ListDocumentHolder.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ListDocumentHolder.java
index 8c3b53c75da60..be6413bfbf6ca 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ListDocumentHolder.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ListDocumentHolder.java
@@ -103,7 +103,7 @@ final class ListDocumentHolder extends DocumentHolder {
final Uri uri = DocumentsContract.buildDocumentUri(docAuthority, docId);
mIconHelper.loadThumbnail(uri, docMimeType, docFlags, docIcon, mIconThumb, mIconMime, null);
- mTitle.setText(docDisplayName);
+ mTitle.setText(docDisplayName, TextView.BufferType.SPANNABLE);
mTitle.setVisibility(View.VISIBLE);
if (docSummary != null) {
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java
index 3a45995a8c416..b3694489aca2f 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/Model.java
@@ -391,6 +391,10 @@ public class Model {
mUpdateListeners.add(listener);
}
+ void removeUpdateListener(UpdateListener listener) {
+ mUpdateListeners.remove(listener);
+ }
+
static interface UpdateListener {
/**
* Called when a successful update has occurred.
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java
index 69a67111ca38f..dd27790ace33f 100644
--- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java
+++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/ModelBackedDocumentsAdapter.java
@@ -182,7 +182,7 @@ final class ModelBackedDocumentsAdapter extends DocumentsAdapter {
@Override
public void unhide(SparseArray ids) {
- if (DEBUG) Log.d(TAG, "Un-iding ids: " + ids);
+ if (DEBUG) Log.d(TAG, "Unhiding ids: " + ids);
// An ArrayList can shrink at runtime...and in fact
// it does when we clear it completely.