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:
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user