diff options
12 files changed, 223 insertions, 16 deletions
diff --git a/packages/DocumentsUI/res/layout/fixed_layout.xml b/packages/DocumentsUI/res/layout/fixed_layout.xml index 8414febfb7ca..84a928dc3d9b 100644 --- a/packages/DocumentsUI/res/layout/fixed_layout.xml +++ b/packages/DocumentsUI/res/layout/fixed_layout.xml @@ -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" diff --git a/packages/DocumentsUI/res/layout/fragment_directory.xml b/packages/DocumentsUI/res/layout/fragment_directory.xml index d0364ff44294..0fb74e5eca34 100644 --- a/packages/DocumentsUI/res/layout/fragment_directory.xml +++ b/packages/DocumentsUI/res/layout/fragment_directory.xml @@ -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" diff --git a/packages/DocumentsUI/res/layout/fragment_roots.xml b/packages/DocumentsUI/res/layout/fragment_roots.xml index f3de3b43be50..b33b8d09b992 100644 --- a/packages/DocumentsUI/res/layout/fragment_roots.xml +++ b/packages/DocumentsUI/res/layout/fragment_roots.xml @@ -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" diff --git a/packages/DocumentsUI/res/layout/single_pane_layout.xml b/packages/DocumentsUI/res/layout/single_pane_layout.xml index f53d69808929..235d22d0737b 100644 --- a/packages/DocumentsUI/res/layout/single_pane_layout.xml +++ b/packages/DocumentsUI/res/layout/single_pane_layout.xml @@ -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" diff --git a/packages/DocumentsUI/src/com/android/documentsui/BaseActivity.java b/packages/DocumentsUI/src/com/android/documentsui/BaseActivity.java index 475387bb224b..4a55906e56c1 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/BaseActivity.java +++ b/packages/DocumentsUI/src/com/android/documentsui/BaseActivity.java @@ -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( diff --git a/packages/DocumentsUI/src/com/android/documentsui/RecentsCreateFragment.java b/packages/DocumentsUI/src/com/android/documentsui/RecentsCreateFragment.java index 7dac0c10e077..0e2762291b09 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/RecentsCreateFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/RecentsCreateFragment.java @@ -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); diff --git a/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java b/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java index 26bda31261ef..53f82972738c 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/RootsFragment.java @@ -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)); diff --git a/packages/DocumentsUI/src/com/android/documentsui/RootsList.java b/packages/DocumentsUI/src/com/android/documentsui/RootsList.java new file mode 100644 index 000000000000..bf03ffd1e6d6 --- /dev/null +++ b/packages/DocumentsUI/src/com/android/documentsui/RootsList.java @@ -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); + } + } +} diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java index 174984cec8ed..726538e34402 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/DirectoryFragment.java @@ -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); diff --git a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java index 86b9146ba48c..ad010a6891fe 100644 --- a/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java +++ b/packages/DocumentsUI/src/com/android/documentsui/dirlist/FocusManager.java @@ -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; } /** diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/FilesActivityUiTest.java b/packages/DocumentsUI/tests/src/com/android/documentsui/FilesActivityUiTest.java index 77f16d9ca9e6..609dc0c67e50 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/FilesActivityUiTest.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/FilesActivityUiTest.java @@ -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"); + } + } } diff --git a/packages/DocumentsUI/tests/src/com/android/documentsui/UiBot.java b/packages/DocumentsUI/tests/src/com/android/documentsui/UiBot.java index 4534c40c6e0e..d2f84034593c 100644 --- a/packages/DocumentsUI/tests/src/com/android/documentsui/UiBot.java +++ b/packages/DocumentsUI/tests/src/com/android/documentsui/UiBot.java @@ -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); + } } |