Introduce ChromeOS-style keyboard navigation.

- Turn the DirectoryFragment (DF) and the RootsFragment (RF) into
  top-level views, and allow switching between them via the tab key.

- Disallow arrow-key navigation from switching the user between the
  DF and RF.

- When nothing is explicitly focused, make navigation keys focus the
  DF.  This makes it so that if a user opens DocumentsUI and just starts
  pressing arrow keys, they'll navigate in the directory listing.

- When restoring focus on the DF and RF, remember the last thing that
  was focused, and restore focus on that thing.

BUG=25195767
BUG=25121367

Change-Id: I00e20cbdbe9edfe269fb356440a93ef5d67c5298
(cherry picked from commit 1c9f9222e5)
This commit is contained in:
Ben Kwa
2016-02-10 07:46:35 -08:00
parent 16e1e55271
commit 2036dad877
12 changed files with 223 additions and 16 deletions

View File

@@ -16,11 +16,14 @@
<!-- CoordinatorLayout is necessary for various components (e.g. Snackbars, and
floating action buttons) to operate correctly. -->
<!-- focusableInTouchMode is set in order to force key events to go to the activity's global key
callback, which is necessary for proper event routing. See BaseActivity.onKeyDown. -->
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/coordinator_layout">
android:id="@+id/coordinator_layout"
android:focusableInTouchMode="true">
<LinearLayout
android:layout_width="match_parent"

View File

@@ -83,7 +83,7 @@
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/list"
android:id="@+id/dir_list"
android:scrollbars="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"

View File

@@ -14,8 +14,8 @@
limitations under the License.
-->
<ListView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@android:id/list"
<com.android.documentsui.RootsList xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/roots_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="8dp"

View File

@@ -16,11 +16,14 @@
<!-- CoordinatorLayout is necessary for various components (e.g. Snackbars, and
floating action buttons) to operate correctly. -->
<!-- focusableInTouchMode is set in order to force key events to go to the activity's global key
callback, which is necessary for proper event routing. See BaseActivity.onKeyDown. -->
<android.support.design.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/coordinator_layout">
android:id="@+id/coordinator_layout"
android:focusableInTouchMode="true">
<LinearLayout
android:layout_width="match_parent"

View File

@@ -42,6 +42,7 @@ import android.support.annotation.CallSuper;
import android.support.annotation.LayoutRes;
import android.support.annotation.Nullable;
import android.util.Log;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuItem;
import android.widget.Spinner;
@@ -83,6 +84,8 @@ public abstract class BaseActivity extends Activity
// We use the time gap to figure out whether to close app or reopen the drawer.
private long mDrawerLastFiddled;
private boolean mNavDrawerHasFocus;
public abstract void onDocumentPicked(DocumentInfo doc, @Nullable SiblingProvider siblings);
public abstract void onDocumentsPicked(List<DocumentInfo> docs);
@@ -580,6 +583,54 @@ public abstract class BaseActivity extends Activity
}
}
/**
* Declare a global key handler to route key events when there isn't a specific focus view. This
* covers the scenario where a user opens DocumentsUI and just starts typing.
*
* @param keyCode
* @param event
* @return
*/
@CallSuper
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (Events.isNavigationKeyCode(keyCode)) {
// Forward all unclaimed navigation keystrokes to the DirectoryFragment. This causes any
// stray navigation keystrokes focus the content pane, which is probably what the user
// is trying to do.
DirectoryFragment df = DirectoryFragment.get(getFragmentManager());
if (df != null) {
df.requestFocus();
return true;
}
} else if (keyCode == KeyEvent.KEYCODE_TAB) {
toggleNavDrawerFocus();
return true;
}
return super.onKeyDown(keyCode, event);
}
/**
* Toggles focus between the navigation drawer and the directory listing. If the drawer isn't
* locked, open/close it as appropriate.
*/
void toggleNavDrawerFocus() {
if (mNavDrawerHasFocus) {
mDrawer.setOpen(false);
DirectoryFragment df = DirectoryFragment.get(getFragmentManager());
if (df != null) {
df.requestFocus();
}
} else {
mDrawer.setOpen(true);
RootsFragment rf = RootsFragment.get(getFragmentManager());
if (rf != null) {
rf.requestFocus();
}
}
mNavDrawerHasFocus = !mNavDrawerHasFocus;
}
DocumentInfo getRootDocumentBlocking(RootInfo root) {
try {
final Uri uri = DocumentsContract.buildDocumentUri(

View File

@@ -83,7 +83,7 @@ public class RecentsCreateFragment extends Fragment {
final View view = inflater.inflate(R.layout.fragment_directory, container, false);
mRecView = (RecyclerView) view.findViewById(R.id.list);
mRecView = (RecyclerView) view.findViewById(R.id.dir_list);
mRecView.setLayoutManager(new LinearLayoutManager(getContext()));
mRecView.addOnItemTouchListener(mItemListener);

View File

@@ -89,7 +89,7 @@ public class RootsFragment extends Fragment {
final Context context = inflater.getContext();
final View view = inflater.inflate(R.layout.fragment_roots, container, false);
mList = (ListView) view.findViewById(android.R.id.list);
mList = (ListView) view.findViewById(R.id.roots_list);
mList.setOnItemClickListener(mItemListener);
mList.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
return view;
@@ -167,6 +167,13 @@ public class RootsFragment extends Fragment {
}
}
/**
* Attempts to shift focus back to the navigation drawer.
*/
public void requestFocus() {
mList.requestFocus();
}
private void showAppDetails(ResolveInfo ri) {
final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", ri.activityInfo.packageName, null));

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2016 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.documentsui;
import android.content.Context;
import android.util.AttributeSet;
import android.view.KeyEvent;
import android.widget.ListView;
/**
* The list in the navigation drawer. This class exists for the purpose of overriding the key
* handler on ListView. Ignoring keystrokes (e.g. the tab key) cannot be properly done using
* View.OnKeyListener.
*/
public class RootsList extends ListView {
// Multiple constructors are needed to handle all the different ways this View could be
// constructed by the framework. Don't remove them!
public RootsList(Context context) {
super(context);
}
public RootsList(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public RootsList(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public RootsList(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
switch (keyCode) {
// Ignore tab key events - this causes them to bubble up to the global key handler where
// they are appropriately handled. See BaseActivity.onKeyDown.
case KeyEvent.KEYCODE_TAB:
return false;
// Prevent left/right arrow keystrokes from shifting focus away from the roots list.
case KeyEvent.KEYCODE_DPAD_LEFT:
case KeyEvent.KEYCODE_DPAD_RIGHT:
return true;
default:
return super.onKeyDown(keyCode, event);
}
}
}

View File

@@ -183,7 +183,7 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
mEmptyView = view.findViewById(android.R.id.empty);
mRecView = (RecyclerView) view.findViewById(R.id.list);
mRecView = (RecyclerView) view.findViewById(R.id.dir_list);
mRecView.setRecyclerListener(
new RecyclerListener() {
@Override
@@ -263,6 +263,7 @@ 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, mSelectionManager);
mModel = new Model();
@@ -834,6 +835,7 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
@Override
public void initDocumentHolder(DocumentHolder holder) {
holder.addEventListener(mItemEventListener);
holder.itemView.setOnFocusChangeListener(mFocusManager);
}
@Override
@@ -1054,6 +1056,13 @@ public class DirectoryFragment extends Fragment implements DocumentsAdapter.Envi
}
}
/**
* Attempts to restore focus on the directory listing.
*/
public void requestFocus() {
mFocusManager.restoreLastFocus();
}
private void setupDragAndDropOnDirectoryView(View view) {
// Listen for drops on non-directory items and empty space.
view.setOnDragListener(mOnDragListener);

View File

@@ -27,7 +27,7 @@ import com.android.documentsui.Events;
/**
* A class that handles navigation and focus within the DirectoryFragment.
*/
class FocusManager {
class FocusManager implements View.OnFocusChangeListener {
private static final String TAG = "FocusManager";
private RecyclerView mView;
@@ -35,6 +35,8 @@ class FocusManager {
private LinearLayoutManager mLayout;
private MultiSelectManager mSelectionManager;
private int mLastFocusPosition = RecyclerView.NO_POSITION;
public FocusManager(RecyclerView view, MultiSelectManager selectionManager) {
mView = view;
mAdapter = view.getAdapter();
@@ -52,24 +54,46 @@ class FocusManager {
* @return Whether the event was handled.
*/
public boolean handleKey(DocumentHolder doc, int keyCode, KeyEvent event) {
boolean handled = false;
if (Events.isNavigationKeyCode(keyCode)) {
// Find the target item and focus it.
int endPos = findTargetPosition(doc.itemView, keyCode, event);
if (endPos != RecyclerView.NO_POSITION) {
focusItem(endPos);
boolean extendSelection = event.isShiftPressed();
// Handle any necessary adjustments to selection.
boolean extendSelection = event.isShiftPressed();
if (extendSelection) {
int startPos = doc.getAdapterPosition();
mSelectionManager.selectRange(startPos, endPos);
}
handled = true;
}
// Swallow all navigation keystrokes. Otherwise they go to the app's global
// key-handler, which will route them back to the DF and cause focus to be reset.
return true;
}
return false;
}
@Override
public void onFocusChange(View v, boolean hasFocus) {
// Remember focus events on items.
if (hasFocus && v.getParent() == mView) {
mLastFocusPosition = mView.getChildAdapterPosition(v);
}
}
/**
* Requests focus on the item that last had focus. Scrolls to that item if necessary.
*/
public void restoreLastFocus() {
if (mLastFocusPosition != RecyclerView.NO_POSITION) {
// The system takes care of situations when a view is no longer on screen, etc,
focusItem(mLastFocusPosition);
} else {
// Focus the first visible item
focusItem(mLayout.findFirstVisibleItemPosition());
}
return handled;
}
/**

View File

@@ -21,6 +21,7 @@ import static com.android.documentsui.StubProvider.ROOT_1_ID;
import android.os.RemoteException;
import android.test.suitebuilder.annotation.LargeTest;
import android.view.KeyEvent;
@LargeTest
public class FilesActivityUiTest extends ActivityTest<FilesActivity> {
@@ -115,4 +116,37 @@ public class FilesActivityUiTest extends ActivityTest<FilesActivity> {
bot.waitForDeleteSnackbarGone();
assertFalse(bot.hasDocuments("poodles.text"));
}
// Tests that pressing tab switches focus between the roots and directory listings.
public void testKeyboard_tab() throws Exception {
bot.pressKey(KeyEvent.KEYCODE_TAB);
bot.assertHasFocus("com.android.documentsui:id/roots_list");
bot.pressKey(KeyEvent.KEYCODE_TAB);
bot.assertHasFocus("com.android.documentsui:id/dir_list");
}
// Tests that arrow keys do not switch focus away from the dir list.
public void testKeyboard_arrowsDirList() throws Exception {
for (int i = 0; i < 10; i++) {
bot.pressKey(KeyEvent.KEYCODE_DPAD_LEFT);
bot.assertHasFocus("com.android.documentsui:id/dir_list");
}
for (int i = 0; i < 10; i++) {
bot.pressKey(KeyEvent.KEYCODE_DPAD_RIGHT);
bot.assertHasFocus("com.android.documentsui:id/dir_list");
}
}
// Tests that arrow keys do not switch focus away from the roots list.
public void testKeyboard_arrowsRootsList() throws Exception {
bot.pressKey(KeyEvent.KEYCODE_TAB);
for (int i = 0; i < 10; i++) {
bot.pressKey(KeyEvent.KEYCODE_DPAD_RIGHT);
bot.assertHasFocus("com.android.documentsui:id/roots_list");
}
for (int i = 0; i < 10; i++) {
bot.pressKey(KeyEvent.KEYCODE_DPAD_LEFT);
bot.assertHasFocus("com.android.documentsui:id/roots_list");
}
}
}

View File

@@ -71,7 +71,7 @@ class UiBot {
UiObject findRoot(String label) throws UiObjectNotFoundException {
final UiSelector rootsList = new UiSelector().resourceId(
"com.android.documentsui:id/container_roots").childSelector(
new UiSelector().resourceId("android:id/list"));
new UiSelector().resourceId("com.android.documentsui:id/roots_list"));
// We might need to expand drawer if not visible
if (!new UiObject(rootsList).waitForExists(mTimeout)) {
@@ -195,6 +195,15 @@ class UiBot {
assertNotNull(getSnackbar(mContext.getString(id)));
}
/**
* Asserts that the specified view or one of its descendents has focus.
*/
void assertHasFocus(String resourceName) {
UiObject2 candidate = mDevice.findObject(By.res(resourceName));
assertNotNull("Expected " + resourceName + " to have focus, but it didn't.",
candidate.findObject(By.focused(true)));
}
void openDocument(String label) throws UiObjectNotFoundException {
int toolType = Configurator.getInstance().getToolType();
Configurator.getInstance().setToolType(MotionEvent.TOOL_TYPE_FINGER);
@@ -309,7 +318,7 @@ class UiBot {
UiObject findDocument(String label) throws UiObjectNotFoundException {
final UiSelector docList = new UiSelector().resourceId(
"com.android.documentsui:id/container_directory").childSelector(
new UiSelector().resourceId("com.android.documentsui:id/list"));
new UiSelector().resourceId("com.android.documentsui:id/dir_list"));
// Wait for the first list item to appear
new UiObject(docList.childSelector(new UiSelector())).waitForExists(mTimeout);
@@ -330,7 +339,7 @@ class UiBot {
UiObject findDocumentsList() {
return findObject(
"com.android.documentsui:id/container_directory",
"com.android.documentsui:id/list");
"com.android.documentsui:id/dir_list");
}
UiObject findSearchView() {
@@ -416,4 +425,8 @@ class UiBot {
mDevice.wait(Until.hasObject(By.pkg(TARGET_PKG).depth(0)), mTimeout);
mDevice.waitForIdle();
}
void pressKey(int keyCode) {
mDevice.pressKeyCode(keyCode);
}
}