Add stretch overscroll to ViewPager
Bug: 171228096
Relnote: "Adds stretch overscroll support to ViewPager"
Test: new tests. ran existing ViewPager tests.
Change-Id: I79d4e0a9dadac5b855ad2504271b2e7723479efc
diff --git a/jetifier/jetifier/migration.config b/jetifier/jetifier/migration.config
index 75d8ce3..f591f9f 100644
--- a/jetifier/jetifier/migration.config
+++ b/jetifier/jetifier/migration.config
@@ -1041,6 +1041,10 @@
"to": "android/support/v4/widget/annotations"
},
{
+ "from": "androidx/viewpager/widget/annotations",
+ "to": "android/support/v4/view/annotations"
+ },
+ {
"from": "androidx/annotation/experimental/(.*)",
"to": "ignore"
}
diff --git a/viewpager/viewpager/api/current.txt b/viewpager/viewpager/api/current.txt
index c0a4ddd..e8231bd 100644
--- a/viewpager/viewpager/api/current.txt
+++ b/viewpager/viewpager/api/current.txt
@@ -63,6 +63,7 @@
method public void fakeDragBy(float);
method public androidx.viewpager.widget.PagerAdapter? getAdapter();
method public int getCurrentItem();
+ method public int getEdgeEffectType();
method public int getOffscreenPageLimit();
method public int getPageMargin();
method public boolean isDragInGutterEnabled();
@@ -76,6 +77,7 @@
method public void setCurrentItem(int);
method public void setCurrentItem(int, boolean);
method public void setDragInGutterEnabled(boolean);
+ method public void setEdgeEffectType(int);
method public void setOffscreenPageLimit(int);
method @Deprecated public void setOnPageChangeListener(androidx.viewpager.widget.ViewPager.OnPageChangeListener!);
method public void setPageMargin(int);
diff --git a/viewpager/viewpager/api/public_plus_experimental_current.txt b/viewpager/viewpager/api/public_plus_experimental_current.txt
index c0a4ddd..e8231bd 100644
--- a/viewpager/viewpager/api/public_plus_experimental_current.txt
+++ b/viewpager/viewpager/api/public_plus_experimental_current.txt
@@ -63,6 +63,7 @@
method public void fakeDragBy(float);
method public androidx.viewpager.widget.PagerAdapter? getAdapter();
method public int getCurrentItem();
+ method public int getEdgeEffectType();
method public int getOffscreenPageLimit();
method public int getPageMargin();
method public boolean isDragInGutterEnabled();
@@ -76,6 +77,7 @@
method public void setCurrentItem(int);
method public void setCurrentItem(int, boolean);
method public void setDragInGutterEnabled(boolean);
+ method public void setEdgeEffectType(int);
method public void setOffscreenPageLimit(int);
method @Deprecated public void setOnPageChangeListener(androidx.viewpager.widget.ViewPager.OnPageChangeListener!);
method public void setPageMargin(int);
diff --git a/viewpager/viewpager/api/restricted_current.txt b/viewpager/viewpager/api/restricted_current.txt
index c0a4ddd..e8231bd 100644
--- a/viewpager/viewpager/api/restricted_current.txt
+++ b/viewpager/viewpager/api/restricted_current.txt
@@ -63,6 +63,7 @@
method public void fakeDragBy(float);
method public androidx.viewpager.widget.PagerAdapter? getAdapter();
method public int getCurrentItem();
+ method public int getEdgeEffectType();
method public int getOffscreenPageLimit();
method public int getPageMargin();
method public boolean isDragInGutterEnabled();
@@ -76,6 +77,7 @@
method public void setCurrentItem(int);
method public void setCurrentItem(int, boolean);
method public void setDragInGutterEnabled(boolean);
+ method public void setEdgeEffectType(int);
method public void setOffscreenPageLimit(int);
method @Deprecated public void setOnPageChangeListener(androidx.viewpager.widget.ViewPager.OnPageChangeListener!);
method public void setPageMargin(int);
diff --git a/viewpager/viewpager/build.gradle b/viewpager/viewpager/build.gradle
index 7194986..e10cfb8 100644
--- a/viewpager/viewpager/build.gradle
+++ b/viewpager/viewpager/build.gradle
@@ -17,7 +17,7 @@
dependencies {
api("androidx.annotation:annotation:1.1.0")
- implementation("androidx.core:core:1.3.0-beta01")
+ implementation project(":core:core")
api("androidx.customview:customview:1.0.0")
androidTestImplementation(ANDROIDX_TEST_EXT_JUNIT)
@@ -27,6 +27,7 @@
androidTestImplementation(ESPRESSO_CORE, libs.exclude_for_espresso)
androidTestImplementation(MOCKITO_CORE, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
androidTestImplementation(DEXMAKER_MOCKITO, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
+ androidTestImplementation project(':internal-testutils-espresso')
}
androidx {
diff --git a/viewpager/viewpager/src/androidTest/java/androidx/viewpager/widget/BaseViewPagerTest.java b/viewpager/viewpager/src/androidTest/java/androidx/viewpager/widget/BaseViewPagerTest.java
index 15cfc63..58663e6 100644
--- a/viewpager/viewpager/src/androidTest/java/androidx/viewpager/widget/BaseViewPagerTest.java
+++ b/viewpager/viewpager/src/androidTest/java/androidx/viewpager/widget/BaseViewPagerTest.java
@@ -23,6 +23,7 @@
import static android.support.v4.testutils.TestUtilsMatchers.startAlignedToParent;
import static androidx.test.espresso.Espresso.onView;
+import static androidx.test.espresso.action.ViewActions.actionWithAssertions;
import static androidx.test.espresso.action.ViewActions.pressKey;
import static androidx.test.espresso.action.ViewActions.swipeLeft;
import static androidx.test.espresso.action.ViewActions.swipeRight;
@@ -54,6 +55,7 @@
import android.app.Activity;
import android.graphics.Color;
+import android.os.Build;
import android.support.v4.testutils.TestUtilsMatchers;
import android.text.TextUtils;
import android.util.Pair;
@@ -66,10 +68,15 @@
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.action.EspressoKey;
+import androidx.test.espresso.action.GeneralLocation;
+import androidx.test.espresso.action.GeneralSwipeAction;
+import androidx.test.espresso.action.Press;
+import androidx.test.espresso.action.Swipe;
import androidx.test.filters.FlakyTest;
import androidx.test.filters.LargeTest;
import androidx.test.filters.MediumTest;
import androidx.test.rule.ActivityTestRule;
+import androidx.testutils.TranslatedCoordinatesProvider;
import androidx.viewpager.test.R;
import org.junit.After;
@@ -94,6 +101,13 @@
@Rule
public final ActivityTestRule<T> mActivityTestRule;
+ /**
+ * The distance of a swipe's start position from the view's edge, in terms of the view's length.
+ * We do not start the swipe exactly on the view's edge, but somewhat more inward, since swiping
+ * from the exact edge may behave in an unexpected way (e.g. may open a navigation drawer).
+ */
+ private static final float EDGE_FUZZ_FACTOR = 0.083f;
+
private static final int DIRECTION_LEFT = -1;
private static final int DIRECTION_RIGHT = 1;
protected ViewPager mViewPager;
@@ -418,8 +432,13 @@
onView(withId(R.id.pager)).perform(ViewPagerActions.wrap(swipeLeft()), ViewPagerActions.wrap(swipeLeft()));
assertEquals("Swipe twice left", 2, mViewPager.getCurrentItem());
- onView(withId(R.id.pager)).perform(ViewPagerActions.wrap(swipeLeft()), ViewPagerActions.wrap(swipeRight()));
- assertEquals("Swipe left beyond last page and then right", 1, mViewPager.getCurrentItem());
+ onView(withId(R.id.pager)).perform(ViewPagerActions.wrap(swipeLeft()),
+ ViewPagerActions.wrap(slowSwipeRight()));
+ // On S and above, the swipe right will be absorbed by the EdgeEffect created during
+ // swipe left.
+ int leftRightPage = isSOrHigher() ? 2 : 1;
+ assertEquals("Swipe left beyond last page and then right", leftRightPage,
+ mViewPager.getCurrentItem());
onView(withId(R.id.pager)).perform(
ViewPagerActions.wrap(swipeRight()), ViewPagerActions.wrap(swipeRight()));
@@ -427,8 +446,47 @@
mViewPager.getCurrentItem());
onView(withId(R.id.pager)).perform(
- ViewPagerActions.wrap(swipeRight()), ViewPagerActions.wrap(swipeLeft()));
- assertEquals("Swipe right beyond first page and then left", 1, mViewPager.getCurrentItem());
+ ViewPagerActions.wrap(swipeRight()), ViewPagerActions.wrap(slowSwipeLeft()));
+ // On S and above, the swipe left will be absorbed by the EdgeEffect created during
+ // swipe right.
+ int rightLeftPage = isSOrHigher() ? 0 : 1;
+ assertEquals("Swipe right beyond first page and then left", rightLeftPage,
+ mViewPager.getCurrentItem());
+ }
+
+ /**
+ * Returns an action that performs a slow swipe left-to-right across the vertical center of the
+ * view. The swipe doesn't start at the very edge of the view, but is a bit offset.<br>
+ */
+ public static ViewAction slowSwipeRight() {
+ return actionWithAssertions(
+ new GeneralSwipeAction(
+ Swipe.SLOW,
+ new TranslatedCoordinatesProvider(
+ GeneralLocation.CENTER_LEFT, EDGE_FUZZ_FACTOR, 0),
+ GeneralLocation.CENTER_RIGHT,
+ Press.FINGER));
+ }
+
+ /**
+ * Returns an action that performs a slow swipe left-to-right across the vertical center of the
+ * view. The swipe doesn't start at the very edge of the view, but is a bit offset.<br>
+ */
+ public static ViewAction slowSwipeLeft() {
+ return actionWithAssertions(
+ new GeneralSwipeAction(
+ Swipe.SLOW,
+ new TranslatedCoordinatesProvider(
+ GeneralLocation.CENTER_RIGHT, -EDGE_FUZZ_FACTOR, 0),
+ GeneralLocation.CENTER_LEFT,
+ Press.FINGER));
+ }
+
+ public static boolean isSOrHigher() {
+ // TODO(b/181171227): Simplify this
+ int sdk = Build.VERSION.SDK_INT;
+ return sdk > Build.VERSION_CODES.R
+ || (sdk == Build.VERSION_CODES.R && Build.VERSION.PREVIEW_SDK_INT != 0);
}
private void verifyPageContent(boolean smoothScroll) {
diff --git a/viewpager/viewpager/src/androidTest/java/androidx/viewpager/widget/ViewPagerTest.java b/viewpager/viewpager/src/androidTest/java/androidx/viewpager/widget/ViewPagerTest.java
index 78f5a07..f72022c 100644
--- a/viewpager/viewpager/src/androidTest/java/androidx/viewpager/widget/ViewPagerTest.java
+++ b/viewpager/viewpager/src/androidTest/java/androidx/viewpager/widget/ViewPagerTest.java
@@ -16,6 +16,9 @@
package androidx.viewpager.widget;
+import static androidx.viewpager.widget.BaseViewPagerTest.isSOrHigher;
+
+import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -23,12 +26,14 @@
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
+import android.widget.EdgeEffect;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.filters.MediumTest;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
+import androidx.viewpager.test.R;
import org.junit.Rule;
import org.junit.Test;
@@ -71,6 +76,29 @@
assertTrue(adapter.primaryCalled);
}
+ @Test
+ public void testEdgeEffectType() throws Throwable {
+ activityRule.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ activityRule.getActivity().setContentView(R.layout.view_pager_with_stretch);
+ }
+ });
+ ViewPager viewPager = (ViewPager) activityRule.getActivity().findViewById(R.id.pager);
+ if (isSOrHigher()) {
+ // Starts out as stretch because the attribute is set
+ assertEquals(EdgeEffect.TYPE_STRETCH, viewPager.getEdgeEffectType());
+ // Set the type to glow
+ viewPager.setEdgeEffectType(EdgeEffect.TYPE_GLOW);
+ assertEquals(EdgeEffect.TYPE_GLOW, viewPager.getEdgeEffectType());
+ } else {
+ // Earlier versions only support glow
+ assertEquals(EdgeEffect.TYPE_GLOW, viewPager.getEdgeEffectType());
+ viewPager.setEdgeEffectType(EdgeEffect.TYPE_STRETCH);
+ assertEquals(EdgeEffect.TYPE_GLOW, viewPager.getEdgeEffectType());
+ }
+ }
+
static final class PrimaryItemPagerAdapter extends PagerAdapter {
public volatile int count;
public volatile boolean primaryCalled;
diff --git a/viewpager/viewpager/src/androidTest/res/layout-v31/view_pager_with_stretch.xml b/viewpager/viewpager/src/androidTest/res/layout-v31/view_pager_with_stretch.xml
new file mode 100644
index 0000000..377370a
--- /dev/null
+++ b/viewpager/viewpager/src/androidTest/res/layout-v31/view_pager_with_stretch.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 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.viewpager.widget.ViewPager
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/pager"
+ android:edgeEffectType="stretch"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"/>
+
diff --git a/viewpager/viewpager/src/androidTest/res/layout/view_pager_with_stretch.xml b/viewpager/viewpager/src/androidTest/res/layout/view_pager_with_stretch.xml
new file mode 100644
index 0000000..15f3e79
--- /dev/null
+++ b/viewpager/viewpager/src/androidTest/res/layout/view_pager_with_stretch.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2015 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.viewpager.widget.ViewPager
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:id="@+id/pager"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"/>
+
diff --git a/viewpager/viewpager/src/main/java/androidx/viewpager/widget/ViewPager.java b/viewpager/viewpager/src/main/java/androidx/viewpager/widget/ViewPager.java
index 76b4a6b..788f4b8 100644
--- a/viewpager/viewpager/src/main/java/androidx/viewpager/widget/ViewPager.java
+++ b/viewpager/viewpager/src/main/java/androidx/viewpager/widget/ViewPager.java
@@ -16,6 +16,9 @@
package androidx.viewpager.widget;
+import static android.widget.EdgeEffect.TYPE_GLOW;
+import static android.widget.EdgeEffect.TYPE_STRETCH;
+
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
@@ -46,15 +49,18 @@
import androidx.annotation.CallSuper;
import androidx.annotation.DrawableRes;
+import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
+import androidx.annotation.RestrictTo;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
+import androidx.core.widget.EdgeEffectCompat;
import androidx.customview.view.AbsSavedState;
import java.lang.annotation.ElementType;
@@ -125,6 +131,13 @@
android.R.attr.layout_gravity
};
+ /** @hide */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
+ @IntDef({TYPE_GLOW, TYPE_STRETCH})
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface EdgeEffectType {
+ }
+
/**
* Used to track what the expected number of items in the adapter should be.
* If the app changes this when we don't expect it, we'll throw a big obnoxious exception.
@@ -391,19 +404,18 @@
public ViewPager(@NonNull Context context) {
super(context);
- initViewPager();
+ initViewPager(context, null);
}
public ViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
- initViewPager();
+ initViewPager(context, attrs);
}
- void initViewPager() {
+ void initViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
setWillNotDraw(false);
setDescendantFocusability(FOCUS_AFTER_DESCENDANTS);
setFocusable(true);
- final Context context = getContext();
mScroller = new Scroller(context, sInterpolator);
final ViewConfiguration configuration = ViewConfiguration.get(context);
final float density = context.getResources().getDisplayMetrics().density;
@@ -411,8 +423,8 @@
mTouchSlop = configuration.getScaledPagingTouchSlop();
mMinimumVelocity = (int) (MIN_FLING_VELOCITY * density);
mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
- mLeftEdge = new EdgeEffect(context);
- mRightEdge = new EdgeEffect(context);
+ mLeftEdge = EdgeEffectCompat.create(context, attrs);
+ mRightEdge = EdgeEffectCompat.create(context, attrs);
mFlingDistance = (int) (MIN_DISTANCE_FOR_FLING * density);
mCloseEnough = (int) (CLOSE_ENOUGH * density);
@@ -477,6 +489,27 @@
});
}
+ /**
+ * Returns the {@link EdgeEffect#getType()} for the edge effects.
+ * @return the {@link EdgeEffect#getType()} for the edge effects.
+ * @attr ref android.R.styleable#EdgeEffect_edgeEffectType
+ */
+ @EdgeEffectType
+ public int getEdgeEffectType() {
+ return EdgeEffectCompat.getType(mLeftEdge);
+ }
+
+ /**
+ * Sets the {@link EdgeEffect#setType(int)} for the edge effects.
+ * @param type The edge effect type to use for the edge effects.
+ * @attr ref android.R.styleable#EdgeEffect_edgeEffectType
+ */
+ public void setEdgeEffectType(@EdgeEffectType int type) {
+ EdgeEffectCompat.setType(mLeftEdge, type);
+ EdgeEffectCompat.setType(mRightEdge, type);
+ invalidate();
+ }
+
@Override
protected void onDetachedFromWindow() {
removeCallbacks(mEndScrollRunnable);
@@ -2107,7 +2140,7 @@
}
if (mIsBeingDragged) {
// Scroll to follow the motion event
- if (performDrag(x)) {
+ if (performDrag(x, y)) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
@@ -2135,6 +2168,18 @@
mIsBeingDragged = true;
requestParentDisallowInterceptTouchEvent(true);
setScrollState(SCROLL_STATE_DRAGGING);
+ } else if (EdgeEffectCompat.getDistance(mLeftEdge) != 0
+ || EdgeEffectCompat.getDistance(mRightEdge) != 0) {
+ // Caught the edge glow animation
+ mIsBeingDragged = true;
+ setScrollState(SCROLL_STATE_DRAGGING);
+ if (EdgeEffectCompat.getDistance(mLeftEdge) != 0) {
+ EdgeEffectCompat.onPullDistance(mLeftEdge, 0f,
+ 1 - mLastMotionY / getHeight());
+ }
+ if (EdgeEffectCompat.getDistance(mRightEdge) != 0) {
+ EdgeEffectCompat.onPullDistance(mRightEdge, 0f, mLastMotionY / getHeight());
+ }
} else {
completeScroll(false);
mIsBeingDragged = false;
@@ -2243,7 +2288,7 @@
// Scroll to follow the motion event
final int activePointerIndex = ev.findPointerIndex(mActivePointerId);
final float x = ev.getX(activePointerIndex);
- needsInvalidate |= performDrag(x);
+ needsInvalidate |= performDrag(x, ev.getY());
}
break;
case MotionEvent.ACTION_UP:
@@ -2310,11 +2355,42 @@
}
}
- private boolean performDrag(float x) {
+ /**
+ * If either of the horizontal edge glows are currently active, this consumes part or all of
+ * deltaX on the edge glow.
+ *
+ * @param deltaX The pointer motion, in pixels, in the horizontal direction, positive
+ * for moving down and negative for moving up.
+ * @param y The vertical position of the pointer.
+ * @return The amount of <code>deltaX</code> that has been consumed by the
+ * edge glow.
+ */
+ private float releaseHorizontalGlow(float deltaX, float y) {
+ // First allow releasing existing overscroll effect:
+ float consumed = 0;
+ float displacement = y / getHeight();
+ float pullDistance = (float) deltaX / getWidth();
+ if (EdgeEffectCompat.getDistance(mLeftEdge) != 0) {
+ consumed = -EdgeEffectCompat.onPullDistance(mLeftEdge, -pullDistance, 1 - displacement);
+ } else if (EdgeEffectCompat.getDistance(mRightEdge) != 0) {
+ consumed = EdgeEffectCompat.onPullDistance(mRightEdge, pullDistance, displacement);
+ }
+ return consumed * getWidth();
+ }
+
+ private boolean performDrag(float x, float y) {
boolean needsInvalidate = false;
- final float deltaX = mLastMotionX - x;
+ final float dX = mLastMotionX - x;
mLastMotionX = x;
+ final float releaseConsumed = releaseHorizontalGlow(dX, y);
+ final float deltaX = dX - releaseConsumed;
+ if (releaseConsumed != 0) {
+ needsInvalidate = true;
+ }
+ if (Math.abs(deltaX) < 0.0001f) { // ignore rounding errors from releaseHorizontalGlow()
+ return needsInvalidate;
+ }
float oldScrollX = getScrollX();
float scrollX = oldScrollX + deltaX;
@@ -2339,14 +2415,14 @@
if (scrollX < leftBound) {
if (leftAbsolute) {
float over = leftBound - scrollX;
- mLeftEdge.onPull(Math.abs(over) / width);
+ EdgeEffectCompat.onPullDistance(mLeftEdge, over / width, 1 - y / getHeight());
needsInvalidate = true;
}
scrollX = leftBound;
} else if (scrollX > rightBound) {
if (rightAbsolute) {
float over = scrollX - rightBound;
- mRightEdge.onPull(Math.abs(over) / width);
+ EdgeEffectCompat.onPullDistance(mRightEdge, over / width, y / getHeight());
needsInvalidate = true;
}
scrollX = rightBound;
@@ -2407,7 +2483,9 @@
private int determineTargetPage(int currentPage, float pageOffset, int velocity, int deltaX) {
int targetPage;
- if (Math.abs(deltaX) > mFlingDistance && Math.abs(velocity) > mMinimumVelocity) {
+ if (Math.abs(deltaX) > mFlingDistance && Math.abs(velocity) > mMinimumVelocity
+ && EdgeEffectCompat.getDistance(mLeftEdge) == 0 // don't fling while stretched
+ && EdgeEffectCompat.getDistance(mRightEdge) == 0) {
targetPage = velocity > 0 ? currentPage : currentPage + 1;
} else {
final float truncator = currentPage >= mCurItem ? 0.4f : 0.6f;