Adds support for page up, page down, move home, and move end key events.
Bug: 205800548
Test: Added additional unit tests.
Change-Id: I72cbba7bf0a0a3763755e3c23f4121ec9577e6ab
diff --git a/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java b/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
index 05a23875..70782d1 100644
--- a/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
+++ b/coordinatorlayout/coordinatorlayout/src/main/java/androidx/coordinatorlayout/widget/CoordinatorLayout.java
@@ -115,6 +115,8 @@
NestedScrollingParent3 {
static final String TAG = "CoordinatorLayout";
static final String WIDGET_PACKAGE_NAME;
+ // For the UP/DOWN keys, we scroll 1/10th of the screen.
+ private static final float KEY_SCROLL_FRACTION_AMOUNT = 0.1f;
static {
final Package pkg = CoordinatorLayout.class.getPackage();
@@ -181,6 +183,13 @@
// This only exist to prevent GC and object instantiation costs that are present before API 21.
private final int[] mNestedScrollingV2ConsumedCompat = new int[2];
+ // Array to be mutated by calls to nested scrolling related methods triggered by key events.
+ // Because these scrolling events rely on lower level methods using mBehaviorConsumed, we need
+ // a separate variable to save memory. As with the above, this only exist to prevent GC and
+ // object instantiation costs that are
+ // present before API 21.
+ private final int[] mKeyTriggeredScrollConsumed = new int[2];
+
private boolean mDisallowInterceptReset;
private boolean mIsAttachedToWindow;
@@ -1945,47 +1954,46 @@
if (event.getAction() == KeyEvent.ACTION_DOWN) {
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_UP:
- case KeyEvent.KEYCODE_DPAD_DOWN:
- case KeyEvent.KEYCODE_SPACE:
-
- int yScrollDelta;
-
- if (event.getKeyCode() == KeyEvent.KEYCODE_SPACE) {
- if (event.isShiftPressed()) {
- // Places the CoordinatorLayout at the top of the available
- // content.
- // Note: The delta may represent a value that would overshoot the
- // top of the screen, but the children only use as much of the
- // delta as they can support, so it will always go exactly to the
- // top.
- yScrollDelta = -getFullContentHeight();
- } else {
- // Places the CoordinatorLayout at the bottom of the available
- // content.
- yScrollDelta = getFullContentHeight() - getHeight();
- }
-
- } else if (event.isAltPressed()) { // For UP and DOWN KeyEvents
- // Full page scroll
- yScrollDelta = getHeight();
-
+ if (event.isAltPressed()) {
+ // Inverse to move up the screen
+ handled = moveVertically(-pageDelta());
} else {
- // Regular arrow scroll
- yScrollDelta = (int) (getHeight() * 0.1f);
+ // Inverse to move up the screen
+ handled = moveVertically(-lineDelta());
}
+ break;
- View focusedView = findDeepestFocusedChild(this);
-
- // Convert delta to negative if the key event is UP.
- if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_UP) {
- yScrollDelta = -yScrollDelta;
+ case KeyEvent.KEYCODE_DPAD_DOWN:
+ if (event.isAltPressed()) {
+ handled = moveVertically(pageDelta());
+ } else {
+ handled = moveVertically(lineDelta());
}
+ break;
- handled = manuallyTriggersNestedScrollFromKeyEvent(
- focusedView,
- yScrollDelta
- );
+ case KeyEvent.KEYCODE_PAGE_UP:
+ // Inverse to move up the screen
+ handled = moveVertically(-pageDelta());
+ break;
+ case KeyEvent.KEYCODE_PAGE_DOWN:
+ handled = moveVertically(pageDelta());
+ break;
+
+ case KeyEvent.KEYCODE_SPACE:
+ if (event.isShiftPressed()) {
+ handled = moveVertically(distanceToTop());
+ } else {
+ handled = moveVertically(distanceToBottom());
+ }
+ break;
+
+ case KeyEvent.KEYCODE_MOVE_HOME:
+ handled = moveVertically(distanceToTop());
+ break;
+
+ case KeyEvent.KEYCODE_MOVE_END:
+ handled = moveVertically(distanceToBottom());
break;
}
}
@@ -1994,6 +2002,36 @@
return handled;
}
+ // Distance for moving one arrow key tap.
+ private int lineDelta() {
+ return (int) (getHeight() * KEY_SCROLL_FRACTION_AMOUNT);
+ }
+
+ private int pageDelta() {
+ return getHeight();
+ }
+
+ private int distanceToTop() {
+ // Note: The delta may represent a value that would overshoot the
+ // top of the screen, but the children only use as much of the
+ // delta as they can support, so it will always go exactly to the
+ // top.
+ return -getFullContentHeight();
+ }
+
+ private int distanceToBottom() {
+ return getFullContentHeight() - getHeight();
+ }
+
+ private boolean moveVertically(int yScrollDelta) {
+ View focusedView = findDeepestFocusedChild(this);
+
+ return manuallyTriggersNestedScrollFromKeyEvent(
+ focusedView,
+ yScrollDelta
+ );
+ }
+
private View findDeepestFocusedChild(View startingParentView) {
View focusedView = startingParentView;
while (focusedView != null) {
@@ -2050,6 +2088,10 @@
ViewCompat.TYPE_NON_TOUCH
);
+ // Reset consumed values to zero.
+ mKeyTriggeredScrollConsumed[0] = 0;
+ mKeyTriggeredScrollConsumed[1] = 0;
+
onNestedScroll(
focusedView,
0,
@@ -2057,12 +2099,12 @@
0,
yScrollDelta,
ViewCompat.TYPE_NON_TOUCH,
- mBehaviorConsumed
+ mKeyTriggeredScrollConsumed
);
onStopNestedScroll(focusedView, ViewCompat.TYPE_NON_TOUCH);
- if (mBehaviorConsumed[1] > 0) {
+ if (mKeyTriggeredScrollConsumed[1] > 0) {
handled = true;
}
diff --git a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewWithCollapsingToolbarTest.java b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewWithCollapsingToolbarTest.java
index 74a1306..2885cc3 100644
--- a/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewWithCollapsingToolbarTest.java
+++ b/core/core/src/androidTest/java/androidx/core/widget/NestedScrollViewWithCollapsingToolbarTest.java
@@ -28,6 +28,7 @@
import android.widget.LinearLayout;
import android.widget.TextView;
+import androidx.annotation.NonNull;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.MediumTest;
@@ -277,7 +278,229 @@
// Assert
// Should trigger a scroll event in parent. Note: OnStartNestedScroll is triggered on
// key action down only, not key action up, so that is why the count is one.
- // Should trigger in parent of scroll event.
+ assertEquals(1, mParentNestedScrollView.getOnStartNestedScrollCount());
+ // Should not trigger in child (because child doesn't have its own inner NestedScrollView).
+ assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
+ }
+
+ @Test
+ public void isOnStartNestedScrollCalled_keyboardPageDownInChild_calledInParent() {
+ // Arrange
+ setupNestedScrollViewInNestedScrollView(
+ ApplicationProvider.getApplicationContext(),
+ 100,
+ 600);
+
+ // Act
+ mChildNestedScrollView.requestFocus();
+ KeyEvent keyEventPressDown = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_PAGE_DOWN,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressDown);
+
+ KeyEvent keyEventPressUp = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_UP,
+ KeyEvent.KEYCODE_PAGE_DOWN,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
+
+ // Assert
+ // Should trigger a scroll event in parent. Note: OnStartNestedScroll is triggered on
+ // key action down only, not key action up, so that is why the count is one.
+ assertEquals(1, mParentNestedScrollView.getOnStartNestedScrollCount());
+ // Should not trigger in child (because child doesn't have its own inner NestedScrollView).
+ assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
+ }
+
+ @Test
+ public void isOnStartNestedScrollCalled_keyboardPageUpInChild_calledInParent() {
+ // Arrange
+ setupNestedScrollViewInNestedScrollView(
+ ApplicationProvider.getApplicationContext(),
+ 100,
+ 600);
+
+ // Move to bottom of the child NestedScrollView, so we can scroll up and not go past child.
+ int scrollRange = mChildNestedScrollView.getScrollRange();
+ mChildNestedScrollView.scrollTo(0, scrollRange);
+
+ // Act
+ mChildNestedScrollView.requestFocus();
+ KeyEvent keyEventPressDown = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_PAGE_UP,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressDown);
+
+ KeyEvent keyEventPressUp = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_UP,
+ KeyEvent.KEYCODE_PAGE_UP,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
+
+ // Assert
+ // Should trigger a scroll event in parent. Note: OnStartNestedScroll is triggered on
+ // key action down only, not key action up, so that is why the count is one.
+ assertEquals(1, mParentNestedScrollView.getOnStartNestedScrollCount());
+ // Should not trigger in child (because child doesn't have its own inner NestedScrollView).
+ assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
+ }
+
+ @Test
+ public void isOnStartNestedScrollCalled_keyboardMoveEndInChild_calledInParent() {
+ // Arrange
+ setupNestedScrollViewInNestedScrollView(
+ ApplicationProvider.getApplicationContext(),
+ 100,
+ 600);
+
+ // Act
+ mChildNestedScrollView.requestFocus();
+ KeyEvent keyEventPressDown = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_MOVE_END,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressDown);
+
+ KeyEvent keyEventPressUp = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_UP,
+ KeyEvent.KEYCODE_MOVE_END,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
+
+ // Assert
+ // Should trigger a scroll event in parent. Note: OnStartNestedScroll is triggered on
+ // key action down only, not key action up, so that is why the count is one.
+ assertEquals(1, mParentNestedScrollView.getOnStartNestedScrollCount());
+ // Should not trigger in child (because child doesn't have its own inner NestedScrollView).
+ assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
+ }
+
+
+ @Test
+ public void isOnStartNestedScrollCalled_keyboardMoveHomeInChild_calledInParent() {
+ // Arrange
+ setupNestedScrollViewInNestedScrollView(
+ ApplicationProvider.getApplicationContext(),
+ 100,
+ 600);
+
+ // Move to bottom of the child NestedScrollView, so we can scroll up and not go past child.
+ int scrollRange = mChildNestedScrollView.getScrollRange();
+ mChildNestedScrollView.scrollTo(0, scrollRange);
+
+ // Act
+ mChildNestedScrollView.requestFocus();
+ KeyEvent keyEventPressDown = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_MOVE_HOME,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressDown);
+
+ KeyEvent keyEventPressUp = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_UP,
+ KeyEvent.KEYCODE_MOVE_HOME,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
+
+ // Assert
+ // Should trigger a scroll event in parent. Note: OnStartNestedScroll is triggered on
+ // key action down only, not key action up, so that is why the count is one.
+ assertEquals(1, mParentNestedScrollView.getOnStartNestedScrollCount());
+ // Should not trigger in child (because child doesn't have its own inner NestedScrollView).
+ assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
+ }
+
+
+ @Test
+ public void isOnStartNestedScrollCalled_keyboardSpaceBarInChild_calledInParent() {
+ // Arrange
+ setupNestedScrollViewInNestedScrollView(
+ ApplicationProvider.getApplicationContext(),
+ 100,
+ 600);
+
+ // Act
+ mChildNestedScrollView.requestFocus();
+ KeyEvent keyEventPressDown = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_SPACE,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressDown);
+
+ KeyEvent keyEventPressUp = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_UP,
+ KeyEvent.KEYCODE_SPACE,
+ 0);
+ mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
+
+ // Assert
+ // Should trigger a scroll event in parent. Note: OnStartNestedScroll is triggered on
+ // key action down only, not key action up, so that is why the count is one.
+ assertEquals(1, mParentNestedScrollView.getOnStartNestedScrollCount());
+ // Should not trigger in child (because child doesn't have its own inner NestedScrollView).
+ assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
+ }
+
+
+ @Test
+ public void isOnStartNestedScrollCalled_keyboardShiftSpaceBarInChild_calledInParent() {
+ // Arrange
+ setupNestedScrollViewInNestedScrollView(
+ ApplicationProvider.getApplicationContext(),
+ 100,
+ 600);
+
+ // Move to bottom of the child NestedScrollView, so we can scroll up and not go past child.
+ int scrollRange = mChildNestedScrollView.getScrollRange();
+ mChildNestedScrollView.scrollTo(0, scrollRange);
+
+ // Act
+ mChildNestedScrollView.requestFocus();
+ KeyEvent keyEventPressDown = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_SPACE,
+ 0,
+ KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON
+ );
+ mChildNestedScrollView.executeKeyEvent(keyEventPressDown);
+
+ KeyEvent keyEventPressUp = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_UP,
+ KeyEvent.KEYCODE_SPACE,
+ 0,
+ KeyEvent.META_SHIFT_ON | KeyEvent.META_SHIFT_LEFT_ON
+ );
+ mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
+
+ // Assert
+ // Should trigger a scroll event in parent. Note: OnStartNestedScroll is triggered on
+ // key action down only, not key action up, so that is why the count is one.
assertEquals(1, mParentNestedScrollView.getOnStartNestedScrollCount());
// Should not trigger in child (because child doesn't have its own inner NestedScrollView).
assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
@@ -321,8 +544,6 @@
mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
// Assert
- // Should trigger in parent of scroll event. Note: OnStartNestedScroll is triggered on
- // key action down only, not key action up, so that is why the count is one.
assertEquals(0, mParentNestedScrollView.getOnStartNestedScrollCount());
// Should not trigger in child (because child doesn't have its own inner NestedScrollView).
assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
@@ -358,9 +579,6 @@
mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
// Assert
- // Should trigger in parent of scroll event. Note: OnStartNestedScroll is triggered on
- // key action down only, not key action up, so that is why the count is one.
- // Should trigger in parent of scroll event.
assertEquals(0, mParentNestedScrollView.getOnStartNestedScrollCount());
// Should not trigger in child (because child doesn't have its own inner NestedScrollView).
assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
@@ -397,9 +615,6 @@
mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
// Assert
- // Should trigger in parent of scroll event. Note: OnStartNestedScroll is triggered on
- // key action down only, not key action up, so that is why the count is one.
- // Should trigger in parent of scroll event.
assertEquals(0, mParentNestedScrollView.getOnStartNestedScrollCount());
// Should not trigger in child (because child doesn't have its own inner NestedScrollView).
assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
@@ -438,9 +653,77 @@
mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
// Assert
- // Should trigger in parent of scroll event. Note: OnStartNestedScroll is triggered on
- // key action down only, not key action up, so that is why the count is one.
- // Should trigger in parent of scroll event.
+ assertEquals(0, mParentNestedScrollView.getOnStartNestedScrollCount());
+ // Should not trigger in child (because child doesn't have its own inner NestedScrollView).
+ assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
+ }
+
+
+ @Test
+ public void isOnStartNestedScrollCalled_keyboardPageUpInChildPastTop_notCalledInParent() {
+ // Arrange
+ setupNestedScrollViewInNestedScrollView(
+ ApplicationProvider.getApplicationContext(),
+ 100,
+ 600);
+
+ // Act
+ mChildNestedScrollView.requestFocus();
+ KeyEvent keyEventPressPageDown = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_PAGE_UP,
+ 0
+ );
+ mChildNestedScrollView.executeKeyEvent(keyEventPressPageDown);
+
+ KeyEvent keyEventPressPageUp = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_UP,
+ KeyEvent.KEYCODE_PAGE_UP,
+ 0
+ );
+ mChildNestedScrollView.executeKeyEvent(keyEventPressPageUp);
+
+ // Assert
+ assertEquals(0, mParentNestedScrollView.getOnStartNestedScrollCount());
+ // Should not trigger in child (because child doesn't have its own inner NestedScrollView).
+ assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
+ }
+
+ @Test
+ public void isOnStartNestedScrollCalled_keyboardPageDownInChildPastBottom_notCalledInParent() {
+ // Arrange
+ setupNestedScrollViewInNestedScrollView(
+ ApplicationProvider.getApplicationContext(),
+ 100,
+ 600);
+ // Move to bottom of the child NestedScrollView, so we can try scrolling past it.
+ int scrollRange = mChildNestedScrollView.getScrollRange();
+ mChildNestedScrollView.scrollTo(0, scrollRange);
+
+ mChildNestedScrollView.requestFocus();
+ KeyEvent keyEventPressDown = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_DOWN,
+ KeyEvent.KEYCODE_PAGE_DOWN,
+ 0
+ );
+ mChildNestedScrollView.executeKeyEvent(keyEventPressDown);
+
+ KeyEvent keyEventPressUp = new KeyEvent(
+ 0,
+ 0,
+ KeyEvent.ACTION_UP,
+ KeyEvent.KEYCODE_PAGE_DOWN,
+ 0
+ );
+ mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
+
+ // Assert
assertEquals(0, mParentNestedScrollView.getOnStartNestedScrollCount());
// Should not trigger in child (because child doesn't have its own inner NestedScrollView).
assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
@@ -475,9 +758,6 @@
mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
// Assert
- // Should trigger in parent of scroll event. Note: OnStartNestedScroll is triggered on
- // key action down only, not key action up, so that is why the count is one.
- // Should trigger in parent of scroll event.
assertEquals(0, mParentNestedScrollView.getOnStartNestedScrollCount());
// Should not trigger in child (because child doesn't have its own inner NestedScrollView).
assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
@@ -513,9 +793,6 @@
mChildNestedScrollView.executeKeyEvent(keyEventPressUp);
// Assert
- // Should trigger in parent of scroll event. Note: OnStartNestedScroll is triggered on
- // key action down only, not key action up, so that is why the count is one.
- // Should trigger in parent of scroll event.
assertEquals(0, mParentNestedScrollView.getOnStartNestedScrollCount());
// Should not trigger in child (because child doesn't have its own inner NestedScrollView).
assertEquals(0, mChildNestedScrollView.getOnStartNestedScrollCount());
@@ -701,7 +978,12 @@
}
@Override
- public boolean onStartNestedScroll(View child, View target, int axes, int type) {
+ public boolean onStartNestedScroll(
+ @NonNull View child,
+ @NonNull View target,
+ int axes,
+ int type
+ ) {
mOnStartNestedScrollCount++;
return super.onStartNestedScroll(child, target, axes, type);
}
diff --git a/core/core/src/main/java/androidx/core/widget/NestedScrollView.java b/core/core/src/main/java/androidx/core/widget/NestedScrollView.java
index dd19e2d..2b0cdce 100644
--- a/core/core/src/main/java/androidx/core/widget/NestedScrollView.java
+++ b/core/core/src/main/java/androidx/core/widget/NestedScrollView.java
@@ -704,22 +704,34 @@
if (event.getAction() == KeyEvent.ACTION_DOWN) {
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_UP:
- if (!event.isAltPressed()) {
- handled = arrowScroll(View.FOCUS_UP);
- } else {
+ if (event.isAltPressed()) {
handled = fullScroll(View.FOCUS_UP);
+ } else {
+ handled = arrowScroll(View.FOCUS_UP);
}
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
- if (!event.isAltPressed()) {
- handled = arrowScroll(View.FOCUS_DOWN);
- } else {
+ if (event.isAltPressed()) {
handled = fullScroll(View.FOCUS_DOWN);
+ } else {
+ handled = arrowScroll(View.FOCUS_DOWN);
}
break;
+ case KeyEvent.KEYCODE_PAGE_UP:
+ handled = fullScroll(View.FOCUS_UP);
+ break;
+ case KeyEvent.KEYCODE_PAGE_DOWN:
+ handled = fullScroll(View.FOCUS_DOWN);
+ break;
case KeyEvent.KEYCODE_SPACE:
pageScroll(event.isShiftPressed() ? View.FOCUS_UP : View.FOCUS_DOWN);
break;
+ case KeyEvent.KEYCODE_MOVE_HOME:
+ pageScroll(View.FOCUS_UP);
+ break;
+ case KeyEvent.KEYCODE_MOVE_END:
+ pageScroll(View.FOCUS_DOWN);
+ break;
}
}