/* * Copyright (c) 2016 Zhang Hai <Dreaming.in.Code.ZH@Gmail.com> * All Rights Reserved. */ package me.zhanghai.android.douya.ui; import android.animation.ObjectAnimator; import android.annotation.TargetApi; import android.content.Context; import android.graphics.Canvas; import android.os.Build; import android.support.v4.view.InputDeviceCompat; import android.support.v4.view.MotionEventCompat; import android.support.v4.view.ViewCompat; import android.support.v4.view.animation.FastOutSlowInInterpolator; import android.support.v4.widget.EdgeEffectCompat; import android.support.v4.widget.FriendlyScrollerCompat; import android.util.AttributeSet; import android.util.TypedValue; import android.view.MotionEvent; import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.ViewParent; import android.widget.LinearLayout; import butterknife.BindInt; import butterknife.ButterKnife; public class FlexibleSpaceLayout extends LinearLayout { private static final int INVALID_POINTER_ID = -1; @BindInt(android.R.integer.config_mediumAnimTime) int mMediumAnimationTime; private int mTouchSlop; private int mMinimumFlingVelocity; private int mMaximumFlingVelocity; private FlexibleSpaceHeaderView mHeaderView; private FlexibleSpaceContentView mContentView; private int mScroll; private boolean mHeaderCollapsed; private boolean mIsBeingDragged; private int mActivePointerId; private float mLastMotionY; private VelocityTracker mVelocityTracker; private FriendlyScrollerCompat mScroller; private EdgeEffectCompat mEdgeEffectBottom; private float mView_verticalScrollFactor = Float.MIN_VALUE; public FlexibleSpaceLayout(Context context) { super(context); init(getContext(), null, 0, 0); } public FlexibleSpaceLayout(Context context, AttributeSet attrs) { super(context, attrs); init(getContext(), attrs, 0, 0); } public FlexibleSpaceLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(getContext(), attrs, defStyleAttr, 0); } @TargetApi(Build.VERSION_CODES.LOLLIPOP) public FlexibleSpaceLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); init(getContext(), attrs, defStyleAttr, defStyleRes); } private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { ButterKnife.bind(this); setFocusable(true); setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); setOrientation(VERTICAL); setWillNotDraw(false); ViewConfiguration viewConfiguration = ViewConfiguration.get(context); mTouchSlop = viewConfiguration.getScaledTouchSlop(); mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity(); mMaximumFlingVelocity = viewConfiguration.getScaledMaximumFlingVelocity(); mScroller = FriendlyScrollerCompat.create(context); mEdgeEffectBottom = new EdgeEffectCompat(context); } @Override protected void onFinishInflate() { super.onFinishInflate(); mHeaderView = findHeaderView(this); if (mHeaderView == null) { throw new IllegalStateException("Cannot find a FlexibleSpaceHeaderView"); } mContentView = findContentView(this); if (mContentView == null) { throw new IllegalStateException("Cannot find a FlexibleSpaceContentView"); } } private FlexibleSpaceHeaderView findHeaderView(View view) { if (view instanceof FlexibleSpaceHeaderView) { return (FlexibleSpaceHeaderView) view; } else if (view instanceof ViewGroup) { ViewGroup viewGroup = (ViewGroup) view; for (int i = 0, count = viewGroup.getChildCount(); i < count; ++i) { FlexibleSpaceHeaderView headerView = findHeaderView(viewGroup.getChildAt(i)); if (headerView != null) { return headerView; } } } return null; } private FlexibleSpaceContentView findContentView(View view) { if (view instanceof FlexibleSpaceContentView) { return (FlexibleSpaceContentView) view; } else if (view instanceof ViewGroup) { ViewGroup viewGroup = (ViewGroup) view; for (int i = 0, count = viewGroup.getChildCount(); i < count; ++i) { FlexibleSpaceContentView contentView = findContentView(viewGroup.getChildAt(i)); if (contentView != null) { return contentView; } } } return null; } public int getScroll() { return mScroll; } public void scrollTo(int scroll) { if (mScroll == scroll) { return; } mHeaderView.scrollTo(scroll); scroll = Math.max(0, scroll - mHeaderView.getScroll()); mContentView.scrollTo(scroll); int headerScroll = mHeaderView.getScroll(); mScroll = headerScroll + mContentView.getScroll(); if (headerScroll == 0) { mHeaderCollapsed = false; } else if (headerScroll == mHeaderView.getScrollExtent()) { mHeaderCollapsed = true; } } public void scrollBy(int delta) { scrollTo(mScroll + delta); } private void fling(float velocity) { // From AOSP MultiShrinkScroller // TODO: Is this true? // For reasons I do not understand, scrolling is less janky when maxY=Integer.MAX_VALUE // then when maxY is set to an actual value. mScroller.fling(0, mScroll, 0, (int) velocity, 0, 0, -Integer.MAX_VALUE, Integer.MAX_VALUE); ViewCompat.postInvalidateOnAnimation(this); } @Override public boolean onInterceptTouchEvent(MotionEvent event) { switch (MotionEventCompat.getActionMasked(event)) { case MotionEvent.ACTION_DOWN: if (!mScroller.isFinished()) { return true; // updateActivePointerId(event) and clearVelocityTrackerIfHas() should be called // in onTouchEvent(). } else { updateActivePointerId(event); clearVelocityTrackerIfHas(); } break; case MotionEvent.ACTION_MOVE: if (mIsBeingDragged) { return true; } else if (Math.abs(getMotionEventY(event) - mLastMotionY) > mTouchSlop) { return true; } break; case MotionEventCompat.ACTION_POINTER_DOWN: onPointerDown(event); break; case MotionEventCompat.ACTION_POINTER_UP: onPointerUp(event); break; } // updateLastMotion() is called here if the touch event is not to be intercepted, so // otherwise it should always be called in onTouchEvent(). updateLastMotion(event); return false; } @Override public boolean onTouchEvent(MotionEvent event) { switch (MotionEventCompat.getActionMasked(event)) { case MotionEvent.ACTION_DOWN: updateActivePointerId(event); clearVelocityTrackerIfHas(); if (!mIsBeingDragged) { startDrag(); } else { restartDrag(); } break; case MotionEvent.ACTION_MOVE: { float deltaY = getMotionEventY(event) - mLastMotionY; if (deltaY == 0) { break; } if (!mIsBeingDragged && Math.abs(deltaY) > mTouchSlop) { startDrag(); if (deltaY > 0) { deltaY -= mTouchSlop; } else { deltaY += mTouchSlop; } } if (mIsBeingDragged) { onDrag(event, deltaY); } break; } case MotionEvent.ACTION_UP: endDrag(false); break; case MotionEvent.ACTION_CANCEL: endDrag(true); break; case MotionEventCompat.ACTION_POINTER_DOWN: onPointerDown(event); break; case MotionEventCompat.ACTION_POINTER_UP: onPointerUp(event); break; } updateLastMotion(event); return true; } private void onPointerDown(MotionEvent event) { int pointerIndex = MotionEventCompat.getActionIndex(event); mActivePointerId = MotionEventCompat.getPointerId(event, pointerIndex); mLastMotionY = MotionEventCompat.getY(event, pointerIndex); } private void onPointerUp(MotionEvent event) { int pointerIndex = MotionEventCompat.getActionIndex(event); int pointerId = MotionEventCompat.getPointerId(event, pointerIndex); if (pointerId == mActivePointerId) { int newPointerIndex = pointerIndex == 0 ? 1 : 0; mActivePointerId = MotionEventCompat.getPointerId(event, newPointerIndex); mLastMotionY = MotionEventCompat.getY(event, newPointerIndex); } } @Override public boolean onGenericMotionEvent(MotionEvent event) { if (MotionEventCompat_isFromSource(event, InputDeviceCompat.SOURCE_CLASS_POINTER)) { if (event.getActionMasked() == MotionEvent.ACTION_SCROLL) { if (!mIsBeingDragged) { float vscroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL); if (vscroll != 0) { float deltaY = vscroll * View_getScrollFactor(); int oldScrollY = getScrollY(); scrollBy(0, (int) -deltaY); return getScrollY() != oldScrollY; } } } } return super.onGenericMotionEvent(event); } private void startDrag() { abortScrollerAnimation(); requestParentDisallowInterceptTouchEventIfHas(true); mIsBeingDragged = true; } private void restartDrag() { abortScrollerAnimation(); } protected void onDrag(MotionEvent event, float delta) { int oldScroll = mScroll; scrollBy((int) -delta); delta += mScroll - oldScroll; if (delta < 0) { pullEdgeEffectBottom(event, delta); } } protected void pullEdgeEffectBottom(MotionEvent event, float delta) { mEdgeEffectBottom.onPull(-delta / getHeight(), 1f - getMotionEventX(event) / getWidth()); if (!mEdgeEffectBottom.isFinished()) { ViewCompat.postInvalidateOnAnimation(this); } } private void endDrag(boolean cancelled) { if (!mIsBeingDragged) { return; } mIsBeingDragged = false; mEdgeEffectBottom.onRelease(); onDragEnd(cancelled); mActivePointerId = INVALID_POINTER_ID; recycleVelocityTrackerIfHas(); } protected void onDragEnd(boolean cancelled) { boolean startedFling = false; if (!cancelled) { float velocity = getCurrentVelocity(); if (Math.abs(velocity) > mMinimumFlingVelocity) { fling(-velocity); startedFling = true; } } if (!startedFling && mScroll > 0 && mScroll < mHeaderView.getScrollExtent()) { snapHeaderView(); } } @Override public void computeScroll() { if (mScroller.computeScrollOffset()) { int oldScroll = mScroll; int scrollerCurrY = mScroller.getCurrY(); scrollTo(scrollerCurrY); int headerScrollExtent = mHeaderView.getScrollExtent(); int scrollerFinalY = mScroller.getFinalY(); if (mScroll > 0 && mScroll < headerScrollExtent && scrollerFinalY > 0 && scrollerFinalY < headerScrollExtent) { forceScrollerFinished(); snapHeaderView(); } else { if (mScroll > oldScroll && scrollerCurrY > mScroll) { // We did scroll down for some y and the target y is beyond our range. mEdgeEffectBottom.onAbsorb((int) mScroller.getCurrVelocity()); } if (scrollerCurrY < 0 || scrollerCurrY > mScroll) { abortScrollerAnimation(); } ViewCompat.postInvalidateOnAnimation(this); } } } private void snapHeaderView() { ObjectAnimator animator = ObjectAnimator.ofInt(this, SCROLL, mScroll, mHeaderCollapsed ? 0 : mHeaderView.getScrollExtent()); animator.setDuration(mMediumAnimationTime); animator.setInterpolator(new FastOutSlowInInterpolator()); animator.start(); } @Override public void draw(Canvas canvas) { super.draw(canvas); if (shouldDrawEdgeEffectBottom() && !mEdgeEffectBottom.isFinished()) { int count = canvas.save(); int width = getWidth(); int height = getHeight(); canvas.translate(-width, height); canvas.rotate(180, width, 0); mEdgeEffectBottom.setSize(width, height); if (mEdgeEffectBottom.draw(canvas)) { ViewCompat.postInvalidateOnAnimation(this); } canvas.restoreToCount(count); } } protected boolean shouldDrawEdgeEffectBottom() { return true; } private void updateActivePointerId(MotionEvent event) { // ACTION_DOWN always refers to pointer index 0. mActivePointerId = MotionEventCompat.getPointerId(event, 0); } private void updateLastMotion(MotionEvent event) { mLastMotionY = getMotionEventY(event); ensureVelocityTracker().addMovement(event); } private float getMotionEventX(MotionEvent event) { if (mActivePointerId != INVALID_POINTER_ID) { int pointerIndex = MotionEventCompat.findPointerIndex(event, mActivePointerId); if (pointerIndex != -1) { return MotionEventCompat.getX(event, pointerIndex); } else { // Error! } } return event.getX(); } private float getMotionEventY(MotionEvent event) { if (mActivePointerId != INVALID_POINTER_ID) { int pointerIndex = MotionEventCompat.findPointerIndex(event, mActivePointerId); if (pointerIndex != -1) { return MotionEventCompat.getY(event, pointerIndex); } else { // Error! } } return event.getY(); } private VelocityTracker ensureVelocityTracker() { if (mVelocityTracker == null) { mVelocityTracker = VelocityTracker.obtain(); } return mVelocityTracker; } private void clearVelocityTrackerIfHas() { if (mVelocityTracker != null) { mVelocityTracker.clear(); } } protected void recycleVelocityTrackerIfHas() { if (mVelocityTracker != null) { mVelocityTracker.recycle(); mVelocityTracker = null; } } private float getCurrentVelocity() { if (mVelocityTracker == null) { return 0; } mVelocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity); return mVelocityTracker.getYVelocity(mActivePointerId); } protected void abortScrollerAnimation() { mScroller.abortAnimation(); } private void forceScrollerFinished() { mScroller.forceFinished(true); } private void requestParentDisallowInterceptTouchEventIfHas(boolean disallowIntercept) { ViewParent viewParent = getParent(); if (viewParent != null) { viewParent.requestDisallowInterceptTouchEvent(disallowIntercept); } } public static final IntProperty<FlexibleSpaceLayout> SCROLL = new IntProperty<FlexibleSpaceLayout>("scroll") { @Override public Integer get(FlexibleSpaceLayout object) { return object.getScroll(); } @Override public void setValue(FlexibleSpaceLayout object, int value) { object.scrollTo(value); } }; private float View_getScrollFactor() { if (mView_verticalScrollFactor == Float.MIN_VALUE) { Context context = getContext(); TypedValue outValue = new TypedValue(); if (context.getTheme().resolveAttribute(android.R.attr.listPreferredItemHeight, outValue, true)) { mView_verticalScrollFactor = outValue.getDimension( context.getResources().getDisplayMetrics()); } else { throw new IllegalStateException( "Expected theme to define listPreferredItemHeight."); } } return mView_verticalScrollFactor; } private boolean MotionEventCompat_isFromSource(MotionEvent event, int source) { return (MotionEventCompat.getSource(event) & source) == source; } }