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];
    		}
    	};
    }
}