PlayerAndroid/liboverscroll/src/main/java/me/everything/android/ui/overscroll/OverScrollBounceEffectDecor...

484 lines
19 KiB
Java
Executable File

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>
*
* @author amit
*
* @see RecyclerViewOverScrollDecorAdapter
* @see IOverScrollDecoratorAdapter
*/
public abstract class OverScrollBounceEffectDecoratorBase implements IOverScrollDecor, View.OnTouchListener {
public static final String TAG = "OverScrollDecor";
public static final float DEFAULT_TOUCH_DRAG_MOVE_RATIO_FWD = 3f;
public static final float DEFAULT_TOUCH_DRAG_MOVE_RATIO_BCK = 1f;
public static final float DEFAULT_DECELERATE_FACTOR = -2f;
protected static final int MAX_BOUNCE_BACK_DURATION_MS = 800;
protected static final int MIN_BOUNCE_BACK_DURATION_MS = 200;
protected final OverScrollStartAttributes mStartAttr = new OverScrollStartAttributes();
protected final IOverScrollDecoratorAdapter mViewAdapter;
protected final IdleState mIdleState;
protected final OverScrollingState mOverScrollingState;
protected final BounceBackState mBounceBackState;
protected IDecoratorState mCurrentState;
protected IOverScrollStateListener mStateListener = new OverScrollStateListenerStub();
protected IOverScrollUpdateListener mUpdateListener = new OverScrollUpdateListenerStub();
/**
* When in over-scroll mode, keep track of dragging velocity to provide a smooth slow-down
* for the bounce-back effect.
*/
protected float mVelocity;
/**
* Motion attributes: keeps data describing current motion event.
* <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);
}