From b9eb861fec55479aaba9a76b579571700ac50c54 Mon Sep 17 00:00:00 2001 From: h4h13 Date: Mon, 6 Jan 2020 01:56:05 +0530 Subject: [PATCH] Added scroll animation --- liboverscroll/.gitignore | 1 + liboverscroll/build.gradle | 49 ++ liboverscroll/src/main/AndroidManifest.xml | 5 + ...zontalOverScrollBounceEffectDecorator.java | 99 +++ .../ui/overscroll/IOverScrollDecor.java | 33 + .../ui/overscroll/IOverScrollState.java | 19 + .../overscroll/IOverScrollStateListener.java | 25 + .../overscroll/IOverScrollUpdateListener.java | 22 + .../android/ui/overscroll/ListenerStubs.java | 17 + .../OverScrollBounceEffectDecoratorBase.java | 483 +++++++++++++ .../overscroll/OverScrollDecoratorHelper.java | 78 +++ ...rticalOverScrollBounceEffectDecorator.java | 100 +++ .../AbsListViewOverScrollDecorAdapter.java | 57 ++ ...ontalScrollViewOverScrollDecorAdapter.java | 41 ++ .../adapters/IOverScrollDecoratorAdapter.java | 33 + .../RecyclerViewOverScrollDecorAdapter.java | 237 +++++++ .../ScrollViewOverScrollDecorAdapter.java | 43 ++ .../StaticOverScrollDecorAdapter.java | 38 ++ ...alOverScrollBounceEffectDecoratorTest.java | 640 ++++++++++++++++++ ...alOverScrollBounceEffectDecoratorTest.java | 600 ++++++++++++++++ 20 files changed, 2620 insertions(+) create mode 100755 liboverscroll/.gitignore create mode 100755 liboverscroll/build.gradle create mode 100755 liboverscroll/src/main/AndroidManifest.xml create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/HorizontalOverScrollBounceEffectDecorator.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollDecor.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollState.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollStateListener.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/IOverScrollUpdateListener.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/ListenerStubs.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollBounceEffectDecoratorBase.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollDecoratorHelper.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/VerticalOverScrollBounceEffectDecorator.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/AbsListViewOverScrollDecorAdapter.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/HorizontalScrollViewOverScrollDecorAdapter.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/IOverScrollDecoratorAdapter.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/RecyclerViewOverScrollDecorAdapter.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/ScrollViewOverScrollDecorAdapter.java create mode 100755 liboverscroll/src/main/java/me/everything/android/ui/overscroll/adapters/StaticOverScrollDecorAdapter.java create mode 100755 liboverscroll/src/test/java/me/everything/android/ui/overscroll/HorizontalOverScrollBounceEffectDecoratorTest.java create mode 100755 liboverscroll/src/test/java/me/everything/android/ui/overscroll/VerticalOverScrollBounceEffectDecoratorTest.java 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: + *

    + *
  1. 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).
  2. + *
  3. Over-scrolling state - handles motion events to apply the over-scroll effect as users + * interact with the view.
  4. + *
  5. 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).
  6. + *
+ *

+ * + * @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