Merge "Implement type-to-focus in the DirectoryFragment." into nyc-dev

am: 7b300ff369

* commit '7b300ff36932a4815f1f85b9ea73ec9a373dee10':
  Implement type-to-focus in the DirectoryFragment.
This commit is contained in:
Ben Kwa
2016-02-19 00:47:04 +00:00
committed by android-build-merger
7 changed files with 276 additions and 14 deletions

View File

@@ -30,6 +30,7 @@
<color name="primary_dark">@*android:color/primary_dark_material_dark</color>
<color name="primary">@*android:color/material_blue_grey_900</color>
<color name="accent">@*android:color/accent_material_light</color>
<color name="accent_dark">@*android:color/accent_material_dark</color>
<color name="action_mode">@color/material_grey_400</color>
<color name="band_select_background">#88ffffff</color>

View File

@@ -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);

View File

@@ -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<String> 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<String> 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);
}
}
};
}
}

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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.

View File

@@ -182,7 +182,7 @@ final class ModelBackedDocumentsAdapter extends DocumentsAdapter {
@Override
public void unhide(SparseArray<String> 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.