diff options
3 files changed, 140 insertions, 106 deletions
diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java index 7138c2d19fe6..cb6cf8dc7e28 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java @@ -1236,6 +1236,11 @@ public class DirectoryFragment extends Fragment return false; } + // Ignore tab key events. Those should be handled by the top-level key handler. + if (keyCode == KeyEvent.KEYCODE_TAB) { + return false; + } + if (mFocusManager.handleKey(doc, keyCode, event)) { // Handle range selection adjustments. Extending the selection will adjust the // bounds of the in-progress range selection. Each time an unshifted navigation diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java index 7f867d521c43..1ede082fdbd3 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java @@ -18,7 +18,11 @@ package com.android.documentsui.dirlist; import static com.android.documentsui.model.DocumentInfo.getCursorString; +import android.annotation.Nullable; import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; import android.provider.DocumentsContract.Document; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.RecyclerView; @@ -38,6 +42,8 @@ import com.android.documentsui.R; import java.util.ArrayList; import java.util.List; +import java.util.Timer; +import java.util.TimerTask; /** * A class that handles navigation and focus within the DirectoryFragment. @@ -254,10 +260,23 @@ class FocusManager implements View.OnFocusChangeListener { * @param pos */ private void focusItem(final int pos) { + focusItem(pos, null); + } + + /** + * Requests focus for the item in the given adapter position, scrolling the RecyclerView if + * necessary. + * + * @param pos + * @param callback A callback to call after the given item has been focused. + */ + private void focusItem(final int pos, @Nullable final FocusCallback callback) { // If the item is already in view, focus it; otherwise, scroll to it and focus it. RecyclerView.ViewHolder vh = mView.findViewHolderForAdapterPosition(pos); if (vh != null) { - vh.itemView.requestFocus(); + if (vh.itemView.requestFocus() && callback != null) { + callback.onFocus(vh.itemView); + } } else { // Set a one-time listener to request focus when the scroll has completed. mView.addOnScrollListener( @@ -269,7 +288,9 @@ class FocusManager implements View.OnFocusChangeListener { RecyclerView.ViewHolder vh = view.findViewHolderForAdapterPosition(pos); if (vh != null) { - vh.itemView.requestFocus(); + if (vh.itemView.requestFocus() && callback != null) { + callback.onFocus(vh.itemView); + } } else { // This might happen in weird corner cases, e.g. if the user is // scrolling while a delete operation is in progress. In that @@ -291,6 +312,10 @@ class FocusManager implements View.OnFocusChangeListener { return mLayout.getSpanCount() > 1; } + private interface FocusCallback { + public void onFocus(View view); + } + /** * 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 @@ -299,15 +324,24 @@ class FocusManager implements View.OnFocusChangeListener { * 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; + static private final int SEARCH_TIMEOUT = 500; // ms + + private final KeyListener mTextListener = new TextKeyListener(Capitalize.NONE, false); + private final Editable mSearchString = Editable.Factory.getInstance().newEditable(""); + private final Highlighter mHighlighter = new Highlighter(); + private final BackgroundColorSpan mSpan; + private List<String> mIndex; private boolean mActive; + private Timer mTimer; + private KeyEvent mLastEvent; + private Handler mUiRunner; public TitleSearchHelper(Context context) { mSpan = new BackgroundColorSpan(context.getColor(R.color.accent_dark)); + // Handler for running things on the main UI thread. Needed for updating the UI from a + // timer (see #activate, below). + mUiRunner = new Handler(Looper.getMainLooper()); } /** @@ -325,7 +359,7 @@ class FocusManager implements View.OnFocusChangeListener { case KeyEvent.KEYCODE_ENTER: if (mActive) { // These keys end any active searches. - deactivate(); + endSearch(); return true; } else { // Don't handle these key events if there is no active search. @@ -333,7 +367,9 @@ class FocusManager implements View.OnFocusChangeListener { } 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. + // spacebar events when a text search is not active. Ignoring the spacebar + // event is necessary because other handlers (see FocusManager#handleKey) also + // listen for and handle it. if (!mActive) { return false; } @@ -341,7 +377,7 @@ class FocusManager implements View.OnFocusChangeListener { // Navigation keys also end active searches. if (Events.isNavigationKeyCode(keyCode)) { - deactivate(); + endSearch(); // Don't handle the keycode, so navigation still occurs. return false; } @@ -350,20 +386,17 @@ class FocusManager implements View.OnFocusChangeListener { 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) { + if (keyCode == KeyEvent.KEYCODE_DEL) { + handled = true; + } + + if (handled) { + mLastEvent = event; + if (mSearchString.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; - } - } + search(); } return handled; @@ -373,10 +406,17 @@ class FocusManager implements View.OnFocusChangeListener { * 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() { + private void search() { if (!mActive) { - // Install listeners. + // The model listener invalidates the search index when the model changes. mModel.addUpdateListener(mModelListener); + + // Used to keep the current search alive until the timeout expires. If the user + // presses another key within that time, that keystroke is added to the current + // search. Otherwise, the current search ends, and subsequent keystrokes start a new + // search. + mTimer = new Timer(); + mActive = true; } // If the search index was invalidated, rebuild it @@ -384,23 +424,38 @@ class FocusManager implements View.OnFocusChangeListener { buildIndex(); } - // TODO: Uncomment this to enable search term highlighting in the UI. -// mHighlighter.activate(); - - mActive = true; + // Search for the current search term. + // Perform case-insensitive search. + String searchString = mSearchString.toString().toLowerCase(); + for (int pos = 0; pos < mIndex.size(); pos++) { + String title = mIndex.get(pos); + if (title != null && title.startsWith(searchString)) { + focusItem(pos, new FocusCallback() { + @Override + public void onFocus(View view) { + mHighlighter.applyHighlight(view); + // Using a timer repeat period of SEARCH_TIMEOUT/2 means the amount of + // time between the last keystroke and a search expiring is actually + // between 500 and 750 ms. A smaller timer period results in less + // variability but does more polling. + mTimer.schedule(new TimeoutTask(), 0, SEARCH_TIMEOUT / 2); + } + }); + break; + } + } } /** - * Deactivates the search helper (see {@link #activate()}). Call this when a search ends. + * Ends the current search (see {@link #search()}. */ - private void deactivate() { + private void endSearch() { if (mActive) { - // Remove listeners. mModel.removeUpdateListener(mModelListener); + mTimer.cancel(); } - // TODO: Uncomment this when search-term highlighting is enabled in the UI. -// mHighlighter.deactivate(); + mHighlighter.removeHighlight(); mIndex = null; mSearchString.clear(); @@ -408,50 +463,6 @@ class FocusManager implements View.OnFocusChangeListener { } /** - * 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. */ @@ -461,8 +472,10 @@ class FocusManager implements View.OnFocusChangeListener { 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)); + String title = + getCursorString(mModel.getItem(modelId), Document.COLUMN_DISPLAY_NAME); + // Perform case-insensitive search. + index.add(title.toLowerCase()); } else { index.add(""); } @@ -484,43 +497,58 @@ class FocusManager implements View.OnFocusChangeListener { } }; - 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)); + private class TimeoutTask extends TimerTask { + @Override + public void run() { + long last = mLastEvent.getEventTime(); + long now = SystemClock.uptimeMillis(); + if ((now - last) > SEARCH_TIMEOUT) { + // endSearch must run on the main thread because it does UI work + mUiRunner.post(new Runnable() { + @Override + public void run() { + endSearch(); + } + }); } - // Keep highlights up-to-date as items come in and out of view. - mView.addOnChildAttachStateChangeListener(this); } + }; + + private class Highlighter { + private Spannable mCurrentHighlight; /** - * Stops highlighting instances of the current search term in the UI. + * 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 */ - public void deactivate() { - // Remove highlights on all views - int itemCount = mView.getChildCount(); - for (int i = 0; i < itemCount; i++) { - removeHighlight(mView.getChildAt(i)); + private void applyHighlight(View view) { + TextView titleView = (TextView) view.findViewById(android.R.id.title); + if (titleView == null) { + return; } - // Stop updating highlights. - mView.removeOnChildAttachStateChangeListener(this); - } - @Override - public void onChildViewAttachedToWindow(View view) { - applyHighlight(view); + CharSequence tmpText = titleView.getText(); + if (tmpText instanceof Spannable) { + if (mCurrentHighlight != null) { + mCurrentHighlight.removeSpan(mSpan); + } + mCurrentHighlight = (Spannable) tmpText; + mCurrentHighlight.setSpan( + mSpan, 0, mSearchString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } } - @Override - public void onChildViewDetachedFromWindow(View view) { - TextView titleView = (TextView) view.findViewById(android.R.id.title); - if (titleView != null) { - removeHighlight(titleView); + /** + * 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() { + if (mCurrentHighlight != null) { + mCurrentHighlight.removeSpan(mSpan); } } }; diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/GridDirectoryHolder.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/GridDirectoryHolder.java index e672327dfdae..a0ff1b5812ae 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/GridDirectoryHolder.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/GridDirectoryHolder.java @@ -63,6 +63,7 @@ final class GridDirectoryHolder extends DocumentHolder { this.modelId = modelId; final String docDisplayName = getCursorString(cursor, Document.COLUMN_DISPLAY_NAME); - mTitle.setText(docDisplayName); + mTitle.setText(docDisplayName, TextView.BufferType.SPANNABLE); + } } |