RecyclerView nested scrolling 3 implementaiton.
Also moved and refactored a simple MotionEvent
generating utility so that both NestedScrollView
and RecyclerView tests can make use of it, and
refactored NestedScrollView tests as necessary.
Bug: 69326291
Bug: 65129787
Bug: 67345200
Test: androidx.recyclerview.widget.
RecyclerViewScrollingTest
RecyclerViewNestedScrollingChildTest
Change-Id: I2137ccf29bc938d8c2e00c28cff1e424ce1760ba
diff --git a/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingChildTest.java b/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingChildTest.java
index 22ba721..ca5422b 100644
--- a/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingChildTest.java
+++ b/core/src/androidTest/java/androidx/core/widget/NestedScrollViewNestedScrollingChildTest.java
@@ -29,6 +29,7 @@
import static org.mockito.Mockito.when;
import android.content.Context;
+import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
@@ -42,6 +43,10 @@
import androidx.test.InstrumentationRegistry;
import androidx.test.filters.SmallTest;
import androidx.test.runner.AndroidJUnit4;
+import androidx.testutils.Direction;
+import androidx.testutils.FlingData;
+import androidx.testutils.MotionEventData;
+import androidx.testutils.SimpleGestureGeneratorKt;
import org.junit.Before;
import org.junit.Test;
@@ -51,6 +56,8 @@
import org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
+import java.util.List;
+
/**
* Small integration tests that verifies that {@link NestedScrollView} interacts with the latest
* version of the nested scroll parents correctly.
@@ -212,8 +219,9 @@
public void uiFling_parentHasNestedScrollingChildWithTypeFling() {
when(mParentSpy.onStartNestedScroll(any(View.class), any(View.class), anyInt(), anyInt()))
.thenReturn(true);
- NestedScrollViewTestUtils
- .simulateFlingDown(InstrumentationRegistry.getContext(), mNestedScrollView);
+ long startTime = SystemClock.uptimeMillis();
+ SimpleGestureGeneratorKt
+ .simulateFling(mNestedScrollView, startTime, 500, 500, Direction.UP);
assertThat(mParentSpy.axesForTypeTouch, is(ViewCompat.SCROLL_AXIS_NONE));
assertThat(mParentSpy.axesForTypeNonTouch, is(ViewCompat.SCROLL_AXIS_VERTICAL));
@@ -223,8 +231,9 @@
public void uiFling_callsNestedFlingsCorrectly() {
when(mParentSpy.onStartNestedScroll(any(View.class), any(View.class), anyInt(), anyInt()))
.thenReturn(true);
- NestedScrollViewTestUtils
- .simulateFlingDown(InstrumentationRegistry.getContext(), mNestedScrollView);
+ long startTime = SystemClock.uptimeMillis();
+ SimpleGestureGeneratorKt
+ .simulateFling(mNestedScrollView, startTime, 500, 500, Direction.UP);
InOrder inOrder = Mockito.inOrder(mParentSpy);
inOrder.verify(mParentSpy).onNestedPreFling(
@@ -240,56 +249,72 @@
@Test
public void uiDown_duringFling_stopsNestedScrolling() {
+
+ // Arrange
+
when(mParentSpy.onStartNestedScroll(any(View.class), any(View.class), anyInt(), anyInt()))
.thenReturn(true);
+
final Context context = InstrumentationRegistry.getContext();
- final int[] targetFlingTimeAndDistance =
- NestedScrollViewTestUtils.getTargetFlingVelocityTimeAndDistance(context);
- final int targetTimePassed = targetFlingTimeAndDistance[1];
- final MotionEvent[] motionEvents =
- NestedScrollViewTestUtils.generateMotionEvents(targetFlingTimeAndDistance);
- NestedScrollViewTestUtils.dispatchMotionEventsToView(mNestedScrollView, motionEvents);
+ FlingData flingData = SimpleGestureGeneratorKt.generateFlingData(context);
+
+ final long firstDownTime = SystemClock.uptimeMillis();
+ // Should be after fling events occurred.
+ final long secondDownTime = firstDownTime + flingData.getTime() + 100;
+
+ List<MotionEventData> motionEventData = SimpleGestureGeneratorKt
+ .generateFlingMotionEventData(flingData, 500, 500, Direction.UP);
+ SimpleGestureGeneratorKt
+ .dispatchTouchEvents(mNestedScrollView, firstDownTime, motionEventData);
+
// Sanity check that onStopNestedScroll has not yet been called of type TYPE_NON_TOUCH.
verify(mParentSpy, never())
.onStopNestedScroll(mNestedScrollView, ViewCompat.TYPE_NON_TOUCH);
+ // Act
+
MotionEvent down = MotionEvent.obtain(
- 0,
- targetTimePassed + 100, // Should be after fling events occurred.
+ secondDownTime,
+ secondDownTime,
MotionEvent.ACTION_DOWN,
500,
500,
0);
mNestedScrollView.dispatchTouchEvent(down);
+ // Assert
+
verify(mParentSpy).onStopNestedScroll(mNestedScrollView, ViewCompat.TYPE_NON_TOUCH);
}
@Test
- public void uiFlings_parentReturnsTrueForOnNestedFling_dispatchNestedFlingCalled() {
- when(mParentSpy.onStartNestedScroll(any(View.class), any(View.class), anyInt(), anyInt()))
- .thenReturn(true);
- when(mParentSpy.onNestedPreFling(eq(mNestedScrollView), anyFloat(), anyFloat()))
- .thenReturn(false);
-
- NestedScrollViewTestUtils
- .simulateFlingDown(InstrumentationRegistry.getContext(), mNestedScrollView);
-
- verify(mParentSpy).onNestedFling(eq(mNestedScrollView), anyFloat(), anyFloat(), eq(true));
+ public void uiFlings_parentReturnsFalseForOnNestedPreFling_onNestedFlingCalled() {
+ uiFlings_returnValueOfOnNestedPreFlingDeterminesCallToOnNestedFling(false, true);
}
@Test
- public void uiFlings_parentReturnsFalseForOnNestedFling_dispatchNestedFlingNotCalled() {
+ public void uiFlings_parentReturnsTrueForOnNestedPreFling_onNestedFlingNotCalled() {
+ uiFlings_returnValueOfOnNestedPreFlingDeterminesCallToOnNestedFling(true, false);
+ }
+
+ private void uiFlings_returnValueOfOnNestedPreFlingDeterminesCallToOnNestedFling(
+ boolean returnValue, boolean onNestedFlingCalled) {
when(mParentSpy.onStartNestedScroll(any(View.class), any(View.class), anyInt(), anyInt()))
.thenReturn(true);
when(mParentSpy.onNestedPreFling(eq(mNestedScrollView), anyFloat(), anyFloat()))
- .thenReturn(true);
+ .thenReturn(returnValue);
- NestedScrollViewTestUtils
- .simulateFlingDown(InstrumentationRegistry.getContext(), mNestedScrollView);
+ long startTime = SystemClock.uptimeMillis();
+ SimpleGestureGeneratorKt
+ .simulateFling(mNestedScrollView, startTime, 500, 500, Direction.UP);
- verify(mParentSpy, never())
- .onNestedFling(any(View.class), anyFloat(), anyFloat(), anyBoolean());
+ if (onNestedFlingCalled) {
+ verify(mParentSpy).onNestedFling(eq(mNestedScrollView), anyFloat(), anyFloat(),
+ eq(true));
+ } else {
+ verify(mParentSpy, never()).onNestedFling(any(View.class), anyFloat(), anyFloat(),
+ anyBoolean());
+ }
}
@Test
diff --git a/core/src/androidTest/java/androidx/core/widget/NestedScrollViewScrollingTest.java b/core/src/androidTest/java/androidx/core/widget/NestedScrollViewScrollingTest.java
index d28612e..dc8d752 100644
--- a/core/src/androidTest/java/androidx/core/widget/NestedScrollViewScrollingTest.java
+++ b/core/src/androidTest/java/androidx/core/widget/NestedScrollViewScrollingTest.java
@@ -31,6 +31,7 @@
import android.content.Context;
import android.graphics.drawable.GradientDrawable;
+import android.os.SystemClock;
import android.support.v4.BaseInstrumentationTestCase;
import android.view.View;
import android.view.ViewGroup;
@@ -45,6 +46,8 @@
import androidx.test.InstrumentationRegistry;
import androidx.test.filters.LargeTest;
import androidx.test.runner.AndroidJUnit4;
+import androidx.testutils.Direction;
+import androidx.testutils.SimpleGestureGeneratorKt;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -53,8 +56,8 @@
import java.util.concurrent.TimeUnit;
/**
-* Large integration tests that verify that a {@link NestedScrollView} interacts with it's child
-* correctly.
+ * Large integration tests that verify correct {@link NestedScrollView} scrolling behavior,
+ * including interaction with nested scrolling.
*/
@RunWith(AndroidJUnit4.class)
@LargeTest
@@ -65,6 +68,7 @@
private static final int NSV_HEIGHT = 400;
private static final int PARENT_HEIGHT = 400;
private static final int WIDTH = 400;
+ private static final int ORIGIN_X_Y = 200;
private static final int TOTAL_SCROLL_DISTANCE = CHILD_HEIGHT - NSV_HEIGHT;
private static final int PARTIAL_SCROLL_DISTANCE = TOTAL_SCROLL_DISTANCE / 10;
@@ -76,8 +80,6 @@
super(TestContentViewActivity.class);
}
- // Large
-
@Test
public void onNestedFling_consumedIsFalse_animatesScroll() throws Throwable {
onNestedFling_consumeParamDeterminesScroll(false, true);
@@ -94,9 +96,8 @@
attachToActivity();
final Context context = InstrumentationRegistry.getContext();
- final int targetVelocity =
- NestedScrollViewTestUtils.getTargetFlingVelocityTimeAndDistance(context)[0];
-
+ final int targetVelocity = (int)
+ Math.ceil(SimpleGestureGeneratorKt.generateFlingData(context).getVelocity() * 1000);
final CountDownLatch countDownLatch = new CountDownLatch(1);
mActivityTestRule.runOnUiThread(new Runnable() {
@Override
@@ -119,22 +120,20 @@
}
@Test
- public void uiFlings_dispatchNestedPreFlingReturnsFalse_scrolls() throws Throwable {
- uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsScroll(false, true);
+ public void uiFlings_dispatchNestedPreFlingReturnsFalse_flings() throws Throwable {
+ uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsFling(false, true);
}
@Test
- public void uiFlings_dispatchNestedPreFlingReturnsTrue_doesNotScroll() throws Throwable {
- uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsScroll(true, false);
+ public void uiFlings_dispatchNestedPreFlingReturnsTrue_doesNotFling() throws Throwable {
+ uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsFling(true, false);
}
- private void uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsScroll(
+ private void uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsFling(
final boolean returnValue, final boolean scrolls) throws Throwable {
setup();
attachToActivity();
- final Context context = InstrumentationRegistry.getContext();
-
final CountDownLatch countDownLatch = new CountDownLatch(1);
mActivityTestRule.runOnUiThread(new Runnable() {
@Override
@@ -149,13 +148,23 @@
@Override
public void onScrollChange(NestedScrollView v, int scrollX, int scrollY,
int oldScrollX, int oldScrollY) {
- if (scrollY > PARTIAL_SCROLL_DISTANCE) {
- countDownLatch.countDown();
+ // If we have a TYPE_NON_TOUCH nested scrolling parent, we are
+ // animated a fling.
+ if (mNestedScrollView
+ .hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
+ if (scrollY > PARTIAL_SCROLL_DISTANCE) {
+ countDownLatch.countDown();
+ }
}
}
});
- NestedScrollViewTestUtils.simulateFlingDown(context, mNestedScrollView);
+ SimpleGestureGeneratorKt.simulateFling(
+ mNestedScrollView,
+ SystemClock.uptimeMillis(),
+ ORIGIN_X_Y,
+ ORIGIN_X_Y,
+ Direction.UP);
}
});
assertThat(countDownLatch.await(1, TimeUnit.SECONDS), is(scrolls));
@@ -166,15 +175,23 @@
setup();
attachToActivity();
- final Context context = InstrumentationRegistry.getContext();
-
- final CountDownLatch countDownLatch = new CountDownLatch(1);
+ final CountDownLatch countDownLatch = new CountDownLatch(2);
mActivityTestRule.runOnUiThread(new Runnable() {
@Override
public void run() {
doReturn(true).when(mParent).onStartNestedScroll(any(View.class), any(View.class),
anyInt(), anyInt());
+ mParent.mOnStopNestedScrollListener =
+ new NestedScrollingSpyView.OnStopNestedScrollListener() {
+ @Override
+ public void onStopNestedScroll(int type) {
+ if (type == ViewCompat.TYPE_NON_TOUCH) {
+ countDownLatch.countDown();
+ }
+ }
+ };
+
mNestedScrollView.setOnScrollChangeListener(
new NestedScrollView.OnScrollChangeListener() {
@Override
@@ -186,7 +203,12 @@
}
});
- NestedScrollViewTestUtils.simulateFlingDown(context, mNestedScrollView);
+ SimpleGestureGeneratorKt.simulateFling(
+ mNestedScrollView,
+ SystemClock.uptimeMillis(),
+ ORIGIN_X_Y,
+ ORIGIN_X_Y,
+ Direction.UP);
}
});
assertThat(countDownLatch.await(2, TimeUnit.SECONDS), is(true));
@@ -224,18 +246,27 @@
public void fling_fullyParticipatesInNestedScrolling() throws Throwable {
setup();
attachToActivity();
-
final Context context = InstrumentationRegistry.getContext();
- final int targetVelocity =
- NestedScrollViewTestUtils.getTargetFlingVelocityTimeAndDistance(context)[0];
+ final int targetVelocity = (int)
+ Math.ceil(SimpleGestureGeneratorKt.generateFlingData(context).getVelocity() * 1000);
- final CountDownLatch countDownLatch = new CountDownLatch(1);
+ final CountDownLatch countDownLatch = new CountDownLatch(2);
mActivityTestRule.runOnUiThread(new Runnable() {
@Override
public void run() {
doReturn(true).when(mParent).onStartNestedScroll(any(View.class), any(View.class),
anyInt(), anyInt());
+ mParent.mOnStopNestedScrollListener =
+ new NestedScrollingSpyView.OnStopNestedScrollListener() {
+ @Override
+ public void onStopNestedScroll(int type) {
+ if (type == ViewCompat.TYPE_NON_TOUCH) {
+ countDownLatch.countDown();
+ }
+ }
+ };
+
mNestedScrollView.setOnScrollChangeListener(
new NestedScrollView.OnScrollChangeListener() {
@Override
@@ -355,9 +386,11 @@
testContentView.awaitLayouts(2);
}
- public class NestedScrollingSpyView extends FrameLayout implements NestedScrollingChild3,
+ public static class NestedScrollingSpyView extends FrameLayout implements NestedScrollingChild3,
NestedScrollingParent3 {
+ public OnStopNestedScrollListener mOnStopNestedScrollListener;
+
public NestedScrollingSpyView(Context context) {
super(context);
}
@@ -376,7 +409,9 @@
@Override
public void onStopNestedScroll(@NonNull View target, int type) {
-
+ if (mOnStopNestedScrollListener != null) {
+ mOnStopNestedScrollListener.onStopNestedScroll(type);
+ }
}
@Override
@@ -517,5 +552,9 @@
public int getNestedScrollAxes() {
return 0;
}
+
+ interface OnStopNestedScrollListener {
+ void onStopNestedScroll(int type);
+ }
}
}
diff --git a/core/src/androidTest/java/androidx/core/widget/NestedScrollViewTestUtils.java b/core/src/androidTest/java/androidx/core/widget/NestedScrollViewTestUtils.java
deleted file mode 100644
index 01e8aac..0000000
--- a/core/src/androidTest/java/androidx/core/widget/NestedScrollViewTestUtils.java
+++ /dev/null
@@ -1,83 +0,0 @@
-/*
- * Copyright 2018 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 androidx.core.widget;
-
-import android.content.Context;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewConfiguration;
-
-public class NestedScrollViewTestUtils {
-
- public static int[] getTargetFlingVelocityTimeAndDistance(Context context) {
- ViewConfiguration configuration =
- ViewConfiguration.get(context);
- int touchSlop = configuration.getScaledTouchSlop();
- int mMinimumVelocity = configuration.getScaledMinimumFlingVelocity();
- int mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
-
- int targetVelocitySeconds = ((mMaximumVelocity - mMinimumVelocity) / 2) + mMinimumVelocity;
- int targetDistanceTraveled = touchSlop * 2;
- int targetTimePassed = (targetDistanceTraveled * 1000) / targetVelocitySeconds;
-
- return new int[]{targetVelocitySeconds, targetTimePassed, targetDistanceTraveled};
- }
-
- public static MotionEvent[] generateMotionEvents(int[] targetFlingVelocityTimeAndDistance) {
- int targetTimePassed = targetFlingVelocityTimeAndDistance[1];
- int targetDistanceTraveled = targetFlingVelocityTimeAndDistance[2];
- targetDistanceTraveled *= -1;
-
- MotionEvent down = MotionEvent.obtain(
- 0,
- 0,
- MotionEvent.ACTION_DOWN,
- 500,
- 500,
- 0);
- MotionEvent move = MotionEvent.obtain(
- 0,
- targetTimePassed,
- MotionEvent.ACTION_MOVE,
- 500,
- 500 + targetDistanceTraveled,
- 0);
- MotionEvent up = MotionEvent.obtain(
- 0,
- targetTimePassed,
- MotionEvent.ACTION_UP,
- 500,
- 500 + targetDistanceTraveled,
- 0);
-
- return new MotionEvent[]{down, move, up};
- }
-
- public static void dispatchMotionEventsToView(View view, MotionEvent[] motionEvents) {
- for (MotionEvent motionEvent : motionEvents) {
- view.dispatchTouchEvent(motionEvent);
- }
- }
-
- public static void simulateFlingDown(Context context, View view) {
- int[] targetFlingTimeAndDistance =
- NestedScrollViewTestUtils.getTargetFlingVelocityTimeAndDistance(context);
- MotionEvent[] motionEvents =
- NestedScrollViewTestUtils.generateMotionEvents(targetFlingTimeAndDistance);
- NestedScrollViewTestUtils.dispatchMotionEventsToView(view, motionEvents);
- }
-}
diff --git a/recyclerview/recyclerview/api/current.txt b/recyclerview/recyclerview/api/current.txt
index 50ef133..fb04788 100644
--- a/recyclerview/recyclerview/api/current.txt
+++ b/recyclerview/recyclerview/api/current.txt
@@ -350,7 +350,7 @@
method public int findTargetSnapPosition(androidx.recyclerview.widget.RecyclerView.LayoutManager, int, int);
}
- public class RecyclerView extends android.view.ViewGroup implements androidx.core.view.NestedScrollingChild2 androidx.core.view.ScrollingView {
+ public class RecyclerView extends android.view.ViewGroup implements androidx.core.view.NestedScrollingChild2 androidx.core.view.NestedScrollingChild3 androidx.core.view.ScrollingView {
ctor public RecyclerView(android.content.Context);
ctor public RecyclerView(android.content.Context, android.util.AttributeSet);
ctor public RecyclerView(android.content.Context, android.util.AttributeSet, int);
@@ -369,6 +369,7 @@
method public int computeVerticalScrollRange();
method public boolean dispatchNestedPreScroll(int, int, int[], int[], int);
method public boolean dispatchNestedScroll(int, int, int, int, int[], int);
+ method public final void dispatchNestedScroll(int, int, int, int, int[], int, int[]);
method public boolean drawChild(android.graphics.Canvas, android.view.View, long);
method public android.view.View findChildViewUnder(float, float);
method public android.view.View findContainingItemView(android.view.View);
diff --git a/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/RecyclerViewNestedScrollingChildTest.java b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/RecyclerViewNestedScrollingChildTest.java
new file mode 100644
index 0000000..eb5aaad
--- /dev/null
+++ b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/RecyclerViewNestedScrollingChildTest.java
@@ -0,0 +1,660 @@
+/*
+ * Copyright 2018 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 androidx.recyclerview.widget;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyFloat;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewConfiguration;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.view.NestedScrollingChild3;
+import androidx.core.view.NestedScrollingParent3;
+import androidx.core.view.ViewCompat;
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.SmallTest;
+import androidx.test.runner.AndroidJUnit4;
+import androidx.testutils.Direction;
+import androidx.testutils.FlingData;
+import androidx.testutils.MotionEventData;
+import androidx.testutils.SimpleGestureGeneratorKt;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+
+import java.util.List;
+
+/**
+ * Small integration tests that verifies that {@link RecyclerView} interacts with the latest
+ * version of the nested scroll parents correctly.
+ */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class RecyclerViewNestedScrollingChildTest {
+
+ private NestedScrollingSpyView mParentSpy;
+ private RecyclerView mRecyclerView;
+
+ private void setup(boolean vertical, int scrollDistance, boolean parentAccepts) {
+
+ Context context = InstrumentationRegistry.getContext();
+
+ // Create views
+
+ mRecyclerView = new RecyclerView(context);
+ mRecyclerView.setMinimumWidth(1000);
+ mRecyclerView.setMinimumHeight(1000);
+
+ mParentSpy = Mockito.spy(new NestedScrollingSpyView(context));
+ mParentSpy.setMinimumWidth(1000);
+ mParentSpy.setMinimumHeight(1000);
+
+ // Setup RecyclerView
+ int orientation = vertical ? RecyclerView.VERTICAL : RecyclerView.HORIZONTAL;
+ mRecyclerView.setLayoutManager(new LinearLayoutManager(context, orientation, false));
+ mRecyclerView.setAdapter(new TestAdapter(context, 1000 + scrollDistance, 1, vertical));
+
+ // Create view hierarchy
+ mParentSpy.addView(mRecyclerView);
+
+ // Measure and layout
+ int measureSpecWidth = View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY);
+ int measureSpecHeight = View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY);
+ mParentSpy.measure(measureSpecWidth, measureSpecHeight);
+ mParentSpy.layout(0, 0, 1000, 1000);
+
+ when(mParentSpy.onStartNestedScroll(any(View.class), any(View.class), anyInt(), anyInt()))
+ .thenReturn(parentAccepts);
+ }
+
+ @Test
+ public void uiFingerDown_vertical_parentHasNestedScrollingChildWithTypeTouch() {
+ setup(true, 100, true);
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+
+ mRecyclerView.dispatchTouchEvent(down);
+
+ assertThat(mParentSpy.axesForTypeTouch, is(ViewCompat.SCROLL_AXIS_VERTICAL));
+ assertThat(mParentSpy.axesForTypeNonTouch, is(ViewCompat.SCROLL_AXIS_NONE));
+ }
+
+ @Test
+ public void uiFingerDown_horizontal_parentHasNestedScrollingChildWithTypeTouch() {
+ setup(false, 100, true);
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+
+ mRecyclerView.dispatchTouchEvent(down);
+
+ assertThat(mParentSpy.axesForTypeTouch, is(ViewCompat.SCROLL_AXIS_HORIZONTAL));
+ assertThat(mParentSpy.axesForTypeNonTouch, is(ViewCompat.SCROLL_AXIS_NONE));
+ }
+
+ @Test
+ public void uiFingerDown_parentRejects_parentDoesNotHaveNestedScrollingChild() {
+ setup(true, 100, false);
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+
+ mRecyclerView.dispatchTouchEvent(down);
+
+ assertThat(mParentSpy.axesForTypeTouch, is(ViewCompat.SCROLL_AXIS_NONE));
+ assertThat(mParentSpy.axesForTypeNonTouch, is(ViewCompat.SCROLL_AXIS_NONE));
+ }
+
+ @Test
+ public void uiFingerUp_afterFingerDown_parentDoesNotHaveNestedScrollingChild() {
+ setup(true, 100, true);
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent up = MotionEvent.obtain(0, 100, MotionEvent.ACTION_UP, 500, 500, 0);
+ mRecyclerView.dispatchTouchEvent(down);
+
+ mRecyclerView.dispatchTouchEvent(up);
+
+ assertThat(mParentSpy.axesForTypeTouch, is(ViewCompat.SCROLL_AXIS_NONE));
+ assertThat(mParentSpy.axesForTypeNonTouch, is(ViewCompat.SCROLL_AXIS_NONE));
+ }
+
+ @Test
+ public void uiFingerScroll_vertical_parentOnNestedPreScrollCalledCorrectly() {
+ setup(true, 100, true);
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent move =
+ MotionEvent.obtain(0, 100, MotionEvent.ACTION_MOVE, 500, 300, 0);
+
+ mRecyclerView.dispatchTouchEvent(down);
+ mRecyclerView.dispatchTouchEvent(move);
+
+ // Can't verify 'consumed' parameter values due to mutation, so instead capturing actual
+ // values manually in the the NestedScrollingSpyView object.
+ verify(mParentSpy).onNestedPreScroll(eq(mRecyclerView), eq(0), eq(200), any(int[].class),
+ eq(ViewCompat.TYPE_TOUCH));
+ assertThat(mParentSpy.onNestedPreScrollConsumedX, is(0));
+ assertThat(mParentSpy.onNestedPreScrollConsumedY, is(0));
+ }
+
+ @Test
+ public void uiFingerScroll_horizontal_parentOnNestedPreScrollCalledCorrectly() {
+ setup(false, 100, true);
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent move =
+ MotionEvent.obtain(0, 100, MotionEvent.ACTION_MOVE, 300, 500, 0);
+
+ mRecyclerView.dispatchTouchEvent(down);
+ mRecyclerView.dispatchTouchEvent(move);
+
+ // Can't verify 'consumed' parameter values due to mutation, so instead capturing actual
+ // values manually in the the NestedScrollingSpyView object.
+ verify(mParentSpy).onNestedPreScroll(eq(mRecyclerView), eq(200), eq(0), any(int[].class),
+ eq(ViewCompat.TYPE_TOUCH));
+ assertThat(mParentSpy.onNestedPreScrollConsumedX, is(0));
+ assertThat(mParentSpy.onNestedPreScrollConsumedY, is(0));
+ }
+
+ @Test
+ public void uiFingerScroll_scrollsBeyondLimitVertical_parentOnNestedScrollCalledCorrectly() {
+ setup(true, 100, true);
+ int touchSlop =
+ ViewConfiguration.get(InstrumentationRegistry.getContext()).getScaledTouchSlop();
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent move =
+ MotionEvent.obtain(0, 100, MotionEvent.ACTION_MOVE, 500, 300 - touchSlop, 0);
+
+ mParentSpy.dispatchTouchEvent(down);
+ mParentSpy.dispatchTouchEvent(move);
+
+ verify(mParentSpy).onNestedScroll(mRecyclerView, 0, 100, 0, 100, ViewCompat.TYPE_TOUCH,
+ new int[]{0, 0});
+ }
+
+ @Test
+ public void uiFingerScroll_scrollsBeyondLimitHorizontal_parentOnNestedScrollCalledCorrectly() {
+ setup(false, 100, true);
+ int touchSlop =
+ ViewConfiguration.get(InstrumentationRegistry.getContext()).getScaledTouchSlop();
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent move =
+ MotionEvent.obtain(0, 100, MotionEvent.ACTION_MOVE, 300 - touchSlop, 500, 0);
+
+ mParentSpy.dispatchTouchEvent(down);
+ mParentSpy.dispatchTouchEvent(move);
+
+ verify(mParentSpy).onNestedScroll(mRecyclerView, 100, 0, 100, 0, ViewCompat.TYPE_TOUCH,
+ new int[]{0, 0});
+ }
+
+ @Test
+ public void uiFingerScroll_scrollsWithinLimitVertical_parentOnNestedScrollCalledCorrectly() {
+ setup(true, 100, true);
+ int touchSlop =
+ ViewConfiguration.get(InstrumentationRegistry.getContext()).getScaledTouchSlop();
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent move =
+ MotionEvent.obtain(0, 100, MotionEvent.ACTION_MOVE, 500, 450 - touchSlop, 0);
+
+ mParentSpy.dispatchTouchEvent(down);
+ mParentSpy.dispatchTouchEvent(move);
+
+ verify(mParentSpy).onNestedScroll(mRecyclerView, 0, 50, 0, 0, ViewCompat.TYPE_TOUCH,
+ new int[]{0, 0});
+ }
+
+ @Test
+ public void uiFingerScroll_scrollsWithinLimitHorizontal_parentOnNestedScrollCalledCorrectly() {
+ setup(false, 100, true);
+ int touchSlop =
+ ViewConfiguration.get(InstrumentationRegistry.getContext()).getScaledTouchSlop();
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent move =
+ MotionEvent.obtain(0, 100, MotionEvent.ACTION_MOVE, 450 - touchSlop, 500, 0);
+
+ mParentSpy.dispatchTouchEvent(down);
+ mParentSpy.dispatchTouchEvent(move);
+
+ verify(mParentSpy).onNestedScroll(mRecyclerView, 50, 0, 0, 0, ViewCompat.TYPE_TOUCH,
+ new int[]{0, 0});
+ }
+
+ @Test
+ public void uiFingerScroll_vertical_preSelfPostChainWorks() {
+ setup(true, 100, true);
+ doAnswer(new Answer() {
+ public Object answer(InvocationOnMock invocation) {
+ Object[] args = invocation.getArguments();
+ ((int[]) args[3])[1] = 50;
+ return null;
+ }
+ }).when(mParentSpy)
+ .onNestedPreScroll(any(View.class), anyInt(), anyInt(), any(int[].class), anyInt());
+ int touchSlop =
+ ViewConfiguration.get(InstrumentationRegistry.getContext()).getScaledTouchSlop();
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent move =
+ MotionEvent.obtain(0, 100, MotionEvent.ACTION_MOVE, 500, 300, 0);
+
+ mRecyclerView.dispatchTouchEvent(down);
+ mRecyclerView.dispatchTouchEvent(move);
+
+ // Can't verify 'consumed' parameter values due to mutation, so instead capturing actual
+ // values manually in the the NestedScrollingSpyView object.
+ verify(mParentSpy).onNestedPreScroll(eq(mRecyclerView), eq(0), eq(200), any(int[].class),
+ eq(ViewCompat.TYPE_TOUCH));
+ assertThat(mParentSpy.onNestedPreScrollConsumedX, is(0));
+ assertThat(mParentSpy.onNestedPreScrollConsumedY, is(0));
+
+ verify(mParentSpy).onNestedScroll(mRecyclerView, 0, 100, 0, 50 - touchSlop,
+ ViewCompat.TYPE_TOUCH, new int[]{0, 0});
+ }
+
+ @Test
+ public void uiFingerScroll_horizontal_preSelfPostChainWorks() {
+ setup(false, 100, true);
+ doAnswer(new Answer() {
+ public Object answer(InvocationOnMock invocation) {
+ Object[] args = invocation.getArguments();
+ ((int[]) args[3])[0] = 50;
+ return null;
+ }
+ }).when(mParentSpy)
+ .onNestedPreScroll(any(View.class), anyInt(), anyInt(), any(int[].class), anyInt());
+ int touchSlop =
+ ViewConfiguration.get(InstrumentationRegistry.getContext()).getScaledTouchSlop();
+ MotionEvent down = MotionEvent.obtain(0, 0, MotionEvent.ACTION_DOWN, 500, 500, 0);
+ MotionEvent move =
+ MotionEvent.obtain(0, 100, MotionEvent.ACTION_MOVE, 300, 500, 0);
+
+ mRecyclerView.dispatchTouchEvent(down);
+ mRecyclerView.dispatchTouchEvent(move);
+
+ // Can't verify 'consumed' parameter values due to mutation, so instead capturing actual
+ // values manually in the the NestedScrollingSpyView object.
+ verify(mParentSpy).onNestedPreScroll(eq(mRecyclerView), eq(200), eq(0), any(int[].class),
+ eq(ViewCompat.TYPE_TOUCH));
+ assertThat(mParentSpy.onNestedPreScrollConsumedX, is(0));
+ assertThat(mParentSpy.onNestedPreScrollConsumedY, is(0));
+
+ verify(mParentSpy).onNestedScroll(mRecyclerView, 100, 0, 50 - touchSlop, 0,
+ ViewCompat.TYPE_TOUCH, new int[]{0, 0});
+ }
+
+ @Test
+ public void uiFling_vertical_parentHasNestedScrollingChildWithTypeFling() {
+ setup(true, 100, true);
+ long startTime = SystemClock.uptimeMillis();
+
+ SimpleGestureGeneratorKt
+ .simulateFling(mRecyclerView, startTime, 500, 500, Direction.UP);
+
+ assertThat(mParentSpy.axesForTypeTouch, is(ViewCompat.SCROLL_AXIS_NONE));
+ assertThat(mParentSpy.axesForTypeNonTouch, is(ViewCompat.SCROLL_AXIS_VERTICAL));
+ }
+
+ @Test
+ public void uiFling_horizontal_parentHasNestedScrollingChildWithTypeFling() {
+ setup(false, 100, true);
+ long startTime = SystemClock.uptimeMillis();
+
+ SimpleGestureGeneratorKt
+ .simulateFling(mRecyclerView, startTime, 500, 500, Direction.LEFT);
+
+ assertThat(mParentSpy.axesForTypeTouch, is(ViewCompat.SCROLL_AXIS_NONE));
+ assertThat(mParentSpy.axesForTypeNonTouch, is(ViewCompat.SCROLL_AXIS_HORIZONTAL));
+ }
+
+ @Test
+ public void uiFling_callsNestedFlingsCorrectly() {
+ setup(true, 100, true);
+ long startTime = SystemClock.uptimeMillis();
+
+ SimpleGestureGeneratorKt
+ .simulateFling(mRecyclerView, startTime, 500, 500, Direction.UP);
+
+ InOrder inOrder = Mockito.inOrder(mParentSpy);
+ inOrder.verify(mParentSpy).onNestedPreFling(
+ eq(mRecyclerView),
+ eq(0f),
+ anyFloat());
+ inOrder.verify(mParentSpy).onNestedFling(
+ eq(mRecyclerView),
+ eq(0f),
+ anyFloat(),
+ eq(true));
+ }
+
+ @Test
+ public void uiDown_duringFling_stopsNestedScrolling() {
+
+ // Arrange
+
+ setup(true, 1000, true);
+
+ final Context context = InstrumentationRegistry.getContext();
+ FlingData flingData = SimpleGestureGeneratorKt.generateFlingData(context);
+
+ final long firstDownTime = SystemClock.uptimeMillis();
+ // Should be after fling events occurred.
+ final long secondDownTime = firstDownTime + flingData.getTime() + 100;
+
+ List<MotionEventData> motionEventData = SimpleGestureGeneratorKt
+ .generateFlingMotionEventData(flingData, 500, 500, Direction.UP);
+ SimpleGestureGeneratorKt
+ .dispatchTouchEvents(mRecyclerView, firstDownTime, motionEventData);
+
+ // Sanity check that onStopNestedScroll has not yet been called of type TYPE_NON_TOUCH.
+ verify(mParentSpy, never())
+ .onStopNestedScroll(mRecyclerView, ViewCompat.TYPE_NON_TOUCH);
+
+ // Act
+
+ MotionEvent down = MotionEvent.obtain(
+ secondDownTime,
+ secondDownTime,
+ MotionEvent.ACTION_DOWN,
+ 500,
+ 500,
+ 0);
+ mRecyclerView.dispatchTouchEvent(down);
+
+ // Assert
+
+ verify(mParentSpy).onStopNestedScroll(mRecyclerView, ViewCompat.TYPE_NON_TOUCH);
+ }
+
+ @Test
+ public void uiFlings_parentReturnsFalseForOnNestedPreFling_onNestedFlingCalled() {
+ uiFlings_returnValueOfOnNestedPreFlingDeterminesCallToOnNestedFling(false, true);
+ }
+
+ @Test
+ public void uiFlings_parentReturnsTrueForOnNestedPreFling_onNestedFlingNotCalled() {
+ uiFlings_returnValueOfOnNestedPreFlingDeterminesCallToOnNestedFling(true, false);
+ }
+
+ private void uiFlings_returnValueOfOnNestedPreFlingDeterminesCallToOnNestedFling(
+ boolean returnValue, boolean onNestedFlingCalled) {
+ setup(true, 100, true);
+ when(mParentSpy.onNestedPreFling(eq(mRecyclerView), anyFloat(), anyFloat()))
+ .thenReturn(returnValue);
+
+ long startTime = SystemClock.uptimeMillis();
+ SimpleGestureGeneratorKt
+ .simulateFling(mRecyclerView, startTime, 500, 500, Direction.UP);
+
+ if (onNestedFlingCalled) {
+ verify(mParentSpy).onNestedFling(eq(mRecyclerView), anyFloat(), anyFloat(), eq(true));
+ } else {
+ verify(mParentSpy, never()).onNestedFling(any(View.class), anyFloat(), anyFloat(),
+ anyBoolean());
+ }
+ }
+
+ @Test
+ public void smoothScrollBy_doesNotStartNestedScrolling() {
+ setup(true, 100, true);
+ mRecyclerView.smoothScrollBy(0, 100);
+ verify(mParentSpy, never()).onStartNestedScroll(
+ any(View.class), any(View.class), anyInt(), anyInt());
+ }
+
+ public class NestedScrollingSpyView extends FrameLayout implements NestedScrollingChild3,
+ NestedScrollingParent3 {
+
+ public int axesForTypeTouch;
+ public int axesForTypeNonTouch;
+ public int onNestedPreScrollConsumedX;
+ public int onNestedPreScrollConsumedY;
+
+ public NestedScrollingSpyView(Context context) {
+ super(context);
+ }
+
+ @Override
+ public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
+ int type) {
+ return false;
+ }
+
+ @Override
+ public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes,
+ int type) {
+ if (type == ViewCompat.TYPE_NON_TOUCH) {
+ axesForTypeNonTouch = axes;
+ } else {
+ axesForTypeTouch = axes;
+ }
+ }
+
+ @Override
+ public void onStopNestedScroll(@NonNull View target, int type) {
+ if (type == ViewCompat.TYPE_NON_TOUCH) {
+ axesForTypeNonTouch = ViewCompat.SCROLL_AXIS_NONE;
+ } else {
+ axesForTypeTouch = ViewCompat.SCROLL_AXIS_NONE;
+ }
+ }
+
+ @Override
+ public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
+ int dxUnconsumed, int dyUnconsumed, int type) {
+
+ }
+
+ @Override
+ public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
+ int type) {
+ onNestedPreScrollConsumedX = consumed[0];
+ onNestedPreScrollConsumedY = consumed[1];
+ }
+
+ @Override
+ public boolean startNestedScroll(int axes, int type) {
+ return false;
+ }
+
+ @Override
+ public void stopNestedScroll(int type) {
+
+ }
+
+ @Override
+ public boolean hasNestedScrollingParent(int type) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
+ @Nullable int[] offsetInWindow, int type) {
+ return false;
+ }
+
+ @Override
+ public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
+ int dxUnconsumed, int dyUnconsumed, int type, @NonNull int[] consumed) {
+ }
+
+ @Override
+ public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, @Nullable int[] offsetInWindow, int type,
+ @NonNull int[] consumed) {
+ }
+
+ @Override
+ public void setNestedScrollingEnabled(boolean enabled) {
+
+ }
+
+ @Override
+ public boolean isNestedScrollingEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean startNestedScroll(int axes) {
+ return false;
+ }
+
+ @Override
+ public void stopNestedScroll() {
+
+ }
+
+ @Override
+ public boolean hasNestedScrollingParent() {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, int[] offsetInWindow) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed,
+ int[] offsetInWindow) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
+ return false;
+ }
+
+ @Override
+ public boolean onStartNestedScroll(View child, View target, int axes) {
+ return false;
+ }
+
+ @Override
+ public void onNestedScrollAccepted(View child, View target, int axes) {
+
+ }
+
+ @Override
+ public void onStopNestedScroll(View target) {
+
+ }
+
+ @Override
+ public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed) {
+
+ }
+
+ @Override
+ public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
+
+ }
+
+ @Override
+ public boolean onNestedFling(View target, float velocityX, float velocityY,
+ boolean consumed) {
+ return false;
+ }
+
+ @Override
+ public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
+ return false;
+ }
+
+ @Override
+ public int getNestedScrollAxes() {
+ return 0;
+ }
+ }
+
+ private class TestAdapter extends RecyclerView.Adapter<TestViewHolder> {
+
+ private Context mContext;
+ private int mOrientationSize;
+ private int mItemCount;
+ private boolean mVertical;
+
+ TestAdapter(Context context, float orientationSize, int itemCount, boolean vertical) {
+ mContext = context;
+ mOrientationSize = (int) Math.floor(orientationSize / itemCount);
+ mItemCount = itemCount;
+ mVertical = vertical;
+ }
+
+ @Override
+ public TestViewHolder onCreateViewHolder(ViewGroup parent,
+ int viewType) {
+ View view = new View(mContext);
+
+ int width;
+ int height;
+ if (mVertical) {
+ width = ViewGroup.LayoutParams.MATCH_PARENT;
+ height = mOrientationSize;
+ } else {
+ width = mOrientationSize;
+ height = ViewGroup.LayoutParams.MATCH_PARENT;
+ }
+
+ view.setLayoutParams(new ViewGroup.LayoutParams(width, height));
+ view.setMinimumHeight(mOrientationSize);
+ return new TestViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(TestViewHolder holder, int position) {
+
+ }
+
+ @Override
+ public int getItemCount() {
+ return mItemCount;
+ }
+ }
+
+ private class TestViewHolder extends RecyclerView.ViewHolder {
+
+ TestViewHolder(View itemView) {
+ super(itemView);
+ }
+ }
+}
diff --git a/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/RecyclerViewNestedScrollingTest.java b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/RecyclerViewNestedScrollingTest.java
new file mode 100644
index 0000000..41f6777
--- /dev/null
+++ b/recyclerview/recyclerview/src/androidTest/java/androidx/recyclerview/widget/RecyclerViewNestedScrollingTest.java
@@ -0,0 +1,525 @@
+/*
+ * Copyright 2018 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 androidx.recyclerview.widget;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyFloat;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.os.SystemClock;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.view.NestedScrollingChild3;
+import androidx.core.view.NestedScrollingParent3;
+import androidx.core.view.ViewCompat;
+import androidx.test.InstrumentationRegistry;
+import androidx.test.filters.LargeTest;
+import androidx.test.rule.ActivityTestRule;
+import androidx.test.runner.AndroidJUnit4;
+import androidx.testutils.Direction;
+import androidx.testutils.SimpleGestureGeneratorKt;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Large integration tests that verify that a {@link RecyclerView} interacts with nested scrolling
+ * correctly.
+ */
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class RecyclerViewNestedScrollingTest {
+
+ private static final int CHILD_HEIGHT = 800;
+ private static final int NSV_HEIGHT = 400;
+ private static final int PARENT_HEIGHT = 400;
+ private static final int WIDTH = 400;
+ private static final int ORIGIN_X_Y = 200;
+ private static final int TOTAL_SCROLL_DISTANCE = CHILD_HEIGHT - NSV_HEIGHT;
+ private static final int PARTIAL_SCROLL_DISTANCE = TOTAL_SCROLL_DISTANCE / 10;
+
+ private RecyclerView mRecyclerView;
+ private NestedScrollingSpyView mParent;
+
+ @Rule
+ public final ActivityTestRule<TestContentViewActivity> mActivityTestRule;
+
+ public RecyclerViewNestedScrollingTest() {
+ mActivityTestRule = new ActivityTestRule<>(TestContentViewActivity.class);
+ }
+
+ @Test
+ public void uiFlings_dispatchNestedPreFlingReturnsFalse_flings() throws Throwable {
+ uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsFling(false, true);
+ }
+
+ @Test
+ public void uiFlings_dispatchNestedPreFlingReturnsTrue_doesNotFling() throws Throwable {
+ uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsFling(true, false);
+ }
+
+ private void uiFlings_parentPreFlingReturnDeterminesNestedScrollViewsFling(
+ final boolean returnValue, final boolean scrolls) throws Throwable {
+ setup();
+ attachToActivity();
+
+ final CountDownLatch countDownLatch = new CountDownLatch(1);
+ mActivityTestRule.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ doReturn(true).when(mParent).onStartNestedScroll(any(View.class), any(View.class),
+ anyInt(), anyInt());
+ doReturn(returnValue).when(mParent).onNestedPreFling(any(View.class), anyFloat(),
+ anyFloat());
+
+ mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ int mTotalScrolled = 0;
+ @Override
+ public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
+
+ }
+
+ @Override
+ public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
+ // If we have a TYPE_NON_TOUCH nested scrolling parent, we are animated a
+ // fling.
+ if (mRecyclerView.hasNestedScrollingParent(ViewCompat.TYPE_NON_TOUCH)) {
+ mTotalScrolled += dy;
+ if (mTotalScrolled > PARTIAL_SCROLL_DISTANCE) {
+ countDownLatch.countDown();
+ }
+ }
+ }
+ });
+
+ SimpleGestureGeneratorKt.simulateFling(
+ mRecyclerView,
+ SystemClock.uptimeMillis(),
+ ORIGIN_X_Y,
+ ORIGIN_X_Y,
+ Direction.UP);
+ }
+ });
+ assertThat(countDownLatch.await(1, TimeUnit.SECONDS), is(scrolls));
+ }
+
+ @Test
+ public void uiFling_fullyParticipatesInNestedScrolling() throws Throwable {
+ setup();
+ attachToActivity();
+
+ final CountDownLatch countDownLatch = new CountDownLatch(2);
+ mActivityTestRule.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+
+ doReturn(true).when(mParent).onStartNestedScroll(any(View.class), any(View.class),
+ anyInt(), anyInt());
+
+ mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ int mTotalScrolled = 0;
+ @Override
+ public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) {
+ countDownLatch.countDown();
+ }
+ }
+
+ @Override
+ public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
+ mTotalScrolled += dy;
+ if (mTotalScrolled == TOTAL_SCROLL_DISTANCE) {
+ countDownLatch.countDown();
+ }
+ }
+ });
+
+ SimpleGestureGeneratorKt.simulateFling(
+ mRecyclerView,
+ SystemClock.uptimeMillis(),
+ ORIGIN_X_Y,
+ ORIGIN_X_Y,
+ Direction.UP);
+ }
+ });
+ assertThat(countDownLatch.await(2, TimeUnit.SECONDS), is(true));
+
+ // Verify all of the following TYPE_TOUCH nested scrolling methods are called.
+ verify(mParent, atLeastOnce()).onStartNestedScroll(mRecyclerView, mRecyclerView,
+ ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
+ verify(mParent, atLeastOnce()).onNestedScrollAccepted(mRecyclerView, mRecyclerView,
+ ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_TOUCH);
+ verify(mParent, atLeastOnce()).onNestedPreScroll(eq(mRecyclerView), anyInt(), anyInt(),
+ any(int[].class), eq(ViewCompat.TYPE_TOUCH));
+ verify(mParent, atLeastOnce()).onNestedScroll(eq(mRecyclerView), anyInt(), anyInt(),
+ anyInt(), anyInt(), eq(ViewCompat.TYPE_TOUCH), any(int[].class));
+ verify(mParent, atLeastOnce()).onNestedPreFling(eq(mRecyclerView), anyFloat(),
+ anyFloat());
+ verify(mParent, atLeastOnce()).onNestedFling(eq(mRecyclerView), anyFloat(), anyFloat(),
+ eq(true));
+ verify(mParent, atLeastOnce()).onStopNestedScroll(mRecyclerView, ViewCompat.TYPE_TOUCH);
+
+ // Verify all of the following TYPE_NON_TOUCH nested scrolling methods are called
+ verify(mParent, atLeastOnce()).onStartNestedScroll(mRecyclerView, mRecyclerView,
+ ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
+ verify(mParent, atLeastOnce()).onNestedScrollAccepted(mRecyclerView, mRecyclerView,
+ ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
+ verify(mParent, atLeastOnce()).onNestedPreScroll(eq(mRecyclerView), anyInt(), anyInt(),
+ any(int[].class), eq(ViewCompat.TYPE_NON_TOUCH));
+ verify(mParent, atLeastOnce()).onNestedScroll(eq(mRecyclerView), anyInt(), anyInt(),
+ anyInt(),
+ anyInt(), eq(ViewCompat.TYPE_NON_TOUCH), any(int[].class));
+ verify(mParent, atLeastOnce()).onStopNestedScroll(mRecyclerView,
+ ViewCompat.TYPE_NON_TOUCH);
+ }
+
+ @Test
+ public void fling_fullyParticipatesInNestedScrolling() throws Throwable {
+ setup();
+ attachToActivity();
+ final Context context = InstrumentationRegistry.getContext();
+ final int targetVelocity = (int) Math.ceil(
+ SimpleGestureGeneratorKt.generateFlingData(context).getVelocity() * 1000);
+
+ final CountDownLatch countDownLatch = new CountDownLatch(2);
+ mActivityTestRule.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ doReturn(true).when(mParent).onStartNestedScroll(any(View.class), any(View.class),
+ anyInt(), anyInt());
+
+ mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
+ int mTotalScrolled = 0;
+ @Override
+ public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
+ if (newState == RecyclerView.SCROLL_STATE_IDLE) {
+ countDownLatch.countDown();
+ }
+ }
+
+ @Override
+ public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
+ mTotalScrolled += dy;
+ if (mTotalScrolled == TOTAL_SCROLL_DISTANCE) {
+ countDownLatch.countDown();
+ }
+ }
+ });
+
+ mRecyclerView.fling(0, targetVelocity);
+ }
+ });
+ assertThat(countDownLatch.await(1, TimeUnit.SECONDS), is(true));
+
+ // Verify all of the following TYPE_NON_TOUCH nested scrolling methods are called
+ verify(mParent, atLeastOnce()).onStartNestedScroll(mRecyclerView, mRecyclerView,
+ ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
+ verify(mParent, atLeastOnce()).onNestedScrollAccepted(mRecyclerView, mRecyclerView,
+ ViewCompat.SCROLL_AXIS_VERTICAL, ViewCompat.TYPE_NON_TOUCH);
+ verify(mParent, atLeastOnce()).onNestedPreScroll(eq(mRecyclerView), anyInt(), anyInt(),
+ any(int[].class), eq(ViewCompat.TYPE_NON_TOUCH));
+ verify(mParent, atLeastOnce()).onNestedScroll(eq(mRecyclerView), anyInt(), anyInt(),
+ anyInt(),
+ anyInt(), eq(ViewCompat.TYPE_NON_TOUCH), any(int[].class));
+ verify(mParent, atLeastOnce()).onStopNestedScroll(mRecyclerView,
+ ViewCompat.TYPE_NON_TOUCH);
+
+ // Verify all of the following TYPE_TOUCH nested scrolling methods are not called.
+ verify(mParent, never()).onStartNestedScroll(any(View.class), any(View.class),
+ anyInt(), eq(ViewCompat.TYPE_TOUCH));
+ verify(mParent, never()).onNestedScrollAccepted(any(View.class), any(View.class),
+ anyInt(), eq(ViewCompat.TYPE_TOUCH));
+ verify(mParent, never()).onNestedPreScroll(any(View.class), anyInt(), anyInt(),
+ any(int[].class), eq(ViewCompat.TYPE_TOUCH));
+ verify(mParent, never()).onNestedScroll(any(View.class), anyInt(), anyInt(), anyInt(),
+ anyInt(), eq(ViewCompat.TYPE_TOUCH), any(int[].class));
+ verify(mParent, never()).onNestedPreFling(any(View.class), anyFloat(), anyFloat());
+ verify(mParent, never()).onNestedFling(any(View.class), anyFloat(), anyFloat(),
+ anyBoolean());
+ verify(mParent, never()).onStopNestedScroll(any(View.class), eq(ViewCompat.TYPE_TOUCH));
+ }
+
+ private void setup() {
+ Context context = mActivityTestRule.getActivity();
+
+ mRecyclerView = new RecyclerView(context);
+ mRecyclerView.setLayoutParams(new ViewGroup.LayoutParams(WIDTH, NSV_HEIGHT));
+ mRecyclerView.setBackgroundColor(0xFF0000FF);
+ mRecyclerView.setLayoutManager(new LinearLayoutManager(context));
+ mRecyclerView.setAdapter(new TestAdapter(context, CHILD_HEIGHT, 100, true));
+
+ mParent = spy(new NestedScrollingSpyView(context));
+ mParent.setLayoutParams(new ViewGroup.LayoutParams(WIDTH, PARENT_HEIGHT));
+ mParent.setBackgroundColor(0xFF0000FF);
+ mParent.addView(mRecyclerView);
+ }
+
+ private void attachToActivity() throws Throwable {
+ final TestContentView testContentView = mActivityTestRule.getActivity().getContentView();
+ testContentView.expectLayouts(1);
+ mActivityTestRule.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ testContentView.addView(mParent);
+ }
+ });
+ testContentView.awaitLayouts(2);
+ }
+
+ public static class NestedScrollingSpyView extends FrameLayout implements NestedScrollingChild3,
+ NestedScrollingParent3 {
+
+ public OnStopNestedScrollListener mOnStopNestedScrollListener;
+
+ public NestedScrollingSpyView(Context context) {
+ super(context);
+ }
+
+ @Override
+ public boolean onStartNestedScroll(@NonNull View child, @NonNull View target, int axes,
+ int type) {
+ return false;
+ }
+
+ @Override
+ public void onNestedScrollAccepted(@NonNull View child, @NonNull View target, int axes,
+ int type) {
+
+ }
+
+ @Override
+ public void onStopNestedScroll(@NonNull View target, int type) {
+ if (mOnStopNestedScrollListener != null) {
+ mOnStopNestedScrollListener.onStopNestedScroll(type);
+ }
+ }
+
+ @Override
+ public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
+ int dxUnconsumed, int dyUnconsumed, int type) {
+
+ }
+
+ @Override
+ public void onNestedPreScroll(@NonNull View target, int dx, int dy, @NonNull int[] consumed,
+ int type) {
+
+ }
+
+ @Override
+ public boolean startNestedScroll(int axes, int type) {
+ return false;
+ }
+
+ @Override
+ public void stopNestedScroll(int type) {
+
+ }
+
+ @Override
+ public boolean hasNestedScrollingParent(int type) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, @Nullable int[] offsetInWindow, int type) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedPreScroll(int dx, int dy, @Nullable int[] consumed,
+ @Nullable int[] offsetInWindow, int type) {
+ return false;
+ }
+
+ @Override
+ public void onNestedScroll(@NonNull View target, int dxConsumed, int dyConsumed,
+ int dxUnconsumed, int dyUnconsumed, int type, @Nullable int[] consumed) {
+ }
+
+ @Override
+ public void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, @Nullable int[] offsetInWindow, int type,
+ @Nullable int[] consumed) {
+ }
+
+ @Override
+ public void setNestedScrollingEnabled(boolean enabled) {
+
+ }
+
+ @Override
+ public boolean isNestedScrollingEnabled() {
+ return false;
+ }
+
+ @Override
+ public boolean startNestedScroll(int axes) {
+ return false;
+ }
+
+ @Override
+ public void stopNestedScroll() {
+
+ }
+
+ @Override
+ public boolean hasNestedScrollingParent() {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, int[] offsetInWindow) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed,
+ int[] offsetInWindow) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) {
+ return false;
+ }
+
+ @Override
+ public boolean dispatchNestedPreFling(float velocityX, float velocityY) {
+ return false;
+ }
+
+ @Override
+ public boolean onStartNestedScroll(View child, View target, int axes) {
+ return false;
+ }
+
+ @Override
+ public void onNestedScrollAccepted(View child, View target, int axes) {
+
+ }
+
+ @Override
+ public void onStopNestedScroll(View target) {
+
+ }
+
+ @Override
+ public void onNestedScroll(View target, int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed) {
+
+ }
+
+ @Override
+ public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
+
+ }
+
+ @Override
+ public boolean onNestedFling(View target, float velocityX, float velocityY,
+ boolean consumed) {
+ return false;
+ }
+
+ @Override
+ public boolean onNestedPreFling(View target, float velocityX, float velocityY) {
+ return false;
+ }
+
+ @Override
+ public int getNestedScrollAxes() {
+ return 0;
+ }
+
+ interface OnStopNestedScrollListener {
+ void onStopNestedScroll(int type);
+ }
+ }
+
+ private class TestAdapter extends RecyclerView.Adapter<TestViewHolder> {
+
+ private Context mContext;
+ private int mOrientationSize;
+ private int mItemCount;
+ private boolean mVertical;
+
+ TestAdapter(Context context, int orientationSize, int itemCount, boolean vertical) {
+ mContext = context;
+ mOrientationSize = orientationSize / itemCount;
+ mItemCount = itemCount;
+ mVertical = vertical;
+ }
+
+ @Override
+ public TestViewHolder onCreateViewHolder(ViewGroup parent,
+ int viewType) {
+ View view = new View(mContext);
+
+ int width;
+ int height;
+ if (mVertical) {
+ width = ViewGroup.LayoutParams.MATCH_PARENT;
+ height = mOrientationSize;
+ } else {
+ width = mOrientationSize;
+ height = ViewGroup.LayoutParams.MATCH_PARENT;
+ }
+
+ view.setLayoutParams(new ViewGroup.LayoutParams(width, height));
+ view.setMinimumHeight(mOrientationSize);
+ return new TestViewHolder(view);
+ }
+
+ @Override
+ public void onBindViewHolder(TestViewHolder holder, int position) {
+
+ }
+
+ @Override
+ public int getItemCount() {
+ return mItemCount;
+ }
+ }
+
+ private class TestViewHolder extends RecyclerView.ViewHolder {
+
+ TestViewHolder(View itemView) {
+ super(itemView);
+ }
+ }
+
+}
diff --git a/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/RecyclerView.java b/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/RecyclerView.java
index a1db707..d2baf5c 100644
--- a/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/RecyclerView.java
+++ b/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/RecyclerView.java
@@ -69,6 +69,7 @@
import androidx.core.view.InputDeviceCompat;
import androidx.core.view.MotionEventCompat;
import androidx.core.view.NestedScrollingChild2;
+import androidx.core.view.NestedScrollingChild3;
import androidx.core.view.NestedScrollingChildHelper;
import androidx.core.view.ScrollingView;
import androidx.core.view.ViewCompat;
@@ -204,7 +205,8 @@
*
* @attr ref androidx.recyclerview.R.styleable#RecyclerView_layoutManager
*/
-public class RecyclerView extends ViewGroup implements ScrollingView, NestedScrollingChild2 {
+public class RecyclerView extends ViewGroup implements ScrollingView,
+ NestedScrollingChild2, NestedScrollingChild3 {
static final String TAG = "RecyclerView";
@@ -565,14 +567,10 @@
private NestedScrollingChildHelper mScrollingChildHelper;
private final int[] mScrollOffset = new int[2];
- final int[] mScrollConsumed = new int[2];
private final int[] mNestedOffsets = new int[2];
- /**
- * Reusable int array for use in calls to {@link #scrollStep(int, int, int[])} so that the
- * method may mutate it to "return" 2 ints.
- */
- final int[] mScrollStepConsumed = new int[2];
+ // Reusable int array to be passed to method calls that mutate it in order to "return" two ints.
+ final int[] mReusableIntPair = new int[2];
/**
* These are views that had their a11y importance changed during a layout. We defer these events
@@ -1919,14 +1917,18 @@
* @return Whether any scroll was consumed in either direction.
*/
boolean scrollByInternal(int x, int y, MotionEvent ev) {
- int unconsumedX = 0, unconsumedY = 0;
- int consumedX = 0, consumedY = 0;
+ int unconsumedX = 0;
+ int unconsumedY = 0;
+ int consumedX = 0;
+ int consumedY = 0;
consumePendingUpdateOperations();
if (mAdapter != null) {
- scrollStep(x, y, mScrollStepConsumed);
- consumedX = mScrollStepConsumed[0];
- consumedY = mScrollStepConsumed[1];
+ mReusableIntPair[0] = 0;
+ mReusableIntPair[1] = 0;
+ scrollStep(x, y, mReusableIntPair);
+ consumedX = mReusableIntPair[0];
+ consumedY = mReusableIntPair[1];
unconsumedX = x - consumedX;
unconsumedY = y - consumedY;
}
@@ -1934,17 +1936,23 @@
invalidate();
}
- if (dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
- TYPE_TOUCH)) {
- // Update the last touch co-ords, taking any scroll offset into account
- mLastTouchX -= mScrollOffset[0];
- mLastTouchY -= mScrollOffset[1];
- if (ev != null) {
- ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
- }
- mNestedOffsets[0] += mScrollOffset[0];
- mNestedOffsets[1] += mScrollOffset[1];
- } else if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
+ mReusableIntPair[0] = 0;
+ mReusableIntPair[1] = 0;
+ dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, mScrollOffset,
+ TYPE_TOUCH, mReusableIntPair);
+ unconsumedX -= mReusableIntPair[0];
+ unconsumedY -= mReusableIntPair[1];
+
+ // Update the last touch co-ords, taking any scroll offset into account
+ mLastTouchX -= mScrollOffset[0];
+ mLastTouchY -= mScrollOffset[1];
+ if (ev != null) {
+ ev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
+ }
+ mNestedOffsets[0] += mScrollOffset[0];
+ mNestedOffsets[1] += mScrollOffset[1];
+
+ if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
if (ev != null && !MotionEventCompat.isFromSource(ev, InputDevice.SOURCE_MOUSE)) {
pullGlows(ev.getX(), unconsumedX, ev.getY(), unconsumedY);
}
@@ -2439,18 +2447,26 @@
void absorbGlows(int velocityX, int velocityY) {
if (velocityX < 0) {
ensureLeftGlow();
- mLeftGlow.onAbsorb(-velocityX);
+ if (mLeftGlow.isFinished()) {
+ mLeftGlow.onAbsorb(-velocityX);
+ }
} else if (velocityX > 0) {
ensureRightGlow();
- mRightGlow.onAbsorb(velocityX);
+ if (mRightGlow.isFinished()) {
+ mRightGlow.onAbsorb(velocityX);
+ }
}
if (velocityY < 0) {
ensureTopGlow();
- mTopGlow.onAbsorb(-velocityY);
+ if (mTopGlow.isFinished()) {
+ mTopGlow.onAbsorb(-velocityY);
+ }
} else if (velocityY > 0) {
ensureBottomGlow();
- mBottomGlow.onAbsorb(velocityY);
+ if (mBottomGlow.isFinished()) {
+ mBottomGlow.onAbsorb(velocityY);
+ }
}
if (velocityX != 0 || velocityY != 0) {
@@ -3027,6 +3043,7 @@
if (mScrollState == SCROLL_STATE_SETTLING) {
getParent().requestDisallowInterceptTouchEvent(true);
setScrollState(SCROLL_STATE_DRAGGING);
+ stopNestedScroll(TYPE_NON_TOUCH);
}
// Clear the nested offsets
@@ -3168,9 +3185,11 @@
int dx = mLastTouchX - x;
int dy = mLastTouchY - y;
- if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset, TYPE_TOUCH)) {
- dx -= mScrollConsumed[0];
- dy -= mScrollConsumed[1];
+ mReusableIntPair[0] = 0;
+ mReusableIntPair[1] = 0;
+ if (dispatchNestedPreScroll(dx, dy, mReusableIntPair, mScrollOffset, TYPE_TOUCH)) {
+ dx -= mReusableIntPair[0];
+ dy -= mReusableIntPair[1];
vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
// Updated the nested offsets
mNestedOffsets[0] += mScrollOffset[0];
@@ -5075,29 +5094,42 @@
// cause unexpected behaviors
final OverScroller scroller = mScroller;
if (scroller.computeScrollOffset()) {
- final int[] scrollConsumed = mScrollConsumed;
final int x = scroller.getCurrX();
final int y = scroller.getCurrY();
- int dx = x - mLastFlingX;
- int dy = y - mLastFlingY;
- int hresult = 0;
- int vresult = 0;
+ int unconsumedX = x - mLastFlingX;
+ int unconsumedY = y - mLastFlingY;
mLastFlingX = x;
mLastFlingY = y;
- int overscrollX = 0, overscrollY = 0;
+ int consumedX = 0;
+ int consumedY = 0;
- if (dispatchNestedPreScroll(dx, dy, scrollConsumed, null, TYPE_NON_TOUCH)) {
- dx -= scrollConsumed[0];
- dy -= scrollConsumed[1];
+ // Nested Pre Scroll
+ mReusableIntPair[0] = 0;
+ mReusableIntPair[1] = 0;
+ if (dispatchNestedPreScroll(unconsumedX, unconsumedY, mReusableIntPair, null,
+ TYPE_NON_TOUCH)) {
+ unconsumedX -= mReusableIntPair[0];
+ unconsumedY -= mReusableIntPair[1];
}
- if (mAdapter != null) {
- scrollStep(dx, dy, mScrollStepConsumed);
- hresult = mScrollStepConsumed[0];
- vresult = mScrollStepConsumed[1];
- overscrollX = dx - hresult;
- overscrollY = dy - vresult;
+ // Based on movement, we may want to trigger the hiding of existing over scroll
+ // glows.
+ if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
+ considerReleasingGlowsOnScroll(unconsumedX, unconsumedY);
+ }
+ // Local Scroll
+ if (mAdapter != null) {
+ mReusableIntPair[0] = 0;
+ mReusableIntPair[1] = 0;
+ scrollStep(unconsumedX, unconsumedY, mReusableIntPair);
+ consumedX = mReusableIntPair[0];
+ consumedY = mReusableIntPair[1];
+ unconsumedX -= consumedX;
+ unconsumedY -= consumedY;
+
+ // If SmoothScroller exists, this ViewFlinger was started by it, so we must
+ // report back to SmoothScroller.
SmoothScroller smoothScroller = mLayout.mSmoothScroller;
if (smoothScroller != null && !smoothScroller.isPendingInitialRun()
&& smoothScroller.isRunning()) {
@@ -5106,62 +5138,57 @@
smoothScroller.stop();
} else if (smoothScroller.getTargetPosition() >= adapterSize) {
smoothScroller.setTargetPosition(adapterSize - 1);
- smoothScroller.onAnimation(dx - overscrollX, dy - overscrollY);
+ smoothScroller.onAnimation(consumedX, consumedY);
} else {
- smoothScroller.onAnimation(dx - overscrollX, dy - overscrollY);
+ smoothScroller.onAnimation(consumedX, consumedY);
}
}
}
+
if (!mItemDecorations.isEmpty()) {
invalidate();
}
- if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
- considerReleasingGlowsOnScroll(dx, dy);
- }
- if (!dispatchNestedScroll(hresult, vresult, overscrollX, overscrollY, null,
- TYPE_NON_TOUCH)
- && (overscrollX != 0 || overscrollY != 0)) {
- final int vel = (int) scroller.getCurrVelocity();
+ // Nested Post Scroll
+ mReusableIntPair[0] = 0;
+ mReusableIntPair[1] = 0;
+ dispatchNestedScroll(consumedX, consumedY, unconsumedX, unconsumedY, null,
+ TYPE_NON_TOUCH, mReusableIntPair);
+ unconsumedX -= mReusableIntPair[0];
+ unconsumedY -= mReusableIntPair[1];
- int velX = 0;
- if (overscrollX != x) {
- velX = overscrollX < 0 ? -vel : overscrollX > 0 ? vel : 0;
- }
-
- int velY = 0;
- if (overscrollY != y) {
- velY = overscrollY < 0 ? -vel : overscrollY > 0 ? vel : 0;
- }
-
- if (getOverScrollMode() != View.OVER_SCROLL_NEVER) {
- absorbGlows(velX, velY);
- }
- if ((velX != 0 || overscrollX == x || scroller.getFinalX() == 0)
- && (velY != 0 || overscrollY == y || scroller.getFinalY() == 0)) {
- scroller.abortAnimation();
- }
- }
- if (hresult != 0 || vresult != 0) {
- dispatchOnScrolled(hresult, vresult);
+ if (consumedX != 0 || consumedY != 0) {
+ dispatchOnScrolled(consumedX, consumedY);
}
if (!awakenScrollBars()) {
invalidate();
}
- final boolean fullyConsumedVertical = dy != 0 && mLayout.canScrollVertically()
- && vresult == dy;
- final boolean fullyConsumedHorizontal = dx != 0 && mLayout.canScrollHorizontally()
- && hresult == dx;
- final boolean fullyConsumedAny = (dx == 0 && dy == 0) || fullyConsumedHorizontal
- || fullyConsumedVertical;
+ // We are done scrolling if scroller is finished, or for both the x and y dimension,
+ // we are done scrolling or we can't scroll further (we know we can't scroll further
+ // when we have unconsumed scroll distance). It's possible that we don't need
+ // to also check for scroller.isFinished() at all, but no harm in doing so in case
+ // of old bugs in Overscroller.
+ boolean scrollerFinishedX = scroller.getCurrX() == scroller.getFinalX();
+ boolean scrollerFinishedY = scroller.getCurrY() == scroller.getFinalY();
+ final boolean doneScrolling = scroller.isFinished()
+ || ((scrollerFinishedX || unconsumedX != 0)
+ && (scrollerFinishedY || unconsumedY != 0));
SmoothScroller smoothScroller = mLayout.mSmoothScroller;
- boolean smoothScrollerPending = smoothScroller != null
- && smoothScroller.isPendingInitialRun();
- if (!smoothScrollerPending && (scroller.isFinished() || (!fullyConsumedAny
- && !hasNestedScrollingParent(TYPE_NON_TOUCH)))) {
+ boolean smoothScrollerPending =
+ smoothScroller != null && smoothScroller.isPendingInitialRun();
+
+ if (!smoothScrollerPending && doneScrolling) {
+ // If we are done scrolling and the layout's SmoothScroller is not pending,
+ // stop the scroll.
+
+ final int vel = (int) scroller.getCurrVelocity();
+ int velX = unconsumedX < 0 ? -vel : unconsumedX > 0 ? vel : 0;
+ int velY = unconsumedY < 0 ? -vel : unconsumedY > 0 ? vel : 0;
+ absorbGlows(velX, velY);
+
// setting state to idle will stop this.
setScrollState(SCROLL_STATE_IDLE);
if (ALLOW_THREAD_GAP_WORK) {
@@ -5169,9 +5196,11 @@
}
stopNestedScroll(TYPE_NON_TOUCH);
} else {
+ // Otherwise continue the scroll.
+
postOnAnimation();
if (mGapWorker != null) {
- mGapWorker.postFromTraversal(RecyclerView.this, dx, dy);
+ mGapWorker.postFromTraversal(RecyclerView.this, unconsumedX, unconsumedY);
}
}
}
@@ -11345,6 +11374,13 @@
}
@Override
+ public final void dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed,
+ int dyUnconsumed, int[] offsetInWindow, int type, int[] consumed) {
+ getScrollingChildHelper().dispatchNestedScroll(dxConsumed, dyConsumed,
+ dxUnconsumed, dyUnconsumed, offsetInWindow, type, consumed);
+ }
+
+ @Override
public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) {
return getScrollingChildHelper().dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow);
}
diff --git a/samples/Support7Demos/src/main/AndroidManifest.xml b/samples/Support7Demos/src/main/AndroidManifest.xml
index 692ebf2..d7f1513 100644
--- a/samples/Support7Demos/src/main/AndroidManifest.xml
+++ b/samples/Support7Demos/src/main/AndroidManifest.xml
@@ -396,6 +396,24 @@
</intent-filter>
</activity>
+ <activity android:name=".widget.RvInNestedScrollViewActivity"
+ android:label="@string/rv_in_nestedScrollView"
+ android:theme="@style/Theme.AppCompat">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="com.example.android.supportv7.SAMPLE_CODE" />
+ </intent-filter>
+ </activity>
+
+ <activity android:name=".widget.RvIn2NestedScrollViewsActivity"
+ android:label="@string/rv_in_2_nestedScrollViews"
+ android:theme="@style/Theme.AppCompat">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="com.example.android.supportv7.SAMPLE_CODE" />
+ </intent-filter>
+ </activity>
+
<activity android:name=".widget.PagerRecyclerViewActivity"
android:label="@string/pager_recycler_view"
android:theme="@style/Theme.AppCompat">
@@ -452,7 +470,7 @@
</activity>
<activity android:name=".widget.StableIdActivity"
- android:label="@string/recycler_view"
+ android:label="@string/recycler_view_stableid"
android:theme="@style/Theme.AppCompat">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
diff --git a/samples/Support7Demos/src/main/java/com/example/android/supportv7/widget/RvIn2NestedScrollViewsActivity.java b/samples/Support7Demos/src/main/java/com/example/android/supportv7/widget/RvIn2NestedScrollViewsActivity.java
new file mode 100644
index 0000000..66a112e
--- /dev/null
+++ b/samples/Support7Demos/src/main/java/com/example/android/supportv7/widget/RvIn2NestedScrollViewsActivity.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2018 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.example.android.supportv7.widget;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.example.android.supportv7.R;
+
+/**
+ * A sample nested RecyclerView activity.
+ */
+public class RvIn2NestedScrollViewsActivity extends Activity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_rv_in_two_nestedscrollviews);
+
+ RecyclerView recyclerView = findViewById(R.id.rv);
+ recyclerView.setLayoutManager(new LinearLayoutManager(this));
+ recyclerView.setAdapter(new MyAdapter(this));
+ }
+
+ private static class MyAdapter extends RecyclerView.Adapter<MyViewHolder> {
+
+ Context mContext;
+ int mItemMinHeight;
+
+ MyAdapter(Context context) {
+ mContext = context;
+ mItemMinHeight = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, 96,
+ context.getResources().getDisplayMetrics());
+ }
+
+ @Override
+ public MyViewHolder onCreateViewHolder(ViewGroup parent,
+ int viewType) {
+ TextView textView = new TextView(mContext);
+ ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ textView.setLayoutParams(layoutParams);
+ textView.setMinHeight(mItemMinHeight);
+ return new MyViewHolder(textView);
+ }
+
+ @Override
+ public void onBindViewHolder(MyViewHolder holder, int position) {
+ TextView textView = ((TextView) holder.itemView);
+ textView.setText("Position: " + position);
+ textView.setOnClickListener(
+ v -> Toast.makeText(v.getContext(), "CLICK!", Toast.LENGTH_SHORT).show());
+ }
+
+ @Override
+ public int getItemCount() {
+ return 10;
+ }
+ }
+
+ private static class MyViewHolder extends RecyclerView.ViewHolder {
+
+ MyViewHolder(View itemView) {
+ super(itemView);
+ }
+ }
+}
diff --git a/samples/Support7Demos/src/main/java/com/example/android/supportv7/widget/RvInNestedScrollViewActivity.java b/samples/Support7Demos/src/main/java/com/example/android/supportv7/widget/RvInNestedScrollViewActivity.java
new file mode 100644
index 0000000..6671131
--- /dev/null
+++ b/samples/Support7Demos/src/main/java/com/example/android/supportv7/widget/RvInNestedScrollViewActivity.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2018 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.example.android.supportv7.widget;
+
+import android.app.Activity;
+import android.content.Context;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.example.android.supportv7.R;
+
+/**
+ * A sample nested RecyclerView activity.
+ */
+public class RvInNestedScrollViewActivity extends Activity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ setContentView(R.layout.activity_rv_in_nestedscrollview);
+
+ RecyclerView recyclerView = findViewById(R.id.rv);
+ recyclerView.setLayoutManager(new LinearLayoutManager(this));
+ recyclerView.setAdapter(new MyAdapter(this));
+ }
+
+ private static class MyAdapter extends RecyclerView.Adapter<MyViewHolder> {
+
+ Context mContext;
+ int mItemMinHeight;
+
+ MyAdapter(Context context) {
+ mContext = context;
+ mItemMinHeight = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, 96,
+ context.getResources().getDisplayMetrics());
+ }
+
+ @Override
+ public MyViewHolder onCreateViewHolder(ViewGroup parent,
+ int viewType) {
+ TextView textView = new TextView(mContext);
+ ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ textView.setLayoutParams(layoutParams);
+ textView.setMinHeight(mItemMinHeight);
+ return new MyViewHolder(textView);
+ }
+
+ @Override
+ public void onBindViewHolder(MyViewHolder holder, int position) {
+ TextView textView = ((TextView) holder.itemView);
+ textView.setText("Position: " + position);
+ textView.setOnClickListener(
+ v -> Toast.makeText(v.getContext(), "CLICK!", Toast.LENGTH_SHORT).show());
+ }
+
+ @Override
+ public int getItemCount() {
+ return 50;
+ }
+ }
+
+ private static class MyViewHolder extends RecyclerView.ViewHolder {
+
+ MyViewHolder(View itemView) {
+ super(itemView);
+ }
+ }
+}
diff --git a/samples/Support7Demos/src/main/res/layout/activity_rv_in_nestedscrollview.xml b/samples/Support7Demos/src/main/res/layout/activity_rv_in_nestedscrollview.xml
new file mode 100644
index 0000000..964849e
--- /dev/null
+++ b/samples/Support7Demos/src/main/res/layout/activity_rv_in_nestedscrollview.xml
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright 2018 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.
+ -->
+
+<androidx.core.widget.NestedScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent">
+ <androidx.recyclerview.widget.RecyclerView
+ android:id="@+id/rv"
+ android:layout_width="match_parent"
+ android:layout_height="2000dp"/>
+</androidx.core.widget.NestedScrollView>
\ No newline at end of file
diff --git a/samples/Support7Demos/src/main/res/layout/activity_rv_in_two_nestedscrollviews.xml b/samples/Support7Demos/src/main/res/layout/activity_rv_in_two_nestedscrollviews.xml
new file mode 100644
index 0000000..eef496a
--- /dev/null
+++ b/samples/Support7Demos/src/main/res/layout/activity_rv_in_two_nestedscrollviews.xml
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright 2018 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.
+ -->
+<androidx.core.widget.NestedScrollView
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:tag="outer">
+
+ <LinearLayout android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="#700">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="100dp"
+ android:text="ONE"/>
+
+ <androidx.core.widget.NestedScrollView
+ android:layout_width="match_parent"
+ android:layout_height="500dp"
+ android:tag="inner">
+
+ <LinearLayout android:orientation="vertical"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:background="#070">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="100dp"
+ android:text="TWO"/>
+
+ <androidx.recyclerview.widget.RecyclerView
+ android:background="#007"
+ android:id="@+id/rv"
+ android:layout_width="match_parent"
+ android:layout_height="400dp"/>
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="100dp"
+ android:text="FOUR"/>
+
+ </LinearLayout>
+
+ </androidx.core.widget.NestedScrollView>
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="100dp"
+ android:text="FIVE"/>
+
+ </LinearLayout>
+
+</androidx.core.widget.NestedScrollView>
\ No newline at end of file
diff --git a/samples/Support7Demos/src/main/res/values/strings.xml b/samples/Support7Demos/src/main/res/values/strings.xml
index c116600..9934d27 100644
--- a/samples/Support7Demos/src/main/res/values/strings.xml
+++ b/samples/Support7Demos/src/main/res/values/strings.xml
@@ -137,9 +137,12 @@
<string name="sample_media_route_activity_presentation">Local Playback on Presentation Display</string>
<string name="recycler_view">RecyclerView/RecyclerViewActivity</string>
+ <string name="recycler_view_stableid">RecyclerView/Stable Ids</string>
<string name="pager_recycler_view">RecyclerView/PagerRecyclerViewActivity</string>
<string name="animated_recycler_view">RecyclerView/Animated RecyclerView</string>
<string name="nested_recycler_view">RecyclerView/Nested RecyclerView</string>
+ <string name="rv_in_nestedScrollView">RecyclerView/RV in NestedScrollView</string>
+ <string name="rv_in_2_nestedScrollViews">RecyclerView/RV in 2 NestedScrollViews</string>
<string name="linear_layout_manager">RecyclerView/Linear Layout Manager</string>
<string name="linear_layout_manager_jank">RecyclerView/Janky Linear Layout Manager</string>
<string name="grid_layout_manager">RecyclerView/Grid Layout Manager</string>
diff --git a/testutils/build.gradle b/testutils/build.gradle
index be56003..f1fe241 100644
--- a/testutils/build.gradle
+++ b/testutils/build.gradle
@@ -18,6 +18,7 @@
plugins {
id("SupportAndroidLibraryPlugin")
+ id("kotlin-android")
}
dependencies {
@@ -30,6 +31,7 @@
implementation(MOCKITO_CORE, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
implementation(DEXMAKER_MOCKITO, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
implementation(JUNIT)
+ implementation(KOTLIN_STDLIB)
}
android {
diff --git a/testutils/src/main/java/androidx/testutils/SimpleGestureGenerator.kt b/testutils/src/main/java/androidx/testutils/SimpleGestureGenerator.kt
new file mode 100644
index 0000000..72e7cf3
--- /dev/null
+++ b/testutils/src/main/java/androidx/testutils/SimpleGestureGenerator.kt
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2018 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 androidx.testutils
+
+import android.content.Context
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewConfiguration
+import java.util.ArrayList
+import kotlin.math.ceil
+import kotlin.math.floor
+
+/** One [MotionEvent] approximately every 10 milliseconds. We care about this frequency because a
+ * standard touchscreen operates at 100 Hz and therefore produces about one touch event every
+ * 10 milliseconds. We want to produce a similar frequency to emulate real world input events.*/
+const val MOTION_EVENT_INTERVAL_MILLIS: Int = 10
+
+/**
+ * Distance and time span necessary to produce a fling.
+ *
+ * Distance and time necessary to produce a fling for [MotionEvent]
+ *
+ * @property distance Distance between [MotionEvent]s in pixels for a fling.
+ * @property time Time between [MotionEvent]s in milliseconds for a fling.
+ */
+data class FlingData(val distance: Float, val time: Int) {
+
+ /**
+ * @property velocity Velocity of fling in pixels per millisecond.
+ */
+ val velocity: Float = distance / time
+}
+
+data class MotionEventData(
+ val eventTimeDelta: Int,
+ val action: Int,
+ val x: Float,
+ val y: Float,
+ val metaState: Int
+)
+
+enum class Direction {
+ UP, DOWN, LEFT, RIGHT
+}
+
+fun MotionEventData.toMotionEvent(downTime: Long): MotionEvent = MotionEvent.obtain(
+ downTime,
+ this.eventTimeDelta + downTime,
+ this.action,
+ this.x,
+ this.y,
+ this.metaState)
+
+/**
+ * Constructs a [FlingData] from a [Context].
+ */
+fun generateFlingData(context: Context): FlingData {
+ val configuration = ViewConfiguration.get(context)
+ val touchSlop = configuration.scaledTouchSlop
+ val minimumVelocity = configuration.scaledMinimumFlingVelocity
+ val maximumVelocity = configuration.scaledMaximumFlingVelocity
+
+ val targetPixelsPerMilli = ((maximumVelocity + minimumVelocity) / 2) / 1000f
+ val targetDistancePixels = touchSlop * 2
+ val targetMillisPassed = floor(targetDistancePixels / targetPixelsPerMilli).toInt()
+
+ if (targetMillisPassed < 1) {
+ throw IllegalArgumentException("Flings must require some time")
+ }
+
+ return FlingData(targetDistancePixels.toFloat(), targetMillisPassed)
+}
+
+/**
+ * Returns [value] rounded up to the closest [interval] * N, where N is a Integer.
+ */
+private fun ceilToInterval(value: Int, interval: Int): Int =
+ ceil(value.toFloat() / interval).toInt() * interval
+
+/**
+ * Generates a [List] of [MotionEventData] starting from ([originX], [originY]) that will cause a
+ * fling in the finger direction [Direction].
+ */
+fun FlingData.generateFlingMotionEventData(
+ originX: Float,
+ originY: Float,
+ fingerDirection: Direction
+):
+ List<MotionEventData> {
+
+ // Ceiling the time and distance to match up with motion event intervals.
+ val time: Int = ceilToInterval(this.time, MOTION_EVENT_INTERVAL_MILLIS)
+ val distance: Float = velocity * time
+
+ val dx: Float = when (fingerDirection) {
+ Direction.LEFT -> -distance
+ Direction.RIGHT -> distance
+ else -> 0f
+ }
+ val dy: Float = when (fingerDirection) {
+ Direction.UP -> -distance
+ Direction.DOWN -> distance
+ else -> 0f
+ }
+ val toX = originX + dx
+ val toY = originY + dy
+
+ val numberOfInnerEvents = (time / MOTION_EVENT_INTERVAL_MILLIS) - 1
+ val dxIncrement = dx / (numberOfInnerEvents + 1)
+ val dyIncrement = dy / (numberOfInnerEvents + 1)
+
+ val motionEventData = ArrayList<MotionEventData>()
+ motionEventData.add(MotionEventData(0, MotionEvent.ACTION_DOWN, originX, originY, 0))
+ for (i in 1..(numberOfInnerEvents)) {
+ val timeDelta = i * MOTION_EVENT_INTERVAL_MILLIS
+ val x = i * dxIncrement
+ val y = i * dyIncrement
+ motionEventData.add(MotionEventData(timeDelta, MotionEvent.ACTION_MOVE, x, y, 0))
+ }
+ motionEventData.add(MotionEventData(time, MotionEvent.ACTION_MOVE, toX, toY, 0))
+ motionEventData.add(MotionEventData(time, MotionEvent.ACTION_UP, toX, toY, 0))
+
+ return motionEventData
+}
+
+/**
+ * Dispatches an array of [MotionEvent] to a [View].
+ *
+ * The MotionEvents will start at [downTime] and will be generated from the [motionEventData].
+ * The MotionEvents will be dispatched synchronously, one after the other, with no gaps of time
+ * in between each [MotionEvent].
+ *
+ */
+fun View.dispatchTouchEvents(downTime: Long, motionEventData: List<MotionEventData>) {
+ for (motionEventDataItem in motionEventData) {
+ dispatchTouchEvent(motionEventDataItem.toMotionEvent(downTime))
+ }
+}
+
+/**
+ * Simulates a fling on a [View].
+ *
+ * Convenience method that calls other public api. See documentation of those functions for more
+ * detail.
+ *
+ * @see [generateFlingData]
+ * @see [generateFlingMotionEventData]
+ * @see [dispatchTouchEvents]
+ */
+fun View.simulateFling(
+ downTime: Long,
+ originX: Float,
+ originY: Float,
+ direction: Direction
+) {
+ dispatchTouchEvents(downTime,
+ generateFlingData(context).generateFlingMotionEventData(originX, originY, direction))
+}