package ru.noties.scrollable;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.FloatEvaluator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Rect;
import android.os.Parcel;
import android.os.Parcelable;
import android.util.AttributeSet;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewTreeObserver;
import android.view.animation.AnimationUtils;
import android.view.animation.Interpolator;
import android.widget.FrameLayout;
import java.util.ArrayList;
import java.util.List;
/**
* <p>
* This is the main {@link android.view.ViewGroup} for implementing Scrollable.
* It has the same as {@link android.widget.FrameLayout#onMeasure(int, int)} measure logic,
* but has it's own {@link #onLayout(boolean, int, int, int, int)} logic.
* </p>
* <p>
* Note, that this ViewGroup will layout it's children as if it were an ordinary {@link android.widget.LinearLayout}
* with orientation set to {@link android.widget.LinearLayout#VERTICAL}.
* No paddings or margins will affect the layout position of children,
* although margins will certainly affect measurements.
* </p>
* <p>
* The best usage would be to include two {@link android.view.View} views.
* The first one would represent the <code>header</code> logic and the second would be scrollable container.
* Note, that we should make use of {@link android.view.ViewGroup.LayoutParams#MATCH_PARENT}
* as the height attribute for scrollable container. Because it directly affects the scrollable behavior.
* If you wish to create a <code>sticky</code> effect for one of the views in <code>header</code> ViewGroup,
* you could specify <code>layout_marginTop</code> attribute for your scrollable layout,
* which represents the height of your sticky element.
* </p>
* <p>
* The logic behind scenes is simple. We should be able to encapsulate scrollable logic in a way (in a <code>tabs</code> case),
* that saves us from adding header placeholders and footers (so that scrollY does
* not change when different tab is selected) to every {@link android.widget.ScrollView}, {@link android.widget.AbsListView} etc.
* So, instead of modifying scrolling behavior of each scrollable View, we are creating our own
* ViewGroup that handles it for us.
* </p>
* <p>
* Follow these steps to create your own Scrollable layout:
* </p>
*
* <b>Simple case</b>
* <pre>
* {@code
* <ru.noties.scrollable.ScrollableLayout
* android:layout_width="match_parent"
* android:layout_height="match_parent"
* app:scrollable_maxScroll="@dimen/header_height"> <!-- (!) -->
*
* <View
* android:layout_width="match_parent"
* android:layout_height="@dimen/header_height" /> <!-- (!) -- >
*
* <ListView
* android:layout_width="match_parent"
* android:layout_height="match_parent" />
*
* </ru.noties.scrollable.ScrollableLayout>
* }
* </pre>
*
* <b>Sticky case</b>
* (of cause it's just an xml step, you also should implement translation logic in OnScrollChangeListener
* {@link #setOnScrollChangedListener(OnScrollChangedListener)})
* <pre>
* {@code
* <ru.noties.scrollable.ScrollableLayout
* android:layout_width="match_parent"
* android:layout_height="match_parent"
* app:scrollable_maxScroll="@dimen/header_height">
*
* <LinearLayout
* android:layout_width="match_parent"
* android:layout_height="wrap_content">
*
* <View
* android:layout_width="match_parent"
* android:layout_height="@dimen/header_height" />
*
* <View
* android:layout_width="match_parent"
* android:layout_height="@dimen/sticky_height" /> <!-- (!) -->
*
* </LinearLayout>
*
* <ListView
* android:layout_width="match_parent"
* android:layout_height="match_parent"
* android:layout_marginTop="@dimen/sticky_height" /> <!-- (!) -->
* }
* </pre>
*
* Created by Dimitry Ivanov (mail@dimitryivanov.ru) on 28.03.2015.
*/
public class ScrollableLayout extends FrameLayout {
private static final long DEFAULT_IDLE_CLOSE_UP_ANIMATION = 200L;
private static final int DEFAULT_CONSIDER_IDLE_MILLIS = 100;
private static final float DEFAULT_FRICTION = .0565F;
private final Rect mDraggableRect = new Rect();
private final List<OnScrollChangedListener> mOnScrollChangedListeners = new ArrayList<>(3);
private ScrollableScroller mScroller;
private GestureDetector mScrollDetector;
private GestureDetector mFlingDetector;
private CanScrollVerticallyDelegate mCanScrollVerticallyDelegate;
private int mMaxScrollY;
private boolean mIsScrolling;
private boolean mIsFlinging;
private MotionEventHook mMotionEventHook;
private CloseUpAlgorithm mCloseUpAlgorithm;
private ValueAnimator mCloseUpAnimator;
private ValueAnimator.AnimatorUpdateListener mCloseUpUpdateListener;
private boolean mSelfUpdateScroll;
private boolean mSelfUpdateFling;
private boolean mIsTouchOngoing;
private CloseUpIdleAnimationTime mCloseUpIdleAnimationTime;
private CloseUpAnimatorConfigurator mCloseAnimatorConfigurator;
private View mDraggableView;
private boolean mIsDraggingDraggable;
private long mConsiderIdleMillis;
private boolean mEventRedirected;
private float mEventRedirectStartedY;
private float mScaledTouchSlop;
private OnFlingOverListener mOnFlingOverListener;
private boolean mAutoMaxScroll;
private ViewTreeObserver.OnGlobalLayoutListener mAutoMaxScrollYLayoutListener;
private int mAutoMaxScrollViewId;
private boolean mOverScrollStarted;
private OverScrollListener mOverScrollListener;
private int mScrollingHeaderId;
private View mScrollingHeader;
// ValueAnimator used to animate between scroll states
private ValueAnimator mManualScrollAnimator;
private ValueAnimator.AnimatorUpdateListener mManualScrollUpdateListener;
public ScrollableLayout(Context context) {
super(context);
init(context, null);
}
public ScrollableLayout(Context context, AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}
public ScrollableLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs);
}
private void init(Context context, AttributeSet attributeSet) {
final TypedArray array = context.obtainStyledAttributes(attributeSet, R.styleable.ScrollableLayout);
try {
final boolean flyWheel = array.getBoolean(R.styleable.ScrollableLayout_scrollable_scrollerFlywheel, false);
mScroller = initScroller(context, null, flyWheel);
final float friction = array.getFloat(R.styleable.ScrollableLayout_scrollable_friction, DEFAULT_FRICTION);
setFriction(friction);
mMaxScrollY = array.getDimensionPixelSize(R.styleable.ScrollableLayout_scrollable_maxScroll, 0);
mAutoMaxScroll = array.getBoolean(R.styleable.ScrollableLayout_scrollable_autoMaxScroll, mMaxScrollY == 0);
mAutoMaxScrollViewId = array.getResourceId(R.styleable.ScrollableLayout_scrollable_autoMaxScrollViewId, 0);
final long considerIdleMillis = array.getInteger(
R.styleable.ScrollableLayout_scrollable_considerIdleMillis,
DEFAULT_CONSIDER_IDLE_MILLIS
);
setConsiderIdleMillis(considerIdleMillis);
final boolean useDefaultCloseUp = array.getBoolean(R.styleable.ScrollableLayout_scrollable_defaultCloseUp, false);
if (useDefaultCloseUp) {
setCloseUpAlgorithm(new DefaultCloseUpAlgorithm());
}
final int closeUpAnimationMillis = array.getInteger(R.styleable.ScrollableLayout_scrollable_closeUpAnimationMillis, -1);
if (closeUpAnimationMillis != -1) {
setCloseUpIdleAnimationTime(new SimpleCloseUpIdleAnimationTime(closeUpAnimationMillis));
}
final int interpolatorResId = array.getResourceId(R.styleable.ScrollableLayout_scrollable_closeUpAnimatorInterpolator, 0);
if (interpolatorResId != 0) {
final Interpolator interpolator = AnimationUtils.loadInterpolator(context, interpolatorResId);
setCloseAnimatorConfigurator(new InterpolatorCloseUpAnimatorConfigurator(interpolator));
}
mScrollingHeaderId = array.getResourceId(R.styleable.ScrollableLayout_scrollable_scrollingHeaderId, 0);
} finally {
array.recycle();
}
mScrollDetector = new GestureDetector(context, new ScrollGestureListener());
mFlingDetector = new GestureDetector(context, new FlingGestureListener(context));
mMotionEventHook = new MotionEventHook(new MotionEventHookCallback() {
@Override
public void apply(MotionEvent event) {
ScrollableLayout.super.dispatchTouchEvent(event);
}
});
mScaledTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
if (mAutoMaxScroll) {
processAutoMaxScroll(true);
}
final View scrollingHeader;
if (mScrollingHeaderId != 0) {
scrollingHeader = findViewById(mScrollingHeaderId);
} else {
if (getChildCount() > 0) {
scrollingHeader = getChildAt(0);
} else {
scrollingHeader = null;
}
}
mScrollingHeader = scrollingHeader;
}
@Override
protected void onDetachedFromWindow() {
// cancel running animators
if (mManualScrollAnimator != null
&& mManualScrollAnimator.isRunning()) {
mManualScrollAnimator.cancel();
}
if (mCloseUpAnimator != null
&& mCloseUpAnimator.isRunning()) {
mCloseUpAnimator.cancel();
}
super.onDetachedFromWindow();
}
/**
* Override this method if you wish to create own {@link android.widget.Scroller}
* @param context {@link android.content.Context}
* @param interpolator {@link android.view.animation.Interpolator}, the default implementation passes <code>null</code>
* @param flywheel {@link android.widget.Scroller#Scroller(android.content.Context, android.view.animation.Interpolator, boolean)}
* @return new instance of {@link android.widget.Scroller} must not bu null
*/
protected ScrollableScroller initScroller(Context context, Interpolator interpolator, boolean flywheel) {
return new ScrollableScroller(context, interpolator, flywheel);
}
/**
* Sets friction for current {@link android.widget.Scroller}
* @see android.widget.Scroller#setFriction(float)
* @param friction to be applied
*/
public void setFriction(float friction) {
mScroller.setFriction(friction);
}
/**
* @see ru.noties.scrollable.CanScrollVerticallyDelegate
* @param delegate which will be invoked when scroll state of scrollable children is needed
*/
public void setCanScrollVerticallyDelegate(CanScrollVerticallyDelegate delegate) {
this.mCanScrollVerticallyDelegate = delegate;
}
/**
* Also can be set via xml attribute <code>scrollable_maxScroll</code>
* @param maxY the max scroll y available for this View.
* @see #getMaxScrollY()
*/
public void setMaxScrollY(int maxY) {
this.mMaxScrollY = maxY;
// disable autoMaxScroll if value was set manually
processAutoMaxScroll(false);
}
/**
* @return value which represents the max scroll distance to <code>this</code> View (aka <code>header</code> height)
* @see #setMaxScrollY(int)
*/
public int getMaxScrollY() {
return mMaxScrollY;
}
/**
* Note that this value might be set with xml definition (<pre>{@code app:scrollable_considerIdleMillis="100"}</pre>)
* @param millis millis after which current scroll
* state would be considered idle and thus firing close up logic if set
* @see #getConsiderIdleMillis()
* @see #DEFAULT_CONSIDER_IDLE_MILLIS
*/
public void setConsiderIdleMillis(long millis) {
mConsiderIdleMillis = millis;
}
/**
* @return current value of millis after which scroll state would be considered idle
* @see #setConsiderIdleMillis(long)
*/
public long getConsiderIdleMillis() {
return mConsiderIdleMillis;
}
/**
* Pass an {@link ru.noties.scrollable.OnScrollChangedListener}
* if you wish to get notifications when scroll state of <code>this</code> View has changed.
* It\'s helpful for implementing own logic which depends on scroll state (e.g. parallax, alpha, etc)
* @param listener to be invoked when {@link #onScrollChanged(int, int, int, int)} has been called.
* Might be <code>null</code> if you don\'t want to receive scroll notifications anymore
*/
@Deprecated
public void setOnScrollChangedListener(OnScrollChangedListener listener) {
mOnScrollChangedListeners.clear();
addOnScrollChangedListener(listener);
}
public void addOnScrollChangedListener(OnScrollChangedListener listener) {
if (listener != null) {
mOnScrollChangedListeners.add(listener);
}
}
public void removeOnScrollChangedListener(OnScrollChangedListener listener) {
if (listener != null) {
mOnScrollChangedListeners.remove(listener);
}
}
public void setOnFlingOverListener(OnFlingOverListener onFlingOverListener) {
this.mOnFlingOverListener = onFlingOverListener;
}
/**
* @see android.view.View#onScrollChanged(int, int, int, int)
* @see ru.noties.scrollable.OnScrollChangedListener#onScrollChanged(int, int, int)
* @see CloseUpAlgorithm
*/
@Override
public void onScrollChanged(int l, int t, int oldL, int oldT) {
final boolean changed = t != oldT;
final int size = changed
? mOnScrollChangedListeners.size()
: 0;
if (size > 0) {
for (int i = 0; i < size; i++) {
mOnScrollChangedListeners.get(i).onScrollChanged(t, oldT, mMaxScrollY);
}
}
if (mCloseUpAlgorithm != null) {
removeCallbacks(mIdleRunnable);
if (!mSelfUpdateScroll && changed && !mIsTouchOngoing) {
postDelayed(mIdleRunnable, mConsiderIdleMillis);
}
}
super.onScrollChanged(l, t, oldL, oldT);
}
/**
* Call this method to enable/disable scrolling logic. If called with `value=false`
* ScrollableLayout won't process any touch events
* @param value indicating whether or not ScrollableLayout should process touch events
*/
public void setSelfUpdateScroll(boolean value) {
mSelfUpdateScroll = value;
}
/**
* @see #setSelfUpdateScroll(boolean)
* @return current value of `mSelfUpdateScroll`
*/
public boolean isSelfUpdateScroll() {
return mSelfUpdateScroll;
}
/**
* Note that {@link DefaultCloseUpAlgorithm} might be set with
* xml definition (<pre>{@code app:scrollable_defaultCloseUp="true"}</pre>)
* @param closeUpAlgorithm {@link CloseUpAlgorithm} implementation, might be null
* @see CloseUpAlgorithm
* @see DefaultCloseUpAlgorithm
*/
public void setCloseUpAlgorithm(CloseUpAlgorithm closeUpAlgorithm) {
this.mCloseUpAlgorithm = closeUpAlgorithm;
}
/**
* Note that {@link SimpleCloseUpIdleAnimationTime} might be set with xml definition
* (<pre>{@code app:scrollable_closeUpAnimationMillis="200"}</pre>)
* @param closeUpIdleAnimationTime {@link CloseUpIdleAnimationTime} implementation, might be null
* @see CloseUpIdleAnimationTime
* @see SimpleCloseUpIdleAnimationTime
* @see #DEFAULT_IDLE_CLOSE_UP_ANIMATION
*/
public void setCloseUpIdleAnimationTime(CloseUpIdleAnimationTime closeUpIdleAnimationTime) {
this.mCloseUpIdleAnimationTime = closeUpIdleAnimationTime;
}
/**
* @param configurator {@link CloseUpAnimatorConfigurator} implementation
* to process current close up
* {@link android.animation.ObjectAnimator}, might be null
* @see CloseUpAnimatorConfigurator
* @see android.animation.ObjectAnimator
*/
public void setCloseAnimatorConfigurator(CloseUpAnimatorConfigurator configurator) {
this.mCloseAnimatorConfigurator = configurator;
}
/**
* Helper method to animate scroll state of ScrollableLayout.
* Please note, that returned {@link ValueAnimator} is not fully configured -
* it needs at least `duration` property.
* Also, there is no checks if the current scrollY is equal to the requested one.
* @param scrollY the final scroll y to animate to
* @return {@link ValueAnimator} configured to animate scroll state
*/
public ValueAnimator animateScroll(final int scrollY) {
// create an instance of this animator that is shared between calls
if (mManualScrollAnimator == null) {
mManualScrollAnimator = ValueAnimator.ofFloat(.0F, 1.F);
mManualScrollAnimator.setEvaluator(new FloatEvaluator());
mManualScrollAnimator.addListener(new SelfUpdateAnimationListener());
} else {
// unregister our update listener
if (mManualScrollUpdateListener != null) {
mManualScrollAnimator.removeUpdateListener(mManualScrollUpdateListener);
}
// cancel if running
if (mManualScrollAnimator.isRunning()) {
mManualScrollAnimator.end();
}
}
final int y;
if (scrollY < 0) {
y = 0;
} else if (scrollY > mMaxScrollY) {
y = mMaxScrollY;
} else {
y = scrollY;
}
final int startY = getScrollY();
final int diff = y - startY;
mManualScrollUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
final float fraction = animation.getAnimatedFraction();
scrollTo(0, (int) (startY + (diff * fraction) + .5F));
}
};
mManualScrollAnimator.addUpdateListener(mManualScrollUpdateListener);
return mManualScrollAnimator;
}
/**
* @see View#scrollTo(int, int)
* @see #setCanScrollVerticallyDelegate(CanScrollVerticallyDelegate)
* @see #setMaxScrollY(int)
*/
@Override
public void scrollTo(int x, int y) {
final int newY = getNewY(y);
if (newY < 0) {
return;
}
super.scrollTo(0, newY);
}
/**
* If set to true then ScrollableLayout will listen for global layout change of a view with
* is passed through xml: scrollable_autoMaxScrollViewId OR first view in layout.
* With this feature no need to specify `scrollable_maxScrollY` attribute
* @param autoMaxScroll to listen for child view height and change mMaxScrollY accordingly
*/
public void setAutoMaxScroll(boolean autoMaxScroll) {
mAutoMaxScroll = autoMaxScroll;
processAutoMaxScroll(mAutoMaxScroll);
}
/**
* @see #setAutoMaxScroll(boolean)
* @return `mAutoMaxScroll` value
*/
public boolean isAutoMaxScroll() {
return mAutoMaxScroll;
}
public void setOverScrollListener(OverScrollListener listener) {
mOverScrollListener = listener;
}
protected void processAutoMaxScroll(boolean autoMaxScroll) {
if (getChildCount() == 0) {
return;
}
final View view;
if (mAutoMaxScrollViewId != 0) {
view = findViewById(mAutoMaxScrollViewId);
} else {
view = getChildAt(0);
}
if (view == null) {
return;
}
if (!autoMaxScroll) {
if (mAutoMaxScrollYLayoutListener != null) {
ViewUtils.removeGlobalLayoutListener(view, mAutoMaxScrollYLayoutListener);
mAutoMaxScrollYLayoutListener = null;
}
} else {
// if it's not null, we have already set it
if (mAutoMaxScrollYLayoutListener == null) {
mAutoMaxScrollYLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
@Override
public void onGlobalLayout() {
mMaxScrollY = view.getMeasuredHeight();
}
};
view.getViewTreeObserver().addOnGlobalLayoutListener(mAutoMaxScrollYLayoutListener);
}
}
}
// we will override this method in order to function with SwipeRefreshLayout (and possible others)
// also, just in case, we will check if we can scroll to bottom
@Override
public boolean canScrollVertically(int direction) {
return (direction < 0 && getScrollY() > 0)
|| (direction > 0 && mCanScrollVerticallyDelegate.canScrollVertically(direction));
}
@Override
public boolean canScrollHorizontally(int direction) {
return false;
}
protected int getNewY(int y) {
final int currentY = getScrollY();
if (currentY == y) {
return -1;
}
final int direction = y - currentY;
final boolean isScrollingBottomTop = direction < 0;
if (mCanScrollVerticallyDelegate != null) {
if (isScrollingBottomTop) {
// if not dragging draggable then return, else do not return
if (!mIsDraggingDraggable
&& !mSelfUpdateScroll
&& mCanScrollVerticallyDelegate.canScrollVertically(direction)) {
return -1;
}
} else {
// we are adding support for the scrolling view in the `header` section (first view)
// we just check if header can scroll in top-bottom direction (but only if we are not dragging draggable view)
// else check if we are at max scroll
if ((!mIsDraggingDraggable && !mSelfUpdateScroll && canHeaderScroll(direction))
|| (currentY == mMaxScrollY && !mCanScrollVerticallyDelegate.canScrollVertically(direction))) {
return -1;
}
}
}
if (y < 0) {
y = 0;
} else if (y > mMaxScrollY) {
y = mMaxScrollY;
}
return y;
}
/**
* Sets View which should be included in receiving scroll gestures.
* Maybe be null
* @param view you wish to include in scrolling gestures (aka tabs)
*/
public void setDraggableView(View view) {
mDraggableView = view;
}
@Override
public boolean dispatchTouchEvent(@SuppressWarnings("NullableProblems") MotionEvent event) {
if (mSelfUpdateScroll) {
mIsTouchOngoing = false;
mIsDraggingDraggable = false;
mIsScrolling = false;
mIsFlinging = false;
mOverScrollStarted = false;
removeCallbacks(mIdleRunnable);
removeCallbacks(mScrollRunnable);
return super.dispatchTouchEvent(event);
}
final int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mIsTouchOngoing = true;
mScroller.abortAnimation();
if (mDraggableView != null && mDraggableView.getGlobalVisibleRect(mDraggableRect)) {
final int x = (int) (event.getRawX() + .5F);
final int y = (int) (event.getRawY() + .5F);
mIsDraggingDraggable = mDraggableRect.contains(x, y);
} else {
mIsDraggingDraggable = false;
}
} else if (action == MotionEvent.ACTION_UP
|| action == MotionEvent.ACTION_CANCEL){
mIsTouchOngoing = false;
if (mCloseUpAlgorithm != null) {
removeCallbacks(mIdleRunnable);
postDelayed(mIdleRunnable, mConsiderIdleMillis);
}
// great, now we are able to cancel ghost touch when up event Y == mMaxScrollY
if (mEventRedirected) {
if (action == MotionEvent.ACTION_UP) {
final float diff = Math.abs(event.getRawY() - mEventRedirectStartedY);
if (Float.compare(diff, mScaledTouchSlop) < 0) {
event.setAction(MotionEvent.ACTION_CANCEL);
}
}
mEventRedirected = false;
}
cancelOverScroll();
}
final boolean isPrevScrolling = mIsScrolling;
final boolean isPrevFlinging = mIsFlinging;
mIsFlinging = mFlingDetector .onTouchEvent(event);
mIsScrolling = mScrollDetector.onTouchEvent(event);
removeCallbacks(mScrollRunnable);
post(mScrollRunnable);
final boolean isIntercepted = mIsScrolling || mIsFlinging;
final boolean isPrevIntercepted = isPrevScrolling || isPrevFlinging;
final boolean shouldRedirectDownTouch = action == MotionEvent.ACTION_MOVE
&& (!isIntercepted && isPrevIntercepted)
&& getScrollY() == mMaxScrollY;
if (isIntercepted || isPrevIntercepted) {
mMotionEventHook.hook(event, MotionEvent.ACTION_CANCEL);
if (!isPrevIntercepted) {
return true;
}
}
if (shouldRedirectDownTouch) {
mMotionEventHook.hook(event, MotionEvent.ACTION_DOWN);
mEventRedirectStartedY = event.getRawY();
mEventRedirected = true;
}
super.dispatchTouchEvent(event);
return true;
}
private void cancelOverScroll() {
if (mOverScrollListener != null && mOverScrollStarted) {
mOverScrollListener.onCancelled(this);
}
mOverScrollStarted = false;
}
private void cancelIdleAnimationIfRunning(boolean removeCallbacks) {
if (removeCallbacks) {
removeCallbacks(mIdleRunnable);
}
if (mCloseUpAnimator != null
&& mCloseUpAnimator.isRunning()) {
if (mCloseUpUpdateListener != null) {
mCloseUpAnimator.removeUpdateListener(mCloseUpUpdateListener);
}
mCloseUpAnimator.end();
}
}
// @Override
// public void computeScroll() {
// if (mScroller.computeScrollOffset()) {
// final int oldY = getScrollY();
// final int nowY = mScroller.getCurrY();
// scrollTo(0, nowY);
// if (oldY != nowY) {
// onScrollChanged(0, getScrollY(), 0, oldY);
// }
// postInvalidate();
// }
// }
@Override
protected int computeVerticalScrollRange() {
return mMaxScrollY;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
final int count = getChildCount();
if (count > 0) {
int childTop = 0;
for (int i = 0; i < count; i++) {
final View view = getChildAt(i);
view.layout(left, childTop, right, childTop + view.getMeasuredHeight());
childTop += view.getMeasuredHeight();
}
}
}
private boolean canHeaderScroll(int direction) {
return mScrollingHeader != null && mScrollingHeader.canScrollVertically(direction);
}
private final Runnable mScrollRunnable = new Runnable() {
@Override
public void run() {
final boolean isContinue = mScroller.computeScrollOffset();
mSelfUpdateFling = isContinue;
if (isContinue) {
final int y = mScroller.getCurrY();
final int nowY = getScrollY();
final int diff = y - nowY;
if (diff != 0) {
scrollTo(0, y);
}
post(this);
}
}
};
private final Runnable mIdleRunnable = new Runnable() {
@Override
public void run() {
cancelIdleAnimationIfRunning(false);
if (mSelfUpdateScroll || mSelfUpdateFling) {
return;
}
final int nowY = getScrollY();
if (nowY == 0
|| nowY == mMaxScrollY) {
return;
}
final int endY = mCloseUpAlgorithm.getIdleFinalY(ScrollableLayout.this, nowY, mMaxScrollY);
if (nowY == endY) {
return;
}
if (mCloseUpAnimator == null) {
mCloseUpAnimator = ValueAnimator.ofFloat(.0F, 1.F);
mCloseUpAnimator.setEvaluator(new FloatEvaluator());
mCloseUpAnimator.addListener(new SelfUpdateAnimationListener());
} else {
if (mCloseUpUpdateListener != null) {
mCloseUpAnimator.removeUpdateListener(mCloseUpUpdateListener);
}
if (mCloseUpAnimator.isRunning()) {
mCloseUpAnimator.end();
}
}
final int diff = endY - nowY;
mCloseUpUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
final float fraction = animation.getAnimatedFraction();
scrollTo(0, (int) (nowY + (diff * fraction) + .5F));
}
};
mCloseUpAnimator.addUpdateListener(mCloseUpUpdateListener);
final long duration = mCloseUpIdleAnimationTime != null
? mCloseUpIdleAnimationTime.compute(ScrollableLayout.this, nowY, endY, mMaxScrollY)
: DEFAULT_IDLE_CLOSE_UP_ANIMATION;
mCloseUpAnimator.setDuration(duration);
if (mCloseAnimatorConfigurator != null) {
mCloseAnimatorConfigurator.configure(mCloseUpAnimator);
}
mCloseUpAnimator.start();
}
};
private class ScrollGestureListener extends GestureListenerAdapter {
private final int mTouchSlop;
{
final ViewConfiguration vc = ViewConfiguration.get(getContext());
mTouchSlop = vc.getScaledTouchSlop();
}
@SuppressWarnings("NullableProblems")
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
final float absX = Math.abs(distanceX);
// forbid horizontal scrolling
if (absX > Math.abs(distanceY)
|| absX > mTouchSlop) {
return false;
}
// okay, let's break-down the logic for overScroll
// IF overScrollListener is NULL, just do whatever we did
// ELSE
// we start tracking of overScroll ONLY if we are at scrollY == 0 && direction of scroll is -1 (from top to bottom)
// a touch event can `wander` from top to bottom and vice versa
// and we still need to apply this:
// IF direction == -1 -> call `hasOverScroll`
// IF direction == 1 ->
final int y = getScrollY();
final int distance = (int) (distanceY + .5F);
if (mOverScrollListener == null) {
scrollTo(0, y + distance);
return y != getScrollY();
}
final int direction = distance < 0 ? -1 : 1;
if (!mOverScrollStarted) {
mOverScrollStarted = y == 0 && direction == -1;
}
boolean handled = false;
if (mOverScrollStarted) {
// here we need to check what direction is this scroll event
if (direction == 1 && y == 0) {
if (mOverScrollListener.hasOverScroll(ScrollableLayout.this, distance)) {
mOverScrollListener.onOverScrolled(ScrollableLayout.this, distance);
handled = true;
} else {
mOverScrollListener.clear();
mOverScrollStarted = false;
}
} else {
mOverScrollListener.onOverScrolled(ScrollableLayout.this, distance);
}
}
if (!handled) {
scrollTo(0, y + distance);
return y != getScrollY();
} else {
return true;
}
}
}
private class FlingGestureListener extends GestureListenerAdapter {
private static final int MIN_FLING_DISTANCE_DIP = 12;
private final int mMinFlingDistance;
private final float mMinVelocity;
FlingGestureListener(Context context) {
this.mMinFlingDistance = DipUtils.dipToPx(context, MIN_FLING_DISTANCE_DIP);
final ViewConfiguration configuration = ViewConfiguration.get(context);
this.mMinVelocity = configuration.getScaledMinimumFlingVelocity();
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (Math.abs(velocityY) < mMinVelocity) {
return false;
}
if (Math.abs(velocityX) > Math.abs(velocityY)) {
return false;
}
// it looks like this is never true
final int nowY = getScrollY();
if (nowY < 0 || nowY > mMaxScrollY) {
return false;
}
int velocity = -(int) (velocityY + .5F);
// if we have fling over listener and we are NOT in collapsed state -> redirect call
// this will allow to skip unpleasant part with fling over event is dispatched a bit `off`
// also.. we need to make sure that scrolling content cannot be scrolled
if (!mIsDraggingDraggable
&& mOnFlingOverListener != null
&& mMaxScrollY != getScrollY()
&& velocity > 0
&& mCanScrollVerticallyDelegate.canScrollVertically(1)) {
final int maxPossibleFinalY;
final int duration;
// we will pass Integer.MAX_VALUE to calculate the maximum possible fling
mScroller.fling(0, nowY, 0, velocity, 0, 0, 0, Integer.MAX_VALUE);
maxPossibleFinalY = mScroller.getFinalY();
duration = mScroller.getSplineFlingDuration(velocityY);
mOnFlingOverListener.onFlingOver(maxPossibleFinalY - mMaxScrollY, duration);
mScroller.abortAnimation();
}
mScroller.fling(0, nowY, 0, velocity, 0, 0, 0, mMaxScrollY);
if (mScroller.computeScrollOffset()) {
final int suggestedY = mScroller.getFinalY();
if (Math.abs(nowY - suggestedY) < mMinFlingDistance) {
mScroller.abortAnimation();
return false;
}
final int finalY;
if (suggestedY == nowY || mCloseUpAlgorithm == null) {
finalY = suggestedY;
} else {
finalY = mCloseUpAlgorithm.getFlingFinalY(
ScrollableLayout.this,
suggestedY - nowY < 0,
nowY,
suggestedY,
mMaxScrollY
);
mScroller.setFinalY(finalY);
}
final int newY = getNewY(finalY);
return !(finalY == nowY || newY < 0);
}
return false;
}
}
private static class MotionEventHook {
final MotionEventHookCallback callback;
MotionEventHook(MotionEventHookCallback callback) {
this.callback = callback;
}
void hook(MotionEvent event, int action) {
final int historyAction = event.getAction();
event.setAction(action);
callback.apply(event);
event.setAction(historyAction);
}
}
private interface MotionEventHookCallback {
void apply(MotionEvent event);
}
private class SelfUpdateAnimationListener extends AnimatorListenerAdapter {
private boolean mInitialValue;
@Override
public void onAnimationStart(Animator animation) {
mInitialValue = mSelfUpdateScroll;
mSelfUpdateScroll = true;
}
@Override
public void onAnimationEnd(Animator animation) {
mSelfUpdateScroll = mInitialValue;
}
@Override
public void onAnimationCancel(Animator animation) {
mSelfUpdateScroll = mInitialValue;
}
}
@Override
public Parcelable onSaveInstanceState() {
final Parcelable superState = super.onSaveInstanceState();
final ScrollableLayoutSavedState savedState = new ScrollableLayoutSavedState(superState);
savedState.scrollY = getScrollY();
savedState.autoMaxScroll = mAutoMaxScroll;
return savedState;
}
@Override
public void onRestoreInstanceState(Parcelable state) {
if (!(state instanceof ScrollableLayoutSavedState)) {
super.onRestoreInstanceState(state);
return;
}
final ScrollableLayoutSavedState in = (ScrollableLayoutSavedState) state;
super.onRestoreInstanceState(in.getSuperState());
setScrollY(in.scrollY);
mAutoMaxScroll = in.autoMaxScroll;
processAutoMaxScroll(mAutoMaxScroll);
}
private static class ScrollableLayoutSavedState extends BaseSavedState {
int scrollY;
boolean autoMaxScroll;
ScrollableLayoutSavedState(Parcel source) {
super(source);
scrollY = source.readInt();
autoMaxScroll = source.readByte() == (byte) 1;
}
ScrollableLayoutSavedState(Parcelable superState) {
super(superState);
}
@Override
public void writeToParcel(Parcel out, int flags) {
super.writeToParcel(out, flags);
out.writeInt(scrollY);
out.writeByte(autoMaxScroll ? (byte) 1 : (byte) 0);
}
public static final Creator<ScrollableLayoutSavedState> CREATOR
= new Creator<ScrollableLayoutSavedState>() {
@Override
public ScrollableLayoutSavedState createFromParcel(Parcel in) {
return new ScrollableLayoutSavedState(in);
}
@Override
public ScrollableLayoutSavedState[] newArray(int size) {
return new ScrollableLayoutSavedState[size];
}
};
}
}