diff --git a/liboverscroll/.gitignore b/liboverscroll/.gitignore
new file mode 100755
index 00000000..796b96d1
--- /dev/null
+++ b/liboverscroll/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/liboverscroll/build.gradle b/liboverscroll/build.gradle
new file mode 100755
index 00000000..aad70cc4
--- /dev/null
+++ b/liboverscroll/build.gradle
@@ -0,0 +1,49 @@
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion 29
+ defaultConfig {
+ minSdkVersion 21
+ targetSdkVersion 28
+ versionCode 1
+ versionName "1.0"
+ }
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ }
+ }
+}
+
+dependencies {
+ implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha01'
+
+ testCompile 'junit:junit:4.12'
+ testCompile "org.mockito:mockito-core:1.9.5"
+ testCompile "org.robolectric:robolectric:3.0"
+}
+
+
+// Running from Gradle tab in IDE would create liboverscroll/build/lib/liboverscroll-sources.jar
+task sourcesJar(type: Jar) {
+ from android.sourceSets.main.java.srcDirs
+ classifier = 'sources'
+}
+
+task javadoc(type: Javadoc) {
+ failOnError false
+ source = android.sourceSets.main.java.srcDirs
+ classpath += project.files(android.getBootClasspath().join(File.pathSeparator))
+}
+
+// Running from Gradle tab in IDE would create liboverscroll/build/lib/liboverscroll-javadoc.jar
+task javadocJar(type: Jar, dependsOn: javadoc) {
+ classifier = 'javadoc'
+ from javadoc.destinationDir
+}
+
+artifacts {
+ archives javadocJar
+ archives sourcesJar
+}
diff --git a/liboverscroll/src/main/AndroidManifest.xml b/liboverscroll/src/main/AndroidManifest.xml
new file mode 100755
index 00000000..6d52f958
--- /dev/null
+++ b/liboverscroll/src/main/AndroidManifest.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/HorizontalOverScrollBounceEffectDecorator.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/HorizontalOverScrollBounceEffectDecorator.java
new file mode 100755
index 00000000..76eb2ece
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/HorizontalOverScrollBounceEffectDecorator.java
@@ -0,0 +1,99 @@
+package me.everything.android.ui.overscroll;
+
+import android.view.MotionEvent;
+import android.view.View;
+
+import me.everything.android.ui.overscroll.adapters.IOverScrollDecoratorAdapter;
+
+/**
+ * A concrete implementation of {@link OverScrollBounceEffectDecoratorBase} for a horizontal orientation.
+ *
+ * @author amit
+ */
+public class HorizontalOverScrollBounceEffectDecorator extends OverScrollBounceEffectDecoratorBase {
+
+ protected static class MotionAttributesHorizontal extends MotionAttributes {
+
+ public boolean init(View view, MotionEvent event) {
+
+ // We must have history available to calc the dx. Normally it's there - if it isn't temporarily,
+ // we declare the event 'invalid' and expect it in consequent events.
+ if (event.getHistorySize() == 0) {
+ return false;
+ }
+
+ // Allow for counter-orientation-direction operations (e.g. item swiping) to run fluently.
+ final float dy = event.getY(0) - event.getHistoricalY(0, 0);
+ final float dx = event.getX(0) - event.getHistoricalX(0, 0);
+ if (Math.abs(dx) < Math.abs(dy)) {
+ return false;
+ }
+
+ mAbsOffset = view.getTranslationX();
+ mDeltaOffset = dx;
+ mDir = mDeltaOffset > 0;
+
+ return true;
+ }
+ }
+
+ protected static class AnimationAttributesHorizontal extends AnimationAttributes {
+
+ public AnimationAttributesHorizontal() {
+ mProperty = View.TRANSLATION_X;
+ }
+
+ @Override
+ protected void init(View view) {
+ mAbsOffset = view.getTranslationX();
+ mMaxOffset = view.getWidth();
+ }
+ }
+
+ /**
+ * C'tor, creating the effect with default arguments:
+ *
Touch-drag ratio in 'forward' direction will be set to DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD.
+ *
Touch-drag ratio in 'backwards' direction will be set to DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK.
+ *
Deceleration factor (for the bounce-back effect) will be set to DEFAULT_DECELERATE_FACTOR.
+ *
+ * @param viewAdapter The view's encapsulation.
+ */
+ public HorizontalOverScrollBounceEffectDecorator(IOverScrollDecoratorAdapter viewAdapter) {
+ this(viewAdapter, DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD, DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK, DEFAULT_DECELERATE_FACTOR);
+ }
+
+ /**
+ * C'tor, creating the effect with explicit arguments.
+ * @param viewAdapter The view's encapsulation.
+ * @param touchDragRatioFwd Ratio of touch distance to actual drag distance when in 'forward' direction.
+ * @param touchDragRatioBck Ratio of touch distance to actual drag distance when in 'backward'
+ * direction (opposite to initial one).
+ * @param decelerateFactor Deceleration factor used when decelerating the motion to create the
+ * bounce-back effect.
+ */
+ public HorizontalOverScrollBounceEffectDecorator(IOverScrollDecoratorAdapter viewAdapter,
+ float touchDragRatioFwd, float touchDragRatioBck, float decelerateFactor) {
+ super(viewAdapter, decelerateFactor, touchDragRatioFwd, touchDragRatioBck);
+ }
+
+ @Override
+ protected MotionAttributes createMotionAttributes() {
+ return new MotionAttributesHorizontal();
+ }
+
+ @Override
+ protected AnimationAttributes createAnimationAttributes() {
+ return new AnimationAttributesHorizontal();
+ }
+
+ @Override
+ protected void translateView(View view, float offset) {
+ view.setTranslationX(offset);
+ }
+
+ @Override
+ protected void translateViewAndEvent(View view, float offset, MotionEvent event) {
+ view.setTranslationX(offset);
+ event.offsetLocation(offset - event.getX(0), 0f);
+ }
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollDecor.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollDecor.java
new file mode 100755
index 00000000..fc6f4eef
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollDecor.java
@@ -0,0 +1,33 @@
+package me.everything.android.ui.overscroll;
+
+import android.view.View;
+
+/**
+ * @author amit
+ */
+public interface IOverScrollDecor {
+ View getView();
+
+ void setOverScrollStateListener(IOverScrollStateListener listener);
+ void setOverScrollUpdateListener(IOverScrollUpdateListener listener);
+
+ /**
+ * Get the current decorator's runtime state, i.e. one of the values specified by {@link IOverScrollState}.
+ * @return The state.
+ */
+ int getCurrentState();
+
+ /**
+ * Detach the decorator from its associated view, thus disabling it entirely.
+ *
+ *
It is best to call this only when over-scroll isn't currently in-effect - i.e. verify that
+ * getCurrentState()==IOverScrollState.STATE_IDLE
as a precondition, or otherwise
+ * use a state listener previously installed using
+ * {@link #setOverScrollStateListener(IOverScrollStateListener)}.
+ *
+ * Note: Upon detachment completion, the view in question will return to the default
+ * Android over-scroll configuration (i.e. {@link View.OVER_SCROLL_ALWAYS} mode). This can be
+ * overridden by calling View.setOverScrollMode(mode)
immediately thereafter.
+ */
+ void detach();
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollState.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollState.java
new file mode 100755
index 00000000..0d1e5ce2
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollState.java
@@ -0,0 +1,19 @@
+package me.everything.android.ui.overscroll;
+
+/**
+ * @author amit
+ */
+public interface IOverScrollState {
+
+ /** No over-scroll is in-effect. */
+ int STATE_IDLE = 0;
+
+ /** User is actively touch-dragging, thus enabling over-scroll at the view's start side. */
+ int STATE_DRAG_START_SIDE = 1;
+
+ /** User is actively touch-dragging, thus enabling over-scroll at the view's end side. */
+ int STATE_DRAG_END_SIDE = 2;
+
+ /** User has released their touch, thus throwing the view back into place via bounce-back animation. */
+ int STATE_BOUNCE_BACK = 3;
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollStateListener.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollStateListener.java
new file mode 100755
index 00000000..a6647690
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollStateListener.java
@@ -0,0 +1,25 @@
+package me.everything.android.ui.overscroll;
+
+/**
+ * A callback-listener enabling over-scroll effect clients to be notified of effect state transitions.
+ *
Invoked whenever state is transitioned onto one of {@link IOverScrollState#STATE_IDLE},
+ * {@link IOverScrollState#STATE_DRAG_START_SIDE}, {@link IOverScrollState#STATE_DRAG_END_SIDE}
+ * or {@link IOverScrollState#STATE_BOUNCE_BACK}.
+ *
+ * @author amit
+ *
+ * @see IOverScrollUpdateListener
+ */
+public interface IOverScrollStateListener {
+
+ /**
+ * The invoked callback.
+ *
+ * @param decor The associated over-scroll 'decorator'.
+ * @param oldState The old over-scroll state; ID's specified by {@link IOverScrollState}, e.g.
+ * {@link IOverScrollState#STATE_IDLE}.
+ * @param newState The new over-scroll state; ID's specified by {@link IOverScrollState},
+ * e.g. {@link IOverScrollState#STATE_IDLE}.
+ */
+ void onOverScrollStateChange(IOverScrollDecor decor, int oldState, int newState);
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollUpdateListener.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollUpdateListener.java
new file mode 100755
index 00000000..69d8e5ee
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollUpdateListener.java
@@ -0,0 +1,22 @@
+package me.everything.android.ui.overscroll;
+
+/**
+ * A callback-listener enabling over-scroll effect clients to subscribe to real-time updates
+ * of over-scrolling intensity, provided as the view-translation offset from pre-scroll position.
+ *
+ * @author amit
+ *
+ * @see IOverScrollStateListener
+ */
+public interface IOverScrollUpdateListener {
+
+ /**
+ * The invoked callback.
+ *
+ * @param decor The associated over-scroll 'decorator'.
+ * @param state One of: {@link IOverScrollState#STATE_IDLE}, {@link IOverScrollState#STATE_DRAG_START_SIDE},
+ * {@link IOverScrollState#STATE_DRAG_START_SIDE} or {@link IOverScrollState#STATE_BOUNCE_BACK}.
+ * @param offset The currently visible offset created due to over-scroll.
+ */
+ void onOverScrollUpdate(IOverScrollDecor decor, int state, float offset);
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/ListenerStubs.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/ListenerStubs.java
new file mode 100755
index 00000000..8cb22d4d
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/ListenerStubs.java
@@ -0,0 +1,17 @@
+package me.everything.android.ui.overscroll;
+
+/**
+ * @author amit
+ */
+public interface ListenerStubs {
+
+ class OverScrollStateListenerStub implements IOverScrollStateListener {
+ @Override
+ public void onOverScrollStateChange(IOverScrollDecor decor, int oldState, int newState) { }
+ }
+
+ class OverScrollUpdateListenerStub implements IOverScrollUpdateListener {
+ @Override
+ public void onOverScrollUpdate(IOverScrollDecor decor, int state, float offset) { }
+ }
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollBounceEffectDecoratorBase.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollBounceEffectDecoratorBase.java
new file mode 100755
index 00000000..cd1f5f93
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollBounceEffectDecoratorBase.java
@@ -0,0 +1,483 @@
+package me.everything.android.ui.overscroll;
+
+import android.animation.Animator;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.util.Log;
+import android.util.Property;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.animation.DecelerateInterpolator;
+import android.view.animation.Interpolator;
+
+import me.everything.android.ui.overscroll.adapters.IOverScrollDecoratorAdapter;
+import me.everything.android.ui.overscroll.adapters.RecyclerViewOverScrollDecorAdapter;
+
+import static me.everything.android.ui.overscroll.IOverScrollState.*;
+import static me.everything.android.ui.overscroll.ListenerStubs.*;
+
+/**
+ * A standalone view decorator adding over-scroll with a smooth bounce-back effect to (potentially) any view -
+ * provided that an appropriate {@link IOverScrollDecoratorAdapter} implementation exists / can be written
+ * for that view type (e.g. {@link RecyclerViewOverScrollDecorAdapter}).
+ *
+ * Design-wise, being a standalone class, this decorator powerfully provides the ability to add
+ * the over-scroll effect over any view without adjusting the view's implementation. In essence, this
+ * eliminates the need to repeatedly implement the effect per each view type (list-view,
+ * recycler-view, image-view, etc.). Therefore, using it is highly recommended compared to other
+ * more intrusive solutions.
+ *
+ * Note that this class is abstract, having {@link HorizontalOverScrollBounceEffectDecorator} and
+ * {@link VerticalOverScrollBounceEffectDecorator} providing concrete implementations that are
+ * view-orientation specific.
+ *
+ *
+ * Implementation Notes
+ *
+ * At it's core, the class simply registers itself as a touch-listener over the decorated view and
+ * intercepts touch events as needed.
+ *
+ * Internally, it delegates the over-scrolling calculations onto 3 state-based classes:
+ *
+ * - Idle state - monitors view state and touch events to intercept over-scrolling initiation
+ * (in which case it hands control over to the Over-scrolling state).
+ * - Over-scrolling state - handles motion events to apply the over-scroll effect as users
+ * interact with the view.
+ * - Bounce-back state - runs the bounce-back animation, all-the-while blocking all
+ * touch events till the animation completes (in which case it hands control back to the idle
+ * state).
+ *
+ *
+ *
+ * @author amit
+ *
+ * @see RecyclerViewOverScrollDecorAdapter
+ * @see IOverScrollDecoratorAdapter
+ */
+public abstract class OverScrollBounceEffectDecoratorBase implements IOverScrollDecor, View.OnTouchListener {
+
+ public static final String TAG = "OverScrollDecor";
+
+ public static final float DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD = 3f;
+ public static final float DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK = 1f;
+ public static final float DEFAULT_DECELERATE_FACTOR = -2f;
+
+ protected static final int MAX_BOUNCE_BACK_DURATION_MS = 800;
+ protected static final int MIN_BOUNCE_BACK_DURATION_MS = 200;
+
+ protected final OverScrollStartAttributes mStartAttr = new OverScrollStartAttributes();
+ protected final IOverScrollDecoratorAdapter mViewAdapter;
+
+ protected final IdleState mIdleState;
+ protected final OverScrollingState mOverScrollingState;
+ protected final BounceBackState mBounceBackState;
+ protected IDecoratorState mCurrentState;
+
+ protected IOverScrollStateListener mStateListener = new OverScrollStateListenerStub();
+ protected IOverScrollUpdateListener mUpdateListener = new OverScrollUpdateListenerStub();
+
+ /**
+ * When in over-scroll mode, keep track of dragging velocity to provide a smooth slow-down
+ * for the bounce-back effect.
+ */
+ protected float mVelocity;
+
+ /**
+ * Motion attributes: keeps data describing current motion event.
+ *
Orientation agnostic: subclasses provide either horizontal or vertical
+ * initialization of the agnostic attributes.
+ */
+ protected abstract static class MotionAttributes {
+ public float mAbsOffset;
+ public float mDeltaOffset;
+ public boolean mDir; // True = 'forward', false = 'backwards'.
+
+ protected abstract boolean init(View view, MotionEvent event);
+ }
+
+ protected static class OverScrollStartAttributes {
+ protected int mPointerId;
+ protected float mAbsOffset;
+ protected boolean mDir; // True = 'forward', false = 'backwards'.
+ }
+
+ protected abstract static class AnimationAttributes {
+ public Property mProperty;
+ public float mAbsOffset;
+ public float mMaxOffset;
+
+ protected abstract void init(View view);
+ }
+
+ /**
+ * Interface of decorator-state delegation classes. Defines states as handles of two fundamental
+ * touch events: actual movement, up/cancel.
+ */
+ protected interface IDecoratorState {
+
+ /**
+ * Handle a motion (touch) event.
+ *
+ * @param event The event from onTouch.
+ * @return Return value for onTouch.
+ */
+ boolean handleMoveTouchEvent(MotionEvent event);
+
+ /**
+ * Handle up / touch-cancel events.
+ *
+ * @param event The event from onTouch.
+ * @return Return value for onTouch.
+ */
+ boolean handleUpOrCancelTouchEvent(MotionEvent event);
+
+ /**
+ * Handle a transition onto this state, as it becomes 'current' state.
+ * @param fromState
+ */
+ void handleEntryTransition(IDecoratorState fromState);
+
+ /**
+ * The client-perspective ID of the state associated with this (internal) one. ID's
+ * are as specified in {@link IOverScrollState}.
+ *
+ * @return The ID, e.g. {@link IOverScrollState#STATE_IDLE}.
+ */
+ int getStateId();
+ }
+
+ /**
+ * Idle state: monitors move events, trying to figure out whether over-scrolling should be
+ * initiated (i.e. when scrolled further when the view is at one of its displayable ends).
+ *
When such is the case, it hands over control to the over-scrolling state.
+ */
+ protected class IdleState implements IDecoratorState {
+
+ final MotionAttributes mMoveAttr;
+
+ public IdleState() {
+ mMoveAttr = createMotionAttributes();
+ }
+
+ @Override
+ public int getStateId() {
+ return STATE_IDLE;
+ }
+
+ @Override
+ public boolean handleMoveTouchEvent(MotionEvent event) {
+
+ final View view = mViewAdapter.getView();
+ if (!mMoveAttr.init(view, event)) {
+ return false;
+ }
+
+ // Has over-scrolling officially started?
+ if ((mViewAdapter.isInAbsoluteStart() && mMoveAttr.mDir) ||
+ (mViewAdapter.isInAbsoluteEnd() && !mMoveAttr.mDir)) {
+
+ // Save initial over-scroll attributes for future reference.
+ mStartAttr.mPointerId = event.getPointerId(0);
+ mStartAttr.mAbsOffset = mMoveAttr.mAbsOffset;
+ mStartAttr.mDir = mMoveAttr.mDir;
+
+ issueStateTransition(mOverScrollingState);
+ return mOverScrollingState.handleMoveTouchEvent(event);
+ }
+
+ return false;
+ }
+
+ @Override
+ public boolean handleUpOrCancelTouchEvent(MotionEvent event) {
+ return false;
+ }
+
+ @Override
+ public void handleEntryTransition(IDecoratorState fromState) {
+ mStateListener.onOverScrollStateChange(OverScrollBounceEffectDecoratorBase.this, fromState.getStateId(), this.getStateId());
+ }
+ }
+
+ /**
+ * Handles the actual over-scrolling: thus translating the view according to configuration
+ * and user interactions, dynamically.
+ *
+ *
The state is exited - thus completing over-scroll handling, in one of two cases:
+ *
When user lets go of the view, it transitions control to the bounce-back state.
+ *
When user moves the view back onto a potential 'under-scroll' state, it abruptly
+ * transitions control to the idle-state, so as to return touch-events management to the
+ * normal over-scroll-less environment (thus preventing under-scrolling and potentially regaining
+ * regular scrolling).
+ */
+ protected class OverScrollingState implements IDecoratorState {
+
+ protected final float mTouchDragRatioFwd;
+ protected final float mTouchDragRatioBck;
+
+ final MotionAttributes mMoveAttr;
+ int mCurrDragState;
+
+ public OverScrollingState(float touchDragRatioFwd, float touchDragRatioBck) {
+ mMoveAttr = createMotionAttributes();
+ mTouchDragRatioFwd = touchDragRatioFwd;
+ mTouchDragRatioBck = touchDragRatioBck;
+ }
+
+ @Override
+ public int getStateId() {
+ // This is really a single class that implements 2 states, so our ID depends on what
+ // it was during the last invocation.
+ return mCurrDragState;
+ }
+
+ @Override
+ public boolean handleMoveTouchEvent(MotionEvent event) {
+
+ // Switching 'pointers' (e.g. fingers) on-the-fly isn't supported -- abort over-scroll
+ // smoothly using the default bounce-back animation in this case.
+ if (mStartAttr.mPointerId != event.getPointerId(0)) {
+ issueStateTransition(mBounceBackState);
+ return true;
+ }
+
+ final View view = mViewAdapter.getView();
+ if (!mMoveAttr.init(view, event)) {
+ // Keep intercepting the touch event as long as we're still over-scrolling...
+ return true;
+ }
+
+ float deltaOffset = mMoveAttr.mDeltaOffset / (mMoveAttr.mDir == mStartAttr.mDir ? mTouchDragRatioFwd : mTouchDragRatioBck);
+ float newOffset = mMoveAttr.mAbsOffset + deltaOffset;
+
+ // If moved in counter direction onto a potential under-scroll state -- don't. Instead, abort
+ // over-scrolling abruptly, thus returning control to which-ever touch handlers there
+ // are waiting (e.g. regular scroller handlers).
+ if ( (mStartAttr.mDir && !mMoveAttr.mDir && (newOffset <= mStartAttr.mAbsOffset)) ||
+ (!mStartAttr.mDir && mMoveAttr.mDir && (newOffset >= mStartAttr.mAbsOffset)) ) {
+ translateViewAndEvent(view, mStartAttr.mAbsOffset, event);
+ mUpdateListener.onOverScrollUpdate(OverScrollBounceEffectDecoratorBase.this, mCurrDragState, 0);
+
+ issueStateTransition(mIdleState);
+ return true;
+ }
+
+ if (view.getParent() != null) {
+ view.getParent().requestDisallowInterceptTouchEvent(true);
+ }
+
+ long dt = event.getEventTime() - event.getHistoricalEventTime(0);
+ if (dt > 0) { // Sometimes (though rarely) dt==0 cause originally timing is in nanos, but is presented in millis.
+ mVelocity = deltaOffset / dt;
+ }
+
+ translateView(view, newOffset);
+ mUpdateListener.onOverScrollUpdate(OverScrollBounceEffectDecoratorBase.this, mCurrDragState, newOffset);
+
+ return true;
+ }
+
+ @Override
+ public boolean handleUpOrCancelTouchEvent(MotionEvent event) {
+ issueStateTransition(mBounceBackState);
+ return false;
+ }
+
+ @Override
+ public void handleEntryTransition(IDecoratorState fromState) {
+ mCurrDragState = (mStartAttr.mDir ? STATE_DRAG_START_SIDE : STATE_DRAG_END_SIDE);
+ mStateListener.onOverScrollStateChange(OverScrollBounceEffectDecoratorBase.this, fromState.getStateId(), this.getStateId());
+ }
+ }
+
+ /**
+ * When entered, starts the bounce-back animation.
+ *
Upon animation completion, transitions control onto the idle state; Does so by
+ * registering itself as an animation listener.
+ *
In the meantime, blocks (intercepts) all touch events.
+ */
+ protected class BounceBackState implements IDecoratorState, Animator.AnimatorListener, ValueAnimator.AnimatorUpdateListener {
+
+ protected final Interpolator mBounceBackInterpolator = new DecelerateInterpolator();
+ protected final float mDecelerateFactor;
+ protected final float mDoubleDecelerateFactor;
+
+ protected final AnimationAttributes mAnimAttributes;
+
+ public BounceBackState(float decelerateFactor) {
+ mDecelerateFactor = decelerateFactor;
+ mDoubleDecelerateFactor = 2f * decelerateFactor;
+
+ mAnimAttributes = createAnimationAttributes();
+ }
+
+ @Override
+ public int getStateId() {
+ return STATE_BOUNCE_BACK;
+ }
+
+ @Override
+ public void handleEntryTransition(IDecoratorState fromState) {
+
+ mStateListener.onOverScrollStateChange(OverScrollBounceEffectDecoratorBase.this, fromState.getStateId(), this.getStateId());
+
+ Animator bounceBackAnim = createAnimator();
+ bounceBackAnim.addListener(this);
+
+ bounceBackAnim.start();
+ }
+
+ @Override
+ public boolean handleMoveTouchEvent(MotionEvent event) {
+ // Flush all touches down the drain till animation is over.
+ return true;
+ }
+
+ @Override
+ public boolean handleUpOrCancelTouchEvent(MotionEvent event) {
+ // Flush all touches down the drain till animation is over.
+ return true;
+ }
+
+ @Override
+ public void onAnimationEnd(Animator animation) {
+ issueStateTransition(mIdleState);
+ }
+
+ @Override
+ public void onAnimationUpdate(ValueAnimator animation) {
+ mUpdateListener.onOverScrollUpdate(OverScrollBounceEffectDecoratorBase.this, STATE_BOUNCE_BACK, (Float) animation.getAnimatedValue());
+ }
+
+ @Override public void onAnimationStart(Animator animation) {}
+ @Override public void onAnimationCancel(Animator animation) {}
+ @Override public void onAnimationRepeat(Animator animation) {}
+
+ protected Animator createAnimator() {
+
+ final View view = mViewAdapter.getView();
+
+ mAnimAttributes.init(view);
+
+ // Set up a low-duration slow-down animation IN the drag direction.
+
+ // Exception: If wasn't dragging in 'forward' direction (or velocity=0 -- i.e. not dragging at all),
+ // skip slow-down anim directly to the bounce-back.
+ if (mVelocity == 0f || (mVelocity < 0 && mStartAttr.mDir) || (mVelocity > 0 && !mStartAttr.mDir)) {
+ return createBounceBackAnimator(mAnimAttributes.mAbsOffset);
+ }
+
+ // dt = (Vt - Vo) / a; Vt=0 ==> dt = -Vo / a
+ float slowdownDuration = -mVelocity / mDecelerateFactor;
+ slowdownDuration = (slowdownDuration < 0 ? 0 : slowdownDuration); // Happens in counter-direction dragging
+
+ // dx = (Vt^2 - Vo^2) / 2a; Vt=0 ==> dx = -Vo^2 / 2a
+ float slowdownDistance = -mVelocity * mVelocity / mDoubleDecelerateFactor;
+ float slowdownEndOffset = mAnimAttributes.mAbsOffset + slowdownDistance;
+
+ ObjectAnimator slowdownAnim = createSlowdownAnimator(view, (int) slowdownDuration, slowdownEndOffset);
+
+ // Set up the bounce back animation, bringing the view back into the original, pre-overscroll position (translation=0).
+
+ ObjectAnimator bounceBackAnim = createBounceBackAnimator(slowdownEndOffset);
+
+ // Play the 2 animations as a sequence.
+ AnimatorSet wholeAnim = new AnimatorSet();
+ wholeAnim.playSequentially(slowdownAnim, bounceBackAnim);
+ return wholeAnim;
+ }
+
+ protected ObjectAnimator createSlowdownAnimator(View view, int slowdownDuration, float slowdownEndOffset) {
+ ObjectAnimator slowdownAnim = ObjectAnimator.ofFloat(view, mAnimAttributes.mProperty, slowdownEndOffset);
+ slowdownAnim.setDuration(slowdownDuration);
+ slowdownAnim.setInterpolator(mBounceBackInterpolator);
+ slowdownAnim.addUpdateListener(this);
+ return slowdownAnim;
+ }
+
+ protected ObjectAnimator createBounceBackAnimator(float startOffset) {
+
+ final View view = mViewAdapter.getView();
+
+ // Duration is proportional to the view's size.
+ float bounceBackDuration = (Math.abs(startOffset) / mAnimAttributes.mMaxOffset) * MAX_BOUNCE_BACK_DURATION_MS;
+ ObjectAnimator bounceBackAnim = ObjectAnimator.ofFloat(view, mAnimAttributes.mProperty, mStartAttr.mAbsOffset);
+ bounceBackAnim.setDuration(Math.max((int) bounceBackDuration, MIN_BOUNCE_BACK_DURATION_MS));
+ bounceBackAnim.setInterpolator(mBounceBackInterpolator);
+ bounceBackAnim.addUpdateListener(this);
+ return bounceBackAnim;
+ }
+ }
+
+ public OverScrollBounceEffectDecoratorBase(IOverScrollDecoratorAdapter viewAdapter, float decelerateFactor, float touchDragRatioFwd, float touchDragRatioBck) {
+ mViewAdapter = viewAdapter;
+
+ mBounceBackState = new BounceBackState(decelerateFactor);
+ mOverScrollingState = new OverScrollingState(touchDragRatioFwd, touchDragRatioBck);
+ mIdleState = new IdleState();
+
+ mCurrentState = mIdleState;
+
+ attach();
+ }
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_MOVE:
+ return mCurrentState.handleMoveTouchEvent(event);
+
+ case MotionEvent.ACTION_CANCEL:
+ case MotionEvent.ACTION_UP:
+ return mCurrentState.handleUpOrCancelTouchEvent(event);
+ }
+
+ return false;
+ }
+
+ @Override
+ public void setOverScrollStateListener(IOverScrollStateListener listener) {
+ mStateListener = (listener != null ? listener : new OverScrollStateListenerStub());
+ }
+
+ @Override
+ public void setOverScrollUpdateListener(IOverScrollUpdateListener listener) {
+ mUpdateListener = (listener != null ? listener : new OverScrollUpdateListenerStub());
+ }
+
+ @Override
+ public int getCurrentState() {
+ return mCurrentState.getStateId();
+ }
+
+ @Override
+ public View getView() {
+ return mViewAdapter.getView();
+ }
+
+ protected void issueStateTransition(IDecoratorState state) {
+ IDecoratorState oldState = mCurrentState;
+ mCurrentState = state;
+ mCurrentState.handleEntryTransition(oldState);
+ }
+
+ protected void attach() {
+ getView().setOnTouchListener(this);
+ getView().setOverScrollMode(View.OVER_SCROLL_NEVER);
+ }
+
+ @Override
+ public void detach() {
+ if (mCurrentState != mIdleState) {
+ Log.w(TAG, "Decorator detached while over-scroll is in effect. You might want to add a precondition of that getCurrentState()==STATE_IDLE, first.");
+ }
+ getView().setOnTouchListener(null);
+ getView().setOverScrollMode(View.OVER_SCROLL_ALWAYS);
+ }
+
+ protected abstract MotionAttributes createMotionAttributes();
+ protected abstract AnimationAttributes createAnimationAttributes();
+ protected abstract void translateView(View view, float offset);
+ protected abstract void translateViewAndEvent(View view, float offset, MotionEvent event);
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollDecoratorHelper.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollDecoratorHelper.java
new file mode 100755
index 00000000..89510253
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollDecoratorHelper.java
@@ -0,0 +1,78 @@
+package me.everything.android.ui.overscroll;
+
+import android.view.View;
+import android.widget.ScrollView;
+import androidx.annotation.NonNull;
+import androidx.core.widget.NestedScrollView;
+import androidx.recyclerview.widget.RecyclerView;
+import me.everything.android.ui.overscroll.adapters.NestedScrollViewOverScrollDecorAdapter;
+import me.everything.android.ui.overscroll.adapters.RecyclerViewOverScrollDecorAdapter;
+import me.everything.android.ui.overscroll.adapters.ScrollViewOverScrollDecorAdapter;
+import me.everything.android.ui.overscroll.adapters.StaticOverScrollDecorAdapter;
+
+/**
+ * @author amit
+ */
+public class OverScrollDecoratorHelper {
+
+ public static final int ORIENTATION_VERTICAL = 0;
+
+ public static final int ORIENTATION_HORIZONTAL = 1;
+
+ /**
+ * Set up the over-scroll effect over a specified {@link RecyclerView} view.
+ *
Only recycler-views using native Android layout managers (i.e. {@link LinearLayoutManager},
+ * {@link GridLayoutManager} and {@link StaggeredGridLayoutManager}) are currently supported
+ * by this convenience method.
+ *
+ * @param recyclerView The view.
+ * @param orientation Either {@link #ORIENTATION_HORIZONTAL} or {@link #ORIENTATION_VERTICAL}.
+ * @return The over-scroll effect 'decorator', enabling further effect configuration.
+ */
+ @NonNull
+ public static IOverScrollDecor setUpOverScroll(@NonNull RecyclerView recyclerView, int orientation) {
+ switch (orientation) {
+ case ORIENTATION_HORIZONTAL:
+ return new HorizontalOverScrollBounceEffectDecorator(
+ new RecyclerViewOverScrollDecorAdapter(recyclerView));
+ case ORIENTATION_VERTICAL:
+ return new VerticalOverScrollBounceEffectDecorator(
+ new RecyclerViewOverScrollDecorAdapter(recyclerView));
+ default:
+ throw new IllegalArgumentException("orientation");
+ }
+ }
+
+ @NonNull
+ public static IOverScrollDecor setUpOverScroll(@NonNull ScrollView scrollView) {
+ return new VerticalOverScrollBounceEffectDecorator(new ScrollViewOverScrollDecorAdapter(scrollView));
+ }
+
+ @NonNull
+ public static IOverScrollDecor setUpOverScroll(@NonNull NestedScrollView nestedScrollView) {
+ return new VerticalOverScrollBounceEffectDecorator(
+ new NestedScrollViewOverScrollDecorAdapter(nestedScrollView));
+ }
+
+ /**
+ * Set up the over-scroll over a generic view, assumed to always be over-scroll ready (e.g.
+ * a plain text field, image view).
+ *
+ * @param view The view.
+ * @param orientation One of {@link #ORIENTATION_HORIZONTAL} or {@link #ORIENTATION_VERTICAL}.
+ * @return The over-scroll effect 'decorator', enabling further effect configuration.
+ */
+ public static IOverScrollDecor setUpStaticOverScroll(View view, int orientation) {
+ switch (orientation) {
+ case ORIENTATION_HORIZONTAL:
+ return new HorizontalOverScrollBounceEffectDecorator(new StaticOverScrollDecorAdapter(view));
+
+ case ORIENTATION_VERTICAL:
+ return new VerticalOverScrollBounceEffectDecorator(new StaticOverScrollDecorAdapter(view));
+
+ default:
+ throw new IllegalArgumentException("orientation");
+ }
+ }
+
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/VerticalOverScrollBounceEffectDecorator.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/VerticalOverScrollBounceEffectDecorator.java
new file mode 100755
index 00000000..bc6ce620
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/VerticalOverScrollBounceEffectDecorator.java
@@ -0,0 +1,100 @@
+package me.everything.android.ui.overscroll;
+
+import android.view.MotionEvent;
+import android.view.View;
+import me.everything.android.ui.overscroll.adapters.IOverScrollDecoratorAdapter;
+
+/**
+ * A concrete implementation of {@link OverScrollBounceEffectDecoratorBase} for a vertical orientation.
+ *
+ * @author amit
+ */
+public class VerticalOverScrollBounceEffectDecorator extends OverScrollBounceEffectDecoratorBase {
+
+ protected static class MotionAttributesVertical extends MotionAttributes {
+
+ public boolean init(View view, MotionEvent event) {
+
+ // We must have history available to calc the dx. Normally it's there - if it isn't temporarily,
+ // we declare the event 'invalid' and expect it in consequent events.
+ if (event.getHistorySize() == 0) {
+ return false;
+ }
+
+ // Allow for counter-orientation-direction operations (e.g. item swiping) to run fluently.
+ final float dy = event.getY(0) - event.getHistoricalY(0, 0);
+ final float dx = event.getX(0) - event.getHistoricalX(0, 0);
+ if (Math.abs(dx) > Math.abs(dy)) {
+ return false;
+ }
+
+ mAbsOffset = view.getTranslationY();
+ mDeltaOffset = dy;
+ mDir = mDeltaOffset > 0;
+
+ return true;
+ }
+ }
+
+ protected static class AnimationAttributesVertical extends AnimationAttributes {
+
+ public AnimationAttributesVertical() {
+ mProperty = View.TRANSLATION_Y;
+ }
+
+ @Override
+ protected void init(View view) {
+ mAbsOffset = view.getTranslationY();
+ mMaxOffset = view.getHeight();
+ }
+ }
+
+ /**
+ * C'tor, creating the effect with default arguments:
+ *
Touch-drag ratio in 'forward' direction will be set to DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD.
+ *
Touch-drag ratio in 'backwards' direction will be set to DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK.
+ *
Deceleration factor (for the bounce-back effect) will be set to DEFAULT_DECELERATE_FACTOR.
+ *
+ * @param viewAdapter The view's encapsulation.
+ */
+ public VerticalOverScrollBounceEffectDecorator(IOverScrollDecoratorAdapter viewAdapter) {
+ this(viewAdapter, DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD, DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK,
+ DEFAULT_DECELERATE_FACTOR);
+ }
+
+ /**
+ * C'tor, creating the effect with explicit arguments.
+ *
+ * @param viewAdapter The view's encapsulation.
+ * @param touchDragRatioFwd Ratio of touch distance to actual drag distance when in 'forward' direction.
+ * @param touchDragRatioBck Ratio of touch distance to actual drag distance when in 'backward'
+ * direction (opposite to initial one).
+ * @param decelerateFactor Deceleration factor used when decelerating the motion to create the
+ * bounce-back effect.
+ */
+ public VerticalOverScrollBounceEffectDecorator(IOverScrollDecoratorAdapter viewAdapter,
+ float touchDragRatioFwd, float touchDragRatioBck, float decelerateFactor) {
+ super(viewAdapter, decelerateFactor, touchDragRatioFwd, touchDragRatioBck);
+ }
+
+ @Override
+ protected AnimationAttributes createAnimationAttributes() {
+ return new AnimationAttributesVertical();
+ }
+
+ @Override
+ protected MotionAttributes createMotionAttributes() {
+ return new MotionAttributesVertical();
+ }
+
+ @Override
+ protected void translateView(View view, float offset) {
+ view.setTranslationY(offset);
+ }
+
+ @Override
+ protected void translateViewAndEvent(View view, float offset, MotionEvent event) {
+ view.setTranslationY(offset);
+ event.offsetLocation(offset - event.getY(0), 0f);
+ }
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/AbsListViewOverScrollDecorAdapter.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/AbsListViewOverScrollDecorAdapter.java
new file mode 100755
index 00000000..9437fd71
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/AbsListViewOverScrollDecorAdapter.java
@@ -0,0 +1,57 @@
+package me.everything.android.ui.overscroll.adapters;
+
+import android.view.View;
+import android.widget.AbsListView;
+
+import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
+import me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator;
+
+/**
+ * An adapter to enable over-scrolling over object of {@link AbsListView}, namely {@link
+ * android.widget.ListView} and it's extensions, and {@link android.widget.GridView}.
+ *
+ * @author amit
+ *
+ * @see HorizontalOverScrollBounceEffectDecorator
+ * @see VerticalOverScrollBounceEffectDecorator
+ */
+public class AbsListViewOverScrollDecorAdapter implements IOverScrollDecoratorAdapter {
+
+ protected final AbsListView mView;
+
+ public AbsListViewOverScrollDecorAdapter(AbsListView view) {
+ mView = view;
+ }
+
+ @Override
+ public View getView() {
+ return mView;
+ }
+
+ @Override
+ public boolean isInAbsoluteStart() {
+ return mView.getChildCount() > 0 && !canScrollListUp();
+ }
+
+ @Override
+ public boolean isInAbsoluteEnd() {
+ return mView.getChildCount() > 0 && !canScrollListDown();
+ }
+
+ public boolean canScrollListUp() {
+ // Ported from AbsListView#canScrollList() which isn't compatible to all API levels
+ final int firstTop = mView.getChildAt(0).getTop();
+ final int firstPosition = mView.getFirstVisiblePosition();
+ return firstPosition > 0 || firstTop < mView.getListPaddingTop();
+ }
+
+ public boolean canScrollListDown() {
+ // Ported from AbsListView#canScrollList() which isn't compatible to all API levels
+ final int childCount = mView.getChildCount();
+ final int itemsCount = mView.getCount();
+ final int firstPosition = mView.getFirstVisiblePosition();
+ final int lastPosition = firstPosition + childCount;
+ final int lastBottom = mView.getChildAt(childCount - 1).getBottom();
+ return lastPosition < itemsCount || lastBottom > mView.getHeight() - mView.getListPaddingBottom();
+ }
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/HorizontalScrollViewOverScrollDecorAdapter.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/HorizontalScrollViewOverScrollDecorAdapter.java
new file mode 100755
index 00000000..72c873c5
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/HorizontalScrollViewOverScrollDecorAdapter.java
@@ -0,0 +1,41 @@
+package me.everything.android.ui.overscroll.adapters;
+
+import android.view.View;
+import android.widget.HorizontalScrollView;
+
+import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
+import me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator;
+
+/**
+ * An adapter that enables over-scrolling support over a {@link HorizontalScrollView}.
+ *
Seeing that {@link HorizontalScrollView} only supports horizontal scrolling, this adapter
+ * should only be used with a {@link HorizontalOverScrollBounceEffectDecorator}.
+ *
+ * @author amit
+ *
+ * @see HorizontalOverScrollBounceEffectDecorator
+ * @see VerticalOverScrollBounceEffectDecorator
+ */
+public class HorizontalScrollViewOverScrollDecorAdapter implements IOverScrollDecoratorAdapter {
+
+ protected final HorizontalScrollView mView;
+
+ public HorizontalScrollViewOverScrollDecorAdapter(HorizontalScrollView view) {
+ mView = view;
+ }
+
+ @Override
+ public View getView() {
+ return mView;
+ }
+
+ @Override
+ public boolean isInAbsoluteStart() {
+ return !mView.canScrollHorizontally(-1);
+ }
+
+ @Override
+ public boolean isInAbsoluteEnd() {
+ return !mView.canScrollHorizontally(1);
+ }
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/IOverScrollDecoratorAdapter.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/IOverScrollDecoratorAdapter.java
new file mode 100755
index 00000000..212156cb
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/IOverScrollDecoratorAdapter.java
@@ -0,0 +1,33 @@
+package me.everything.android.ui.overscroll.adapters;
+
+import android.view.View;
+
+import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
+
+/**
+ * @author amitd
+ *
+ * @see HorizontalOverScrollBounceEffectDecorator
+ */
+public interface IOverScrollDecoratorAdapter {
+
+ View getView();
+
+ /**
+ * Is view in it's absolute start position - such that a negative over-scroll can potentially
+ * be initiated. For example, in list-views, this is synonymous with the first item being
+ * fully visible.
+ *
+ * @return Whether in absolute start position.
+ */
+ boolean isInAbsoluteStart();
+
+ /**
+ * Is view in it's absolute end position - such that an over-scroll can potentially
+ * be initiated. For example, in list-views, this is synonymous with the last item being
+ * fully visible.
+ *
+ * @return Whether in absolute end position.
+ */
+ boolean isInAbsoluteEnd();
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/RecyclerViewOverScrollDecorAdapter.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/RecyclerViewOverScrollDecorAdapter.java
new file mode 100755
index 00000000..bd1f38b3
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/RecyclerViewOverScrollDecorAdapter.java
@@ -0,0 +1,237 @@
+package me.everything.android.ui.overscroll.adapters;
+
+import android.graphics.Canvas;
+import android.view.View;
+import androidx.recyclerview.widget.ItemTouchHelper;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.recyclerview.widget.StaggeredGridLayoutManager;
+import java.util.List;
+import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
+import me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator;
+
+/**
+ * @author amitd
+ * @see HorizontalOverScrollBounceEffectDecorator
+ * @see VerticalOverScrollBounceEffectDecorator
+ */
+public class RecyclerViewOverScrollDecorAdapter implements IOverScrollDecoratorAdapter {
+
+ private static class ItemTouchHelperCallbackWrapper extends ItemTouchHelper.Callback {
+
+ final ItemTouchHelper.Callback mCallback;
+
+ private ItemTouchHelperCallbackWrapper(ItemTouchHelper.Callback callback) {
+ mCallback = callback;
+ }
+
+ @Override
+ public boolean canDropOver(RecyclerView recyclerView, RecyclerView.ViewHolder current,
+ RecyclerView.ViewHolder target) {
+ return mCallback.canDropOver(recyclerView, current, target);
+ }
+
+ @Override
+ public RecyclerView.ViewHolder chooseDropTarget(RecyclerView.ViewHolder selected,
+ List dropTargets, int curX, int curY) {
+ return mCallback.chooseDropTarget(selected, dropTargets, curX, curY);
+ }
+
+ @Override
+ public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
+ mCallback.clearView(recyclerView, viewHolder);
+ }
+
+ @Override
+ public int convertToAbsoluteDirection(int flags, int layoutDirection) {
+ return mCallback.convertToAbsoluteDirection(flags, layoutDirection);
+ }
+
+ @Override
+ public long getAnimationDuration(RecyclerView recyclerView, int animationType, float animateDx,
+ float animateDy) {
+ return mCallback.getAnimationDuration(recyclerView, animationType, animateDx, animateDy);
+ }
+
+ @Override
+ public int getBoundingBoxMargin() {
+ return mCallback.getBoundingBoxMargin();
+ }
+
+ @Override
+ public float getMoveThreshold(RecyclerView.ViewHolder viewHolder) {
+ return mCallback.getMoveThreshold(viewHolder);
+ }
+
+ @Override
+ public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
+ return mCallback.getMovementFlags(recyclerView, viewHolder);
+ }
+
+ @Override
+ public float getSwipeThreshold(RecyclerView.ViewHolder viewHolder) {
+ return mCallback.getSwipeThreshold(viewHolder);
+ }
+
+ @Override
+ public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize, int viewSizeOutOfBounds,
+ int totalSize, long msSinceStartScroll) {
+ return mCallback.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize,
+ msSinceStartScroll);
+ }
+
+ @Override
+ public boolean isItemViewSwipeEnabled() {
+ return mCallback.isItemViewSwipeEnabled();
+ }
+
+ @Override
+ public boolean isLongPressDragEnabled() {
+ return mCallback.isLongPressDragEnabled();
+ }
+
+ @Override
+ public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX,
+ float dY, int actionState, boolean isCurrentlyActive) {
+ mCallback.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
+ }
+
+ @Override
+ public void onChildDrawOver(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX,
+ float dY, int actionState, boolean isCurrentlyActive) {
+ mCallback.onChildDrawOver(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
+ }
+
+ @Override
+ public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder,
+ RecyclerView.ViewHolder target) {
+ return mCallback.onMove(recyclerView, viewHolder, target);
+ }
+
+ @Override
+ public void onMoved(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, int fromPos,
+ RecyclerView.ViewHolder target, int toPos, int x, int y) {
+ mCallback.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y);
+ }
+
+ @Override
+ public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
+ mCallback.onSelectedChanged(viewHolder, actionState);
+ }
+
+ @Override
+ public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
+ mCallback.onSwiped(viewHolder, direction);
+ }
+ }
+
+ protected class ImplHorizLayout implements Impl {
+
+ @Override
+ public boolean isInAbsoluteEnd() {
+ return !mRecyclerView.canScrollHorizontally(1);
+ }
+
+ @Override
+ public boolean isInAbsoluteStart() {
+ return !mRecyclerView.canScrollHorizontally(-1);
+ }
+ }
+
+ protected class ImplVerticalLayout implements Impl {
+
+ @Override
+ public boolean isInAbsoluteEnd() {
+ return !mRecyclerView.canScrollVertically(1);
+ }
+
+ @Override
+ public boolean isInAbsoluteStart() {
+ return !mRecyclerView.canScrollVertically(-1);
+ }
+ }
+
+ /**
+ * A delegation of the adapter implementation of this view that should provide the processing
+ * of {@link #isInAbsoluteStart()} and {@link #isInAbsoluteEnd()}. Essentially needed simply
+ * because the implementation depends on the layout manager implementation being used.
+ */
+ protected interface Impl {
+
+ boolean isInAbsoluteEnd();
+
+ boolean isInAbsoluteStart();
+ }
+
+ protected final Impl mImpl;
+
+ protected boolean mIsItemTouchInEffect = false;
+
+ protected final RecyclerView mRecyclerView;
+
+ public RecyclerViewOverScrollDecorAdapter(RecyclerView recyclerView) {
+
+ mRecyclerView = recyclerView;
+
+ final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
+ if (layoutManager instanceof LinearLayoutManager ||
+ layoutManager instanceof StaggeredGridLayoutManager) {
+ final int orientation =
+ (layoutManager instanceof LinearLayoutManager
+ ? ((LinearLayoutManager) layoutManager).getOrientation()
+ : ((StaggeredGridLayoutManager) layoutManager).getOrientation());
+
+ if (orientation == LinearLayoutManager.HORIZONTAL) {
+ mImpl = new ImplHorizLayout();
+ } else {
+ mImpl = new ImplVerticalLayout();
+ }
+ } else {
+ throw new IllegalArgumentException(
+ "Recycler views with custom layout managers are not supported by this adapter out of the box." +
+ "Try implementing and providing an explicit 'impl' parameter to the other c'tors, or otherwise create a custom adapter subclass of your own.");
+ }
+ }
+
+ public RecyclerViewOverScrollDecorAdapter(RecyclerView recyclerView, Impl impl) {
+ mRecyclerView = recyclerView;
+ mImpl = impl;
+ }
+
+ public RecyclerViewOverScrollDecorAdapter(RecyclerView recyclerView,
+ ItemTouchHelper.Callback itemTouchHelperCallback) {
+ this(recyclerView);
+ setUpTouchHelperCallback(itemTouchHelperCallback);
+ }
+
+ public RecyclerViewOverScrollDecorAdapter(RecyclerView recyclerView, Impl impl,
+ ItemTouchHelper.Callback itemTouchHelperCallback) {
+ this(recyclerView, impl);
+ setUpTouchHelperCallback(itemTouchHelperCallback);
+ }
+
+ @Override
+ public View getView() {
+ return mRecyclerView;
+ }
+
+ @Override
+ public boolean isInAbsoluteEnd() {
+ return !mIsItemTouchInEffect && mImpl.isInAbsoluteEnd();
+ }
+
+ @Override
+ public boolean isInAbsoluteStart() {
+ return !mIsItemTouchInEffect && mImpl.isInAbsoluteStart();
+ }
+
+ protected void setUpTouchHelperCallback(final ItemTouchHelper.Callback itemTouchHelperCallback) {
+ new ItemTouchHelper(new ItemTouchHelperCallbackWrapper(itemTouchHelperCallback) {
+ @Override
+ public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
+ mIsItemTouchInEffect = actionState != 0;
+ super.onSelectedChanged(viewHolder, actionState);
+ }
+ }).attachToRecyclerView(mRecyclerView);
+ }
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/ScrollViewOverScrollDecorAdapter.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/ScrollViewOverScrollDecorAdapter.java
new file mode 100755
index 00000000..2c4c79cb
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/ScrollViewOverScrollDecorAdapter.java
@@ -0,0 +1,43 @@
+package me.everything.android.ui.overscroll.adapters;
+
+import android.view.View;
+import android.widget.ScrollView;
+
+import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
+import me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator;
+
+/**
+ * An adapter that enables over-scrolling over a {@link ScrollView}.
+ *
Seeing that {@link ScrollView} only supports vertical scrolling, this adapter
+ * should only be used with a {@link VerticalOverScrollBounceEffectDecorator}. For horizontal
+ * over-scrolling, use {@link HorizontalScrollViewOverScrollDecorAdapter} in conjunction with
+ * a {@link android.widget.HorizontalScrollView}.
+ *
+ * @author amit
+ *
+ * @see HorizontalOverScrollBounceEffectDecorator
+ * @see VerticalOverScrollBounceEffectDecorator
+ */
+public class ScrollViewOverScrollDecorAdapter implements IOverScrollDecoratorAdapter {
+
+ protected final ScrollView mView;
+
+ public ScrollViewOverScrollDecorAdapter(ScrollView view) {
+ mView = view;
+ }
+
+ @Override
+ public View getView() {
+ return mView;
+ }
+
+ @Override
+ public boolean isInAbsoluteStart() {
+ return !mView.canScrollVertically(-1);
+ }
+
+ @Override
+ public boolean isInAbsoluteEnd() {
+ return !mView.canScrollVertically(1);
+ }
+}
diff --git a/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/StaticOverScrollDecorAdapter.java b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/StaticOverScrollDecorAdapter.java
new file mode 100755
index 00000000..95395f82
--- /dev/null
+++ b/liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/StaticOverScrollDecorAdapter.java
@@ -0,0 +1,38 @@
+package me.everything.android.ui.overscroll.adapters;
+
+import android.view.View;
+
+import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
+import me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator;
+
+/**
+ * A static adapter for views that are ALWAYS over-scroll-able (e.g. image view).
+ *
+ * @author amit
+ *
+ * @see HorizontalOverScrollBounceEffectDecorator
+ * @see VerticalOverScrollBounceEffectDecorator
+ */
+public class StaticOverScrollDecorAdapter implements IOverScrollDecoratorAdapter {
+
+ protected final View mView;
+
+ public StaticOverScrollDecorAdapter(View view) {
+ mView = view;
+ }
+
+ @Override
+ public View getView() {
+ return mView;
+ }
+
+ @Override
+ public boolean isInAbsoluteStart() {
+ return true;
+ }
+
+ @Override
+ public boolean isInAbsoluteEnd() {
+ return true;
+ }
+}
diff --git a/liboverscroll/src/test/java/me/everything/android/ui/overscroll/HorizontalOverScrollBounceEffectDecoratorTest.java b/liboverscroll/src/test/java/me/everything/android/ui/overscroll/HorizontalOverScrollBounceEffectDecoratorTest.java
new file mode 100755
index 00000000..cddd6d21
--- /dev/null
+++ b/liboverscroll/src/test/java/me/everything/android/ui/overscroll/HorizontalOverScrollBounceEffectDecoratorTest.java
@@ -0,0 +1,640 @@
+package me.everything.android.ui.overscroll;
+
+import android.view.MotionEvent;
+import android.view.View;
+
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import me.everything.android.ui.overscroll.adapters.IOverScrollDecoratorAdapter;
+
+import static me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator.DEFAULT_DECELERATE_FACTOR;
+import static me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator.DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+import static me.everything.android.ui.overscroll.IOverScrollState.*;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Matchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * @author amitd
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class HorizontalOverScrollBounceEffectDecoratorTest {
+
+ View mView;
+ IOverScrollDecoratorAdapter mViewAdapter;
+ IOverScrollStateListener mStateListener;
+ IOverScrollUpdateListener mUpdateListener;
+
+ @Before
+ public void setUp() throws Exception {
+ mView = mock(View.class);
+ mViewAdapter = mock(IOverScrollDecoratorAdapter.class);
+ when(mViewAdapter.getView()).thenReturn(mView);
+
+ mStateListener = mock(IOverScrollStateListener.class);
+ mUpdateListener = mock(IOverScrollUpdateListener.class);
+ }
+
+ @Test
+ public void detach_decoratorIsAttached_detachFromView() throws Exception {
+
+ // Arrange
+
+ HorizontalOverScrollBounceEffectDecorator uut = new HorizontalOverScrollBounceEffectDecorator(mViewAdapter);
+
+ // Act
+
+ uut.detach();
+
+ // Assert
+
+ verify(mView).setOnTouchListener(eq((View.OnTouchListener) null));
+ verify(mView).setOverScrollMode(View.OVER_SCROLL_ALWAYS);
+ }
+
+ @Test
+ public void detach_overScrollInEffect_detachFromView() throws Exception {
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+ uut.onTouch(mView, createShortRightMoveEvent());
+
+ // Act
+
+ uut.detach();
+
+ // Assert
+
+ verify(mView).setOnTouchListener(eq((View.OnTouchListener) null));
+ verify(mView).setOverScrollMode(View.OVER_SCROLL_ALWAYS);
+ }
+
+ /*
+ * Move-action event
+ */
+
+ @Test
+ public void onTouchMoveAction_notInViewEnds_ignoreTouchEvent() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortRightMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertFalse(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ verify(mStateListener, never()).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ verify(mUpdateListener, never()).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_dragRightInLeftEnd_overscrollRight() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortRightMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ final boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ final float expectedTransX = (event.getX() - event.getHistoricalX(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ verify(mView).setTranslationX(expectedTransX);
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_START_SIDE, uut.getCurrentState());
+
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_START_SIDE));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(expectedTransX));
+ }
+
+ @Test
+ public void onTouchMoveAction_dragLeftInRightEnd_overscrollLeft() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortLeftMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ final boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ final float expectedTransX = (event.getX() - event.getHistoricalX(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ verify(mView).setTranslationX(expectedTransX);
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_END_SIDE, uut.getCurrentState());
+
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_END_SIDE));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(expectedTransX));
+ }
+
+ @Test
+ public void onTouchMoveAction_dragLeftInLeftEnd_ignoreTouchEvent() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortLeftMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ final boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertFalse(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ verify(mStateListener, never()).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ verify(mUpdateListener, never()).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_dragRightInRightEnd_ignoreTouchEvent() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortRightMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertFalse(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ verify(mStateListener, never()).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ verify(mUpdateListener, never()).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_2ndRightDragInLeftEnd_overscrollRightFurther() throws Exception {
+
+ // Arrange
+
+ // Bring UUT to a right-overscroll state
+ MotionEvent event1 = createShortRightMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+ uut.onTouch(mView, event1);
+ reset(mView);
+
+ // Create 2nd right-drag event
+ MotionEvent event2 = createLongRightMoveEvent();
+
+ // Act
+
+ final boolean ret = uut.onTouch(mView, event2);
+
+ // Assert
+
+ final float expectedTransX1 = (event1.getX() - event1.getHistoricalX(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ final float expectedTransX2 = (event2.getX() - event2.getHistoricalX(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ verify(mView).setTranslationX(expectedTransX2);
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_START_SIDE, uut.getCurrentState());
+
+ // State-change listener called only once?
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_START_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(expectedTransX1));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(expectedTransX2));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_2ndLeftDragInRightEnd_overscrollLeftFurther() throws Exception {
+
+ // Arrange
+
+ // Bring UUT to a left-overscroll state
+ MotionEvent event1 = createShortLeftMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+ uut.onTouch(mView, event1);
+ reset(mView);
+
+ // Create 2nd left-drag event
+ MotionEvent event2 = createLongLeftMoveEvent();
+
+ // Act
+
+ final boolean ret = uut.onTouch(mView, event2);
+
+ // Assert
+
+ final float expectedTransX1 = (event1.getX() - event1.getHistoricalX(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ final float expectedTransX2 = (event2.getX() - event2.getHistoricalX(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ verify(mView).setTranslationX(expectedTransX2);
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_END_SIDE, uut.getCurrentState());
+
+ // State-change listener called only once?
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_END_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(expectedTransX1));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(expectedTransX2));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ /**
+ * When over-scroll has already started (to the right in this case) and suddenly the user changes
+ * their mind and scrolls a bit in the other direction:
+ *
We expect the touch to still be intercepted in that case, and the overscroll to
+ * remain in effect.
+ */
+ @Test
+ public void onTouchMoveAction_dragLeftWhenRightOverscolled_continueOverscrollingLeft() throws Exception {
+
+ // Arrange
+
+ // In left & right tests we use equal ratios to avoid the effect's under-scroll handling
+ final float touchDragRatioFwd = 3f;
+ final float touchDragRatioBck = 3f;
+
+ // Bring UUT to a right-overscroll state
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT(touchDragRatioFwd, touchDragRatioBck);
+ MotionEvent eventMoveRight = createLongRightMoveEvent();
+ uut.onTouch(mView, eventMoveRight);
+ reset(mView);
+ float startTransX = (eventMoveRight.getX() - eventMoveRight.getHistoricalX(0)) / touchDragRatioFwd;
+ when(mView.getTranslationX()).thenReturn(startTransX);
+
+ // Create the left-drag event
+ MotionEvent eventMoveLeft = createShortLeftMoveEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, eventMoveLeft);
+
+ // Assert
+
+ float expectedTransX = startTransX +
+ (eventMoveLeft.getX() - eventMoveLeft.getHistoricalX(0)) / touchDragRatioBck;
+ verify(mView).setTranslationX(expectedTransX);
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_START_SIDE, uut.getCurrentState());
+
+ // State-change listener called only once?
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_START_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(startTransX));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(expectedTransX));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ /**
+ * When over-scroll has already started (to the left in this case) and suddenly the user changes
+ * their mind and scrolls a bit in the other direction:
+ *
We expect the touch to still be intercepted in that case, and the overscroll to remain in effect.
+ */
+ @Test
+ public void onTouchMoveAction_dragRightWhenLeftOverscolled_continueOverscrollingRight() throws Exception {
+
+ // Arrange
+
+ // In left & right tests we use equal ratios to avoid the effect's under-scroll handling
+ final float touchDragRatioFwd = 3f;
+ final float touchDragRatioBck = 3f;
+
+ // Bring UUT to a left-overscroll state
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT(touchDragRatioFwd, touchDragRatioBck);
+ MotionEvent eventMoveLeft = createLongLeftMoveEvent();
+ uut.onTouch(mView, eventMoveLeft);
+ reset(mView);
+
+ float startTransX = (eventMoveLeft.getX() - eventMoveLeft.getHistoricalX(0)) / touchDragRatioFwd;
+ when(mView.getTranslationX()).thenReturn(startTransX);
+
+ // Create the right-drag event
+ MotionEvent eventMoveRight = createShortRightMoveEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, eventMoveRight);
+
+ // Assert
+
+ float expectedTransX = startTransX + (eventMoveRight.getX() - eventMoveRight.getHistoricalX(0)) / touchDragRatioBck;
+ verify(mView).setTranslationX(expectedTransX);
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_END_SIDE, uut.getCurrentState());
+
+ // State-change listener called only once?
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_END_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(startTransX));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(expectedTransX));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_undragWhenRightOverscrolled_endOverscrolling() throws Exception {
+
+ // Arrange
+
+ // In left & right tests we use equal ratios to avoid the effect's under-scroll handling
+ final float touchDragRatioFwd = 3f;
+ final float touchDragRatioBck = 3f;
+
+ // Bring UUT to a right-overscroll state
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT(touchDragRatioFwd, touchDragRatioBck);
+ MotionEvent eventMoveRight = createLongRightMoveEvent();
+ uut.onTouch(mView, eventMoveRight);
+ reset(mView);
+ float startTransX = (eventMoveRight.getX() - eventMoveRight.getHistoricalX(0)) / touchDragRatioFwd;
+ when(mView.getTranslationX()).thenReturn(startTransX);
+
+ // Create the left-drag event
+ MotionEvent eventMoveLeft = createLongLeftMoveEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, eventMoveLeft);
+
+ // Assert
+
+ verify(mView).setTranslationX(0);
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ // State-change listener invoked to say drag-on and drag-off (idle).
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_START_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_DRAG_START_SIDE), eq(STATE_IDLE));
+ verify(mStateListener, times(2)).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(startTransX));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(0f));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_undragWhenLeftOverscrolled_endOverscrolling() throws Exception {
+
+ // Arrange
+
+ // In left & right tests we use equal ratios to avoid the effect's under-scroll handling
+ final float touchDragRatioFwd = 3f;
+ final float touchDragRatioBck = 3f;
+
+ // Bring UUT to a left-overscroll state
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT(touchDragRatioFwd, touchDragRatioBck);
+ MotionEvent eventMoveLeft = createLongLeftMoveEvent();
+ uut.onTouch(mView, eventMoveLeft);
+ reset(mView);
+ float startTransX = (eventMoveLeft.getX() - eventMoveLeft.getHistoricalX(0)) / touchDragRatioFwd;
+ when(mView.getTranslationX()).thenReturn(startTransX);
+
+ // Create the left-drag event
+ MotionEvent eventMoveRight = createLongRightMoveEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, eventMoveRight);
+
+ // Assert
+
+ verify(mView).setTranslationX(0);
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ // State-change listener invoked to say drag-on and drag-off (idle).
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_END_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_DRAG_END_SIDE), eq(STATE_IDLE));
+ verify(mStateListener, times(2)).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(startTransX));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(0f));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+
+ /*
+ * Up action event
+ */
+
+ @Test
+ public void onTouchUpAction_eventWhenNotOverscrolled_ignoreTouchEvent() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createDefaultUpActionEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertFalse(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ verify(mStateListener, never()).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ verify(mUpdateListener, never()).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ /**
+ * TODO: Make this work using a decent animation shadows / newer Robolectric
+ * @throws Exception
+ */
+ @Ignore
+ @Test
+ public void onTouchUpAction_eventWhenLeftOverscrolling_smoothScrollBackToRightEnd() throws Exception {
+
+ // Arrange
+
+ // Bring UUT to a left-overscroll state
+ MotionEvent moveEvent = createShortLeftMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ HorizontalOverScrollBounceEffectDecorator uut = getUUT();
+ uut.onTouch(mView, moveEvent);
+ reset(mView);
+
+ // Make the view as though it's been moved by the move event
+ float viewX = moveEvent.getX();
+ when(mView.getTranslationX()).thenReturn(viewX);
+
+ MotionEvent upEvent = createDefaultUpActionEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, upEvent);
+
+ // Assert
+
+ assertTrue(ret);
+
+ verify(mView, atLeastOnce()).setTranslationX(anyFloat());
+ }
+
+ protected MotionEvent createShortRightMoveEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_MOVE);
+ when(event.getX()).thenReturn(100f);
+ when(event.getY()).thenReturn(200f);
+ when(event.getX(0)).thenReturn(100f);
+ when(event.getY(0)).thenReturn(200f);
+ when(event.getHistorySize()).thenReturn(1);
+ when(event.getHistoricalX(eq(0))).thenReturn(80f);
+ when(event.getHistoricalY(eq(0))).thenReturn(190f);
+ when(event.getHistoricalX(eq(0), eq(0))).thenReturn(80f);
+ when(event.getHistoricalY(eq(0), eq(0))).thenReturn(190f);
+ return event;
+ }
+
+ protected MotionEvent createLongRightMoveEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_MOVE);
+ when(event.getX()).thenReturn(150f);
+ when(event.getY()).thenReturn(250f);
+ when(event.getX(0)).thenReturn(150f);
+ when(event.getY(0)).thenReturn(250f);
+ when(event.getHistorySize()).thenReturn(1);
+ when(event.getHistoricalX(eq(0))).thenReturn(100f);
+ when(event.getHistoricalY(eq(0))).thenReturn(200f);
+ when(event.getHistoricalX(eq(0), eq(0))).thenReturn(100f);
+ when(event.getHistoricalY(eq(0), eq(0))).thenReturn(200f);
+ return event;
+ }
+
+ protected MotionEvent createShortLeftMoveEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_MOVE);
+ when(event.getX()).thenReturn(100f);
+ when(event.getY()).thenReturn(200f);
+ when(event.getX(0)).thenReturn(100f);
+ when(event.getY(0)).thenReturn(200f);
+ when(event.getHistorySize()).thenReturn(1);
+ when(event.getHistoricalX(eq(0))).thenReturn(120f);
+ when(event.getHistoricalY(eq(0))).thenReturn(220f);
+ when(event.getHistoricalX(eq(0), eq(0))).thenReturn(120f);
+ when(event.getHistoricalY(eq(0), eq(0))).thenReturn(220f);
+ return event;
+ }
+
+ protected MotionEvent createLongLeftMoveEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_MOVE);
+ when(event.getX()).thenReturn(50f);
+ when(event.getY()).thenReturn(150f);
+ when(event.getX(0)).thenReturn(50f);
+ when(event.getY(0)).thenReturn(150f);
+ when(event.getHistorySize()).thenReturn(1);
+ when(event.getHistoricalX(eq(0))).thenReturn(100f);
+ when(event.getHistoricalY(eq(0))).thenReturn(200f);
+ when(event.getHistoricalX(eq(0), eq(0))).thenReturn(100f);
+ when(event.getHistoricalY(eq(0), eq(0))).thenReturn(200f);
+ return event;
+ }
+
+ protected MotionEvent createDefaultUpActionEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_UP);
+ return event;
+ }
+
+ protected HorizontalOverScrollBounceEffectDecorator getUUT() {
+ HorizontalOverScrollBounceEffectDecorator uut = new HorizontalOverScrollBounceEffectDecorator(mViewAdapter);
+ uut.setOverScrollStateListener(mStateListener);
+ uut.setOverScrollUpdateListener(mUpdateListener);
+ return uut;
+ }
+
+ protected HorizontalOverScrollBounceEffectDecorator getUUT(float touchDragRatioFwd, float touchDragRatioBck) {
+ HorizontalOverScrollBounceEffectDecorator uut = new HorizontalOverScrollBounceEffectDecorator(mViewAdapter, touchDragRatioFwd, touchDragRatioBck, DEFAULT_DECELERATE_FACTOR);
+ uut.setOverScrollStateListener(mStateListener);
+ uut.setOverScrollUpdateListener(mUpdateListener);
+ return uut;
+ }
+}
diff --git a/liboverscroll/src/test/java/me/everything/android/ui/overscroll/VerticalOverScrollBounceEffectDecoratorTest.java b/liboverscroll/src/test/java/me/everything/android/ui/overscroll/VerticalOverScrollBounceEffectDecoratorTest.java
new file mode 100755
index 00000000..b7c7ff38
--- /dev/null
+++ b/liboverscroll/src/test/java/me/everything/android/ui/overscroll/VerticalOverScrollBounceEffectDecoratorTest.java
@@ -0,0 +1,600 @@
+package me.everything.android.ui.overscroll;
+
+import android.view.MotionEvent;
+import android.view.View;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import me.everything.android.ui.overscroll.adapters.IOverScrollDecoratorAdapter;
+
+import static me.everything.android.ui.overscroll.IOverScrollState.*;
+import static me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator.DEFAULT_DECELERATE_FACTOR;
+import static me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator.DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Matchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * @author amitd
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(manifest = Config.NONE)
+public class VerticalOverScrollBounceEffectDecoratorTest {
+
+ View mView;
+ IOverScrollDecoratorAdapter mViewAdapter;
+ IOverScrollStateListener mStateListener;
+ IOverScrollUpdateListener mUpdateListener;
+
+ @Before
+ public void setUp() throws Exception {
+ mView = mock(View.class);
+ mViewAdapter = mock(IOverScrollDecoratorAdapter.class);
+ when(mViewAdapter.getView()).thenReturn(mView);
+
+ mStateListener = mock(IOverScrollStateListener.class);
+ mUpdateListener = mock(IOverScrollUpdateListener.class);
+ }
+
+ @Test
+ public void detach_decoratorIsAttached_detachFromView() throws Exception {
+
+ // Arrange
+
+ HorizontalOverScrollBounceEffectDecorator uut = new HorizontalOverScrollBounceEffectDecorator(mViewAdapter);
+
+ // Act
+
+ uut.detach();
+
+ // Assert
+
+ verify(mView).setOnTouchListener(eq((View.OnTouchListener) null));
+ verify(mView).setOverScrollMode(View.OVER_SCROLL_ALWAYS);
+ }
+
+ @Test
+ public void detach_overScrollInEffect_detachFromView() throws Exception {
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+ uut.onTouch(mView, createShortDownwardsMoveEvent());
+
+ // Act
+
+ uut.detach();
+
+ // Assert
+
+ verify(mView).setOnTouchListener(eq((View.OnTouchListener) null));
+ verify(mView).setOverScrollMode(View.OVER_SCROLL_ALWAYS);
+ }
+
+ /*
+ * Move-action event
+ */
+
+ @Test
+ public void onTouchMoveAction_notInViewEnds_ignoreTouchEvent() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortDownwardsMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertFalse(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ verify(mStateListener, never()).onOverScrollStateChange(eq(uut),anyInt(), anyInt());
+ verify(mUpdateListener, never()).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_dragDownInUpperEnd_overscrollDownwards() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortDownwardsMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ float expectedTransY = (event.getY() - event.getHistoricalY(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ verify(mView).setTranslationY(expectedTransY);
+ verify(mView, never()).setTranslationX(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_START_SIDE, uut.getCurrentState());
+
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_START_SIDE));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(expectedTransY));
+ }
+
+ @Test
+ public void onTouchMoveAction_dragUpInBottomEnd_overscrollUpwards() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortUpwardsMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ float expectedTransY = (event.getY() - event.getHistoricalY(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ verify(mView).setTranslationY(expectedTransY);
+ verify(mView, never()).setTranslationX(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_END_SIDE, uut.getCurrentState());
+
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_END_SIDE));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(expectedTransY));
+ }
+
+ @Test
+ public void onTouchMoveAction_dragUpInUpperEnd_ignoreTouchEvent() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortUpwardsMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertFalse(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ verify(mStateListener, never()).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ verify(mUpdateListener, never()).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_dragDownInBottomEnd_ignoreTouchEvent() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createShortDownwardsMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertFalse(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ verify(mStateListener, never()).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ verify(mUpdateListener, never()).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_2ndDownDragInUpperEnd_overscrollDownwardsFurther() throws Exception {
+
+ // Arrange
+
+ // Bring UUT to a downwards-overscroll state
+ MotionEvent event1 = createShortDownwardsMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+ uut.onTouch(mView, event1);
+ reset(mView);
+
+ // Create 2nd downwards-drag event
+ MotionEvent event2 = createLongDownwardsMoveEvent();
+
+ // Act
+
+ final boolean ret = uut.onTouch(mView, event2);
+
+ // Assert
+
+ final float expectedTransY1 = (event1.getY() - event1.getHistoricalY(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ final float expectedTransY2 = (event2.getY() - event2.getHistoricalY(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ verify(mView).setTranslationY(expectedTransY2);
+ verify(mView, never()).setTranslationX(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_START_SIDE, uut.getCurrentState());
+
+ // State-change listener called only once?
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_START_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(expectedTransY1));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(expectedTransY2));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_2ndUpDragInBottomEnd_overscrollUpwardsFurther() throws Exception {
+
+ // Arrange
+
+ // Bring UUT to an upwards-overscroll state
+ MotionEvent event1 = createShortUpwardsMoveEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+ uut.onTouch(mView, event1);
+ reset(mView);
+
+ // Create 2nd upward-drag event
+ MotionEvent event2 = createLongUpwardsMoveEvent();
+
+ // Act
+
+ final boolean ret = uut.onTouch(mView, event2);
+
+ // Assert
+
+ final float expectedTransY1 = (event1.getY() - event1.getHistoricalY(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ final float expectedTransY2 = (event2.getY() - event2.getHistoricalY(0)) / DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD;
+ verify(mView).setTranslationY(expectedTransY2);
+ verify(mView, never()).setTranslationX(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_END_SIDE, uut.getCurrentState());
+
+ // State-change listener called only once?
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_END_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(expectedTransY1));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(expectedTransY2));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ /**
+ * When over-scroll has already started (downwards in this case) and suddenly the user changes
+ * their mind and scrolls a bit in the other direction:
+ *
We expect the touch to still be intercepted in that case, and the overscroll to remain in effect.
+ */
+ @Test
+ public void onTouchMoveAction_dragUpWhenDownOverscolled_continueOverscrollingUpwards() throws Exception {
+
+ // Arrange
+
+ // In down & up drag tests we use equal ratios to avoid the effect's under-scroll handling
+ final float touchDragRatioFwd = 3f;
+ final float touchDragRatioBck = 3f;
+
+ // Bring UUT to a downwrads-overscroll state
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT(touchDragRatioFwd, touchDragRatioBck);
+ MotionEvent eventMoveRight = createLongDownwardsMoveEvent();
+ uut.onTouch(mView, eventMoveRight);
+ reset(mView);
+ float startTransY = (eventMoveRight.getY() - eventMoveRight.getHistoricalY(0)) / touchDragRatioFwd;
+ when(mView.getTranslationY()).thenReturn(startTransY);
+
+ // Create the up-drag event
+ MotionEvent eventMoveUpwards = createShortUpwardsMoveEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, eventMoveUpwards);
+
+ // Assert
+
+ float expectedTransY = startTransY +
+ (eventMoveUpwards.getY() - eventMoveUpwards.getHistoricalY(0)) / touchDragRatioBck;
+ verify(mView).setTranslationY(expectedTransY);
+ verify(mView, never()).setTranslationX(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_START_SIDE, uut.getCurrentState());
+
+ // State-change listener called only once?
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_START_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(startTransY));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(expectedTransY));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ /**
+ * When over-scroll has already started (upwards in this case) and suddenly the user changes
+ * their mind and scrolls a bit in the other direction:
+ *
We expect the touch to still be intercepted in that case, and the overscroll to remain in effect.
+ */
+ @Test
+ public void onTouchMoveAction_dragDownWhenUpOverscolled_continueOverscrollingDownwards() throws Exception {
+
+ // Arrange
+
+ // In up & down drag tests we use equal ratios to avoid the effect's under-scroll handling
+ final float touchDragRatioFwd = 3f;
+ final float touchDragRatioBck = 3f;
+
+ // Bring UUT to an upwards-overscroll state
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT(touchDragRatioFwd, touchDragRatioBck);
+ MotionEvent eventMoveUp = createLongUpwardsMoveEvent();
+ uut.onTouch(mView, eventMoveUp);
+ reset(mView);
+
+ float startTransY = (eventMoveUp.getY() - eventMoveUp.getHistoricalY(0)) / touchDragRatioFwd;
+ when(mView.getTranslationY()).thenReturn(startTransY);
+
+ // Create the down-drag event
+ MotionEvent eventMoveDown = createShortDownwardsMoveEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, eventMoveDown);
+
+ // Assert
+
+ float expectedTransY = startTransY + (eventMoveDown.getY() - eventMoveDown.getHistoricalY(0)) / touchDragRatioBck;
+ verify(mView).setTranslationY(expectedTransY);
+ verify(mView, never()).setTranslationX(anyFloat());
+ assertTrue(ret);
+ assertEquals(STATE_DRAG_END_SIDE, uut.getCurrentState());
+
+ // State-change listener called only once?
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_END_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(startTransY));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(expectedTransY));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_undragWhenDownOverscrolled_endOverscrolling() throws Exception {
+
+ // Arrange
+
+ // In left & right tests we use equal ratios to avoid the effect's under-scroll handling
+ final float touchDragRatioFwd = 3f;
+ final float touchDragRatioBck = 3f;
+
+ // Bring UUT to a downwards-overscroll state
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT(touchDragRatioFwd, touchDragRatioBck);
+ MotionEvent eventMoveDown = createLongDownwardsMoveEvent();
+ uut.onTouch(mView, eventMoveDown);
+ reset(mView);
+ float startTransX = (eventMoveDown.getX() - eventMoveDown.getHistoricalX(0)) / touchDragRatioFwd;
+ when(mView.getTranslationX()).thenReturn(startTransX);
+
+ // Create the (negative) upwards-drag event
+ MotionEvent eventMoveUp = createLongUpwardsMoveEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, eventMoveUp);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView).setTranslationY(0);
+ assertTrue(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ // State-change listener invoked to say drag-on and drag-off (idle).
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_START_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_DRAG_START_SIDE), eq(STATE_IDLE));
+ verify(mStateListener, times(2)).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(startTransX));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_START_SIDE), eq(0f));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ @Test
+ public void onTouchMoveAction_undragWhenUpOverscrolled_endOverscrolling() throws Exception {
+
+ // Arrange
+
+ // In left & right tests we use equal ratios to avoid the effect's under-scroll handling
+ final float touchDragRatioFwd = 3f;
+ final float touchDragRatioBck = 3f;
+
+ // Bring UUT to a left-overscroll state
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(false);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT(touchDragRatioFwd, touchDragRatioBck);
+ MotionEvent eventMoveUp = createLongUpwardsMoveEvent();
+ uut.onTouch(mView, eventMoveUp);
+ reset(mView);
+ float startTransX = (eventMoveUp.getX() - eventMoveUp.getHistoricalX(0)) / touchDragRatioFwd;
+ when(mView.getTranslationX()).thenReturn(startTransX);
+
+ // Create the (negative) downwards-drag event
+ MotionEvent eventMoveDown = createLongDownwardsMoveEvent();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, eventMoveDown);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView).setTranslationY(0);
+ assertTrue(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ // State-change listener invoked to say drag-on and drag-off (idle).
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_IDLE), eq(STATE_DRAG_END_SIDE));
+ verify(mStateListener).onOverScrollStateChange(eq(uut), eq(STATE_DRAG_END_SIDE), eq(STATE_IDLE));
+ verify(mStateListener, times(2)).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ // Update-listener called exactly twice?
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(startTransX));
+ verify(mUpdateListener).onOverScrollUpdate(eq(uut), eq(STATE_DRAG_END_SIDE), eq(0f));
+ verify(mUpdateListener, times(2)).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ /*
+ * Up action event
+ */
+
+ @Test
+ public void onTouchUpAction_eventWhenNotOverscrolled_ignoreTouchEvent() throws Exception {
+
+ // Arrange
+
+ MotionEvent event = createDefaultUpActionEvent();
+
+ when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
+ when(mViewAdapter.isInAbsoluteEnd()).thenReturn(true);
+
+ VerticalOverScrollBounceEffectDecorator uut = getUUT();
+
+ // Act
+
+ boolean ret = uut.onTouch(mView, event);
+
+ // Assert
+
+ verify(mView, never()).setTranslationX(anyFloat());
+ verify(mView, never()).setTranslationY(anyFloat());
+ assertFalse(ret);
+ assertEquals(STATE_IDLE, uut.getCurrentState());
+
+ verify(mStateListener, never()).onOverScrollStateChange(eq(uut), anyInt(), anyInt());
+ verify(mUpdateListener, never()).onOverScrollUpdate(eq(uut), anyInt(), anyFloat());
+ }
+
+ protected MotionEvent createShortDownwardsMoveEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_MOVE);
+ when(event.getX()).thenReturn(200f);
+ when(event.getY()).thenReturn(100f);
+ when(event.getX(0)).thenReturn(200f);
+ when(event.getY(0)).thenReturn(100f);
+ when(event.getHistorySize()).thenReturn(1);
+ when(event.getHistoricalX(eq(0))).thenReturn(190f);
+ when(event.getHistoricalY(eq(0))).thenReturn(80f);
+ when(event.getHistoricalX(eq(0), eq(0))).thenReturn(190f);
+ when(event.getHistoricalY(eq(0), eq(0))).thenReturn(80f);
+ return event;
+ }
+
+ protected MotionEvent createLongDownwardsMoveEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_MOVE);
+ when(event.getX()).thenReturn(250f);
+ when(event.getY()).thenReturn(150f);
+ when(event.getX(0)).thenReturn(250f);
+ when(event.getY(0)).thenReturn(150f);
+ when(event.getHistorySize()).thenReturn(1);
+ when(event.getHistoricalX(eq(0))).thenReturn(200f);
+ when(event.getHistoricalY(eq(0))).thenReturn(100f);
+ when(event.getHistoricalX(eq(0), eq(0))).thenReturn(200f);
+ when(event.getHistoricalY(eq(0), eq(0))).thenReturn(100f);
+ return event;
+ }
+
+ protected MotionEvent createShortUpwardsMoveEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_MOVE);
+ when(event.getX()).thenReturn(200f);
+ when(event.getY()).thenReturn(100f);
+ when(event.getX(0)).thenReturn(200f);
+ when(event.getY(0)).thenReturn(100f);
+ when(event.getHistorySize()).thenReturn(1);
+ when(event.getHistoricalX(eq(0))).thenReturn(220f);
+ when(event.getHistoricalY(eq(0))).thenReturn(120f);
+ when(event.getHistoricalX(eq(0), eq(0))).thenReturn(220f);
+ when(event.getHistoricalY(eq(0), eq(0))).thenReturn(120f);
+ return event;
+ }
+
+ protected MotionEvent createLongUpwardsMoveEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_MOVE);
+ when(event.getX()).thenReturn(200f);
+ when(event.getY()).thenReturn(100f);
+ when(event.getX(0)).thenReturn(200f);
+ when(event.getY(0)).thenReturn(100f);
+ when(event.getHistorySize()).thenReturn(1);
+ when(event.getHistoricalX(eq(0))).thenReturn(250f);
+ when(event.getHistoricalY(eq(0))).thenReturn(150f);
+ when(event.getHistoricalX(eq(0), eq(0))).thenReturn(250f);
+ when(event.getHistoricalY(eq(0), eq(0))).thenReturn(150f);
+ return event;
+ }
+
+ protected MotionEvent createDefaultUpActionEvent() {
+ MotionEvent event = mock(MotionEvent.class);
+ when(event.getAction()).thenReturn(MotionEvent.ACTION_UP);
+ return event;
+ }
+
+ protected VerticalOverScrollBounceEffectDecorator getUUT() {
+ VerticalOverScrollBounceEffectDecorator uut = new VerticalOverScrollBounceEffectDecorator(mViewAdapter);
+ uut.setOverScrollStateListener(mStateListener);
+ uut.setOverScrollUpdateListener(mUpdateListener);
+ return uut;
+ }
+
+ protected VerticalOverScrollBounceEffectDecorator getUUT(float touchDragRatioFwd, float touchDragRatioBck) {
+ VerticalOverScrollBounceEffectDecorator uut = new VerticalOverScrollBounceEffectDecorator(mViewAdapter, touchDragRatioFwd, touchDragRatioBck, DEFAULT_DECELERATE_FACTOR);
+ uut.setOverScrollStateListener(mStateListener);
+ uut.setOverScrollUpdateListener(mUpdateListener);
+ return uut;
+ }
+}
\ No newline at end of file