Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a4acc90b authored by Ben Kwa's avatar Ben Kwa
Browse files

Implement type-to-focus in the DirectoryFragment.

Add a helper to the FocusManager, to handle alphanumeric keystrokes.
- Build a search index mapping document titles to adapter positions, and
  keep it up-to-date.
- Intercept alphanumeric keystrokes and build up a search string from
  them.
- Use the search string and index to set focus in the directory listing.
- Highlight code is written, but not activated in the UI for now, to
  minimize surface area for potential breakages.  Revisit enabling
  highlights for O.

BUG=24988911

Change-Id: I148ff5583c655338d12bc06d6484a9ed7aa528ad
parent 21eb4e26
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -25,6 +25,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>
+3 −3
Original line number Diff line number Diff line
@@ -267,13 +267,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);
+265 −7
Original line number Diff line number Diff line
@@ -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)) {
@@ -229,7 +252,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() {
@@ -251,6 +273,7 @@ class FocusManager implements View.OnFocusChangeListener {
                            }
                        }
                    });
            mView.smoothScrollToPosition(pos);
        }
    }

@@ -260,4 +283,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);
                }
            }
        };
    }
}
+1 −2
Original line number Diff line number Diff line
@@ -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);
        }

+1 −1
Original line number Diff line number Diff line
@@ -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) {
Loading