add flutter

This commit is contained in:
Ariska
2026-03-11 15:29:37 +07:00
parent c253e1a370
commit 619d758027
9490 changed files with 135801 additions and 1353 deletions
@@ -0,0 +1 @@
/build
@@ -0,0 +1,33 @@
apply plugin: 'com.android.library'
android {
namespace 'me.everything.android.ui.overscroll'
compileSdk 33
defaultConfig {
minSdkVersion 21
targetSdkVersion 33
versionCode 1
versionName '1.0'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.viewpager:viewpager:1.0.0'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:3.1.0'
testImplementation 'org.robolectric:robolectric:4.3.1'
}
// Enable publishing
if (rootProject.hasProperty('_isOverscrollDecor')) {
apply from: './publishing.gradle'
}
@@ -0,0 +1,39 @@
// Based on https://stackoverflow.com/a/42160584/453052
project.ext.buildPomXmlDependencies = { pom, configurations ->
pom.withXml {
final rootNode = asNode().appendNode('dependencies')
addConfigurationDependencies(rootNode, configurations.api, 'compile')
addConfigurationDependencies(rootNode, configurations.compile, 'compile')
addConfigurationDependencies(rootNode, configurations.implementation, 'runtime')
}
}
private static def addConfigurationDependencies(rootNode, Configuration configuration, String scope) {
configuration.dependencies.each { dep -> addChildDependency(rootNode, dep, scope) }
}
private static def addChildDependency(rootNode, Dependency dep, String scope) {
if (dep.group == null || dep.version == null || dep.name == null || dep.name == "unspecified")
return
final childNode = rootNode.appendNode('dependency')
childNode.appendNode('groupId', dep.group)
childNode.appendNode('artifactId', dep.name)
childNode.appendNode('version', dep.version)
childNode.appendNode('scope', scope)
if (!dep.transitive) {
// If this dependency is transitive, we should force exclude all its dependencies them from the POM
final exclusionNode = childNode.appendNode('exclusions').appendNode('exclusion')
exclusionNode.appendNode('groupId', '*')
exclusionNode.appendNode('artifactId', '*')
} else if (!dep.properties.excludeRules.empty) {
// Otherwise add specified exclude rules
final exclusionNode = childNode.appendNode('exclusions').appendNode('exclusion')
dep.properties.excludeRules.each { ExcludeRule rule ->
exclusionNode.appendNode('groupId', rule.group ?: '*')
exclusionNode.appendNode('artifactId', rule.module ?: '*')
}
}
}
@@ -0,0 +1,205 @@
apply plugin: 'maven-publish'
apply plugin: 'signing'
apply from: './publish-pom.gradle'
String TARGET_MAVEN_CENTRAL_URL = 'https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/'
List DEVELOPERS = [
[name: 'd4vidi', email: 'amit.d4vidi@gmail.com'],
]
String _sonatypeUsername = 'd4vidi'
String _sonatypePassword = System.getProperty('sonatypePassword')
String _versionName = System.getProperty('version')
String _mavenRepoUrl = TARGET_MAVEN_CENTRAL_URL
Map _mavenCredentials = [
username: _sonatypeUsername,
password: _sonatypePassword,
]
def _selectedVariant = null
def onPrePublish = {
assertDefined(_versionName, "Publishing: Version not specified (run 'gradle publish' with a -Dversion=1.2.3 argument)")
assertDefined(_sonatypePassword, "Publishing: Please specify the password to use for sonatype (run 'gradle publish' with a -DsonatypePassword=<pw> argument)")
logger.lifecycle("Publishing is now in session! 📣\n Version: $_versionName\n Target URL: ${_mavenRepoUrl}\n Build-variant: '${_selectedVariant.name}'")
}
def declareArchive = { target ->
project.artifacts {
archives target
}
}
// Running from Gradle tab in IDE would create liboverscroll/build/libs/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/libs/liboverscroll-javadoc.jar
task javadocJar(type: Jar, dependsOn: javadoc) {
classifier = 'javadoc'
from javadoc.destinationDir
}
/*
* Signing configuration
* https://docs.gradle.org/current/userguide/signing_plugin.html
*/
// Tell signing task to sign everything current and future we set as a project archive...
signing {
sign configurations.archives
}
/*
* Plumbing work for actually having the publishing task work properly, if executed
*/
project.afterEvaluate {
project.tasks.all { Task task ->
android.libraryVariants.all { variant ->
String variantName = variant.name.capitalize()
if (task.name == "publishMaven${variantName}AarPublicationToMavenRepository") {
task.dependsOn "assemble${variantName}"
task.dependsOn project.tasks.signArchives
task.doFirst {
onPrePublish()
}
}
}
}
}
/*
* Publishing configuration
*/
publishing {
repositories {
maven {
url _mavenRepoUrl
if (_mavenCredentials != null) {
credentials {
username _mavenCredentials.username
password _mavenCredentials.password
}
}
}
}
publications {
android.libraryVariants.all { variant ->
if (isReleaseVariant(variant)) {
_selectedVariant = variant
String variantNameCapitalized = variant.name.capitalize()
"maven${variantNameCapitalized}Aar"(MavenPublication) {
groupId 'io.github.everythingme'
artifactId 'overscroll-decor-android'
version "$_versionName"
// Register built .aar as published artifact (as a file, explicitly)
variant.outputs.forEach { output ->
artifact output.outputFile
// Also register as an archive-artifact, for signing (via equivalent task's output)
declareArchive project.tasks["bundle${variantNameCapitalized}Aar"]
}
// Register sources, javadoc as published artifacts (via equivalent tasks' output)
artifact sourcesJar
artifact javadocJar
// Also register source, javadoc as archive-artifacts, for signing
declareArchive sourcesJar
declareArchive javadocJar
// Add package metadata to the .pom
pom {
name = 'Overscroll-Decor'
description = 'iOS-like over-scrolling effect for Android'
url = 'https://github.com/EverythingMe/overscroll-decor'
packaging 'aar' // Oh so important - or apps would ignore our code!!!!!
scm {
connection = 'scm:git:git://github.com/EverythingMe/overscroll-decor'
developerConnection = 'scm:git:git@github.com/EverythingMe/overscroll-decor.git'
url = 'https://github.com/EverythingMe/overscroll-decor'
}
licenses {
license {
name = 'BSD-2'
url = 'https://github.com/EverythingMe/overscroll-decor/blob/master/LICENSE'
}
}
developers {
DEVELOPERS.each { d ->
developer {
name = d.name
email = d.email
}
}
}
}
// Add dependencies to the .pom
buildPomXmlDependencies(pom, configurations)
// Register pom.xml's signature file (pom.xml.asc) as published artifact
// Note: this is done manually, instead of registering the pom as an archived artifact
pom.withXml {
def pomFile = file("${project.buildDir}/generated-pom.xml")
writeTo(pomFile) // Need to force-write so as to have the signature generated over the finalized content
def pomAscFile = signing.sign(pomFile).signatureFiles[0]
artifact(pomAscFile) {
classifier = null
extension = 'pom.asc'
}
}
// Register all artifacts we've previously registered as archives (i.e. .jar.asc's, .aar.asc's) as published artifacts.
// Note: this relies on preregistering the equivalent generator-tasks as archive artifacts
// inside a project.artifacts { ... } clause.
project.tasks.signArchives.signatureFiles.each {
artifact(it) {
def matcherSrcDocs = (it.file =~ /-(sources|javadoc)\.jar\.asc$/)
def matcherAAR = (it.file =~ /\.aar\.asc$/)
if (matcherSrcDocs.find()) {
classifier = matcherSrcDocs.group(1)
extension = 'jar.asc'
} else if (matcherAAR.find()) {
classifier = null
extension = 'aar.asc'
} else {
classifier = null
extension = null
}
}
}
}
}
}
}
}
/*
* Utils
*/
private static def isReleaseVariant(variant) {
return variant.buildType.name == 'release'
}
private static def assertDefined(target, message) {
if (target == null) {
throw new IllegalArgumentException(message)
}
}
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,97 @@
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.
*/
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:
* <br/>Touch-drag ratio in 'forward' direction will be set to DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD.
* <br/>Touch-drag ratio in 'backwards' direction will be set to DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK.
* <br/>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);
}
}
@@ -0,0 +1,30 @@
package me.everything.android.ui.overscroll;
import android.view.View;
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.
*
* <p>It is best to call this only when over-scroll isn't currently in-effect - i.e. verify that
* <code>getCurrentState()==IOverScrollState.STATE_IDLE</code> as a precondition, or otherwise
* use a state listener previously installed using
* {@link #setOverScrollStateListener(IOverScrollStateListener)}.</p>
*
* <p>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 <code>View.setOverScrollMode(mode)</code> immediately thereafter.</p>
*/
void detach();
}
@@ -0,0 +1,16 @@
package me.everything.android.ui.overscroll;
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 <i>start</i> side. */
int STATE_DRAG_START_SIDE = 1;
/** User is actively touch-dragging, thus enabling over-scroll at the view's <i>end</i> 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;
}
@@ -0,0 +1,23 @@
package me.everything.android.ui.overscroll;
/**
* A callback-listener enabling over-scroll effect clients to be notified of effect state transitions.
* <br/>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}.
*
* @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 <b>new</b> over-scroll state; ID's specified by {@link IOverScrollState},
* e.g. {@link IOverScrollState#STATE_IDLE}.
*/
void onOverScrollStateChange(IOverScrollDecor decor, int oldState, int newState);
}
@@ -0,0 +1,20 @@
package me.everything.android.ui.overscroll;
/**
* A callback-listener enabling over-scroll effect clients to subscribe to <b>real-time</b> updates
* of over-scrolling intensity, provided as the view-translation offset from pre-scroll position.
*
* @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);
}
@@ -0,0 +1,14 @@
package me.everything.android.ui.overscroll;
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) { }
}
}
@@ -0,0 +1,481 @@
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}).
*
* <p>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.</p>
*
* <p>Note that this class is abstract, having {@link HorizontalOverScrollBounceEffectDecorator} and
* {@link VerticalOverScrollBounceEffectDecorator} providing concrete implementations that are
* view-orientation specific.</p>
*
* <hr width="97%"/>
* <h2>Implementation Notes</h2>
*
* <p>At it's core, the class simply registers itself as a touch-listener over the decorated view and
* intercepts touch events as needed.</p>
*
* <p>Internally, it delegates the over-scrolling calculations onto 3 state-based classes:
* <ol>
* <li><b>Idle state</b> - monitors view state and touch events to intercept over-scrolling initiation
* (in which case it hands control over to the Over-scrolling state).</li>
* <li><b>Over-scrolling state</b> - handles motion events to apply the over-scroll effect as users
* interact with the view.</li>
* <li><b>Bounce-back state</b> - 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).</li>
* </ol>
* </p>
*
* @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.
* <br/>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<View, Float> 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).
* <br/>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.
*
* <br/><br/>The state is exited - thus completing over-scroll handling, in one of two cases:
* <br/>When user lets go of the view, it transitions control to the bounce-back state.
* <br/>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.
* <br/>Upon animation completion, transitions control onto the idle state; Does so by
* registering itself as an animation listener.
* <br/>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);
}
@@ -0,0 +1,90 @@
package me.everything.android.ui.overscroll;
import androidx.viewpager.widget.ViewPager;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
import android.view.View;
import android.widget.GridView;
import android.widget.HorizontalScrollView;
import android.widget.ListView;
import android.widget.ScrollView;
import me.everything.android.ui.overscroll.adapters.AbsListViewOverScrollDecorAdapter;
import me.everything.android.ui.overscroll.adapters.HorizontalScrollViewOverScrollDecorAdapter;
import me.everything.android.ui.overscroll.adapters.RecyclerViewOverScrollDecorAdapter;
import me.everything.android.ui.overscroll.adapters.ScrollViewOverScrollDecorAdapter;
import me.everything.android.ui.overscroll.adapters.StaticOverScrollDecorAdapter;
import me.everything.android.ui.overscroll.adapters.ViewPagerOverScrollDecorAdapter;
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.
* <br/>Only recycler-views using <b>native</b> 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.
*/
public static IOverScrollDecor setUpOverScroll(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");
}
}
public static IOverScrollDecor setUpOverScroll(ListView listView) {
return new VerticalOverScrollBounceEffectDecorator(new AbsListViewOverScrollDecorAdapter(listView));
}
public static IOverScrollDecor setUpOverScroll(GridView gridView) {
return new VerticalOverScrollBounceEffectDecorator(new AbsListViewOverScrollDecorAdapter(gridView));
}
public static IOverScrollDecor setUpOverScroll(ScrollView scrollView) {
return new VerticalOverScrollBounceEffectDecorator(new ScrollViewOverScrollDecorAdapter(scrollView));
}
public static IOverScrollDecor setUpOverScroll(HorizontalScrollView scrollView) {
return new HorizontalOverScrollBounceEffectDecorator(new HorizontalScrollViewOverScrollDecorAdapter(scrollView));
}
/**
* 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");
}
}
public static IOverScrollDecor setUpOverScroll(ViewPager viewPager) {
return new HorizontalOverScrollBounceEffectDecorator(new ViewPagerOverScrollDecorAdapter(viewPager));
}
}
@@ -0,0 +1,97 @@
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.
*/
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:
* <br/>Touch-drag ratio in 'forward' direction will be set to DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD.
* <br/>Touch-drag ratio in 'backwards' direction will be set to DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK.
* <br/>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 MotionAttributes createMotionAttributes() {
return new MotionAttributesVertical();
}
@Override
protected AnimationAttributes createAnimationAttributes() {
return new AnimationAttributesVertical();
}
@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);
}
}
@@ -0,0 +1,55 @@
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}.
*
* @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();
}
}
@@ -0,0 +1,39 @@
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}.
* <br/>Seeing that {@link HorizontalScrollView} only supports horizontal scrolling, this adapter
* should only be used with a {@link HorizontalOverScrollBounceEffectDecorator}.
*
* @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);
}
}
@@ -0,0 +1,31 @@
package me.everything.android.ui.overscroll.adapters;
import android.view.View;
import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
/**
* @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();
}
@@ -0,0 +1,226 @@
package me.everything.android.ui.overscroll.adapters;
import android.graphics.Canvas;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import android.view.View;
import java.util.List;
import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
import me.everything.android.ui.overscroll.VerticalOverScrollBounceEffectDecorator;
/**
* @see HorizontalOverScrollBounceEffectDecorator
* @see VerticalOverScrollBounceEffectDecorator
*/
public class RecyclerViewOverScrollDecorAdapter implements IOverScrollDecoratorAdapter {
/**
* 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 isInAbsoluteStart();
boolean isInAbsoluteEnd();
}
protected final RecyclerView mRecyclerView;
protected final Impl mImpl;
protected boolean mIsItemTouchInEffect = false;
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);
}
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);
}
@Override
public View getView() {
return mRecyclerView;
}
@Override
public boolean isInAbsoluteStart() {
return !mIsItemTouchInEffect && mImpl.isInAbsoluteStart();
}
@Override
public boolean isInAbsoluteEnd() {
return !mIsItemTouchInEffect && mImpl.isInAbsoluteEnd();
}
protected class ImplHorizLayout implements Impl {
@Override
public boolean isInAbsoluteStart() {
return !mRecyclerView.canScrollHorizontally(-1);
}
@Override
public boolean isInAbsoluteEnd() {
return !mRecyclerView.canScrollHorizontally(1);
}
}
protected class ImplVerticalLayout implements Impl {
@Override
public boolean isInAbsoluteStart() {
return !mRecyclerView.canScrollVertically(-1);
}
@Override
public boolean isInAbsoluteEnd() {
return !mRecyclerView.canScrollVertically(1);
}
}
private static class ItemTouchHelperCallbackWrapper extends ItemTouchHelper.Callback {
final ItemTouchHelper.Callback mCallback;
private ItemTouchHelperCallbackWrapper(ItemTouchHelper.Callback callback) {
mCallback = callback;
}
@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
return mCallback.getMovementFlags(recyclerView, viewHolder);
}
@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
return mCallback.onMove(recyclerView, viewHolder, target);
}
@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
mCallback.onSwiped(viewHolder, direction);
}
@Override
public int convertToAbsoluteDirection(int flags, int layoutDirection) {
return mCallback.convertToAbsoluteDirection(flags, layoutDirection);
}
@Override
public boolean canDropOver(RecyclerView recyclerView, RecyclerView.ViewHolder current, RecyclerView.ViewHolder target) {
return mCallback.canDropOver(recyclerView, current, target);
}
@Override
public boolean isLongPressDragEnabled() {
return mCallback.isLongPressDragEnabled();
}
@Override
public boolean isItemViewSwipeEnabled() {
return mCallback.isItemViewSwipeEnabled();
}
@Override
public int getBoundingBoxMargin() {
return mCallback.getBoundingBoxMargin();
}
@Override
public float getSwipeThreshold(RecyclerView.ViewHolder viewHolder) {
return mCallback.getSwipeThreshold(viewHolder);
}
@Override
public float getMoveThreshold(RecyclerView.ViewHolder viewHolder) {
return mCallback.getMoveThreshold(viewHolder);
}
@Override
public RecyclerView.ViewHolder chooseDropTarget(RecyclerView.ViewHolder selected, List<RecyclerView.ViewHolder> dropTargets, int curX, int curY) {
return mCallback.chooseDropTarget(selected, dropTargets, curX, curY);
}
@Override
public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
mCallback.onSelectedChanged(viewHolder, actionState);
}
@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 clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
mCallback.clearView(recyclerView, viewHolder);
}
@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 long getAnimationDuration(RecyclerView recyclerView, int animationType, float animateDx, float animateDy) {
return mCallback.getAnimationDuration(recyclerView, animationType, animateDx, animateDy);
}
@Override
public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize, int viewSizeOutOfBounds, int totalSize, long msSinceStartScroll) {
return mCallback.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll);
}
}
}
@@ -0,0 +1,41 @@
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}.
* <br/>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}.
*
* @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);
}
}
@@ -0,0 +1,36 @@
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).
*
* @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;
}
}
@@ -0,0 +1,63 @@
package me.everything.android.ui.overscroll.adapters;
import android.view.View;
import androidx.viewpager.widget.ViewPager;
import me.everything.android.ui.overscroll.HorizontalOverScrollBounceEffectDecorator;
/**
* An adapter to enable over-scrolling over object of {@link ViewPager}
*
* @see HorizontalOverScrollBounceEffectDecorator
*/
public class ViewPagerOverScrollDecorAdapter implements IOverScrollDecoratorAdapter, ViewPager.OnPageChangeListener {
protected final ViewPager mViewPager;
protected int mLastPagerPosition = 0;
protected float mLastPagerScrollOffset;
public ViewPagerOverScrollDecorAdapter(ViewPager viewPager) {
this.mViewPager = viewPager;
mViewPager.addOnPageChangeListener(this);
mLastPagerPosition = mViewPager.getCurrentItem();
mLastPagerScrollOffset = 0f;
}
@Override
public View getView() {
return mViewPager;
}
@Override
public boolean isInAbsoluteStart() {
return mLastPagerPosition == 0 &&
mLastPagerScrollOffset == 0f;
}
@Override
public boolean isInAbsoluteEnd() {
return mLastPagerPosition == mViewPager.getAdapter().getCount()-1 &&
mLastPagerScrollOffset == 0f;
}
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
mLastPagerPosition = position;
mLastPagerScrollOffset = positionOffset;
}
@Override
public void onPageSelected(int position) {
}
@Override
public void onPageScrollStateChanged(int state) {
}
}
@@ -0,0 +1,636 @@
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.Mockito.*;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class HorizontalOverScrollBounceEffectDecoratorTest {
View mView;
IOverScrollDecoratorAdapter mViewAdapter;
IOverScrollStateListener mStateListener;
IOverScrollUpdateListener mUpdateListener;
@Before
public void setUp() {
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() {
// Arrange
HorizontalOverScrollBounceEffectDecorator uut = new HorizontalOverScrollBounceEffectDecorator(mViewAdapter);
// Act
uut.detach();
// Assert
verify(mView).setOnTouchListener(null);
verify(mView).setOverScrollMode(View.OVER_SCROLL_ALWAYS);
}
@Test
public void detach_overScrollInEffect_detachFromView() {
when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
HorizontalOverScrollBounceEffectDecorator uut = getUUT();
uut.onTouch(mView, createShortRightMoveEvent());
// Act
uut.detach();
// Assert
verify(mView).setOnTouchListener(null);
verify(mView).setOverScrollMode(View.OVER_SCROLL_ALWAYS);
}
/*
* Move-action event
*/
@Test
public void onTouchMoveAction_notInViewEnds_ignoreTouchEvent() {
// 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() {
// 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() {
// 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() {
// 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() {
// 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() {
// 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() {
// 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:
* <br/>We expect the <b>touch to still be intercepted</b> in that case, and the <b>overscroll to
* remain in effect</b>.
*/
@Test
public void onTouchMoveAction_dragLeftWhenRightOverscolled_continueOverscrollingLeft() {
// 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:
* <br/>We expect the <b>touch to still be intercepted</b> in that case, and the <b>overscroll to remain in effect</b>.
*/
@Test
public void onTouchMoveAction_dragRightWhenLeftOverscolled_continueOverscrollingRight() {
// 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() {
// 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() {
// 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() {
// 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() {
// 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;
}
}
@@ -0,0 +1,596 @@
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.Mockito.*;
@RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE)
public class VerticalOverScrollBounceEffectDecoratorTest {
View mView;
IOverScrollDecoratorAdapter mViewAdapter;
IOverScrollStateListener mStateListener;
IOverScrollUpdateListener mUpdateListener;
@Before
public void setUp() {
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() {
// Arrange
HorizontalOverScrollBounceEffectDecorator uut = new HorizontalOverScrollBounceEffectDecorator(mViewAdapter);
// Act
uut.detach();
// Assert
verify(mView).setOnTouchListener(null);
verify(mView).setOverScrollMode(View.OVER_SCROLL_ALWAYS);
}
@Test
public void detach_overScrollInEffect_detachFromView() {
when(mViewAdapter.isInAbsoluteStart()).thenReturn(true);
when(mViewAdapter.isInAbsoluteEnd()).thenReturn(false);
VerticalOverScrollBounceEffectDecorator uut = getUUT();
uut.onTouch(mView, createShortDownwardsMoveEvent());
// Act
uut.detach();
// Assert
verify(mView).setOnTouchListener(null);
verify(mView).setOverScrollMode(View.OVER_SCROLL_ALWAYS);
}
/*
* Move-action event
*/
@Test
public void onTouchMoveAction_notInViewEnds_ignoreTouchEvent() {
// 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() {
// 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() {
// 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() {
// 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() {
// 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() {
// 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() {
// 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:
* <br/>We expect the <b>touch to still be intercepted</b> in that case, and the <b>overscroll to remain in effect</b>.
*/
@Test
public void onTouchMoveAction_dragUpWhenDownOverscolled_continueOverscrollingUpwards() {
// 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:
* <br/>We expect the <b>touch to still be intercepted</b> in that case, and the <b>overscroll to remain in effect</b>.
*/
@Test
public void onTouchMoveAction_dragDownWhenUpOverscolled_continueOverscrollingDownwards() {
// 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() {
// 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() {
// 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() {
// 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;
}
}