仿虾米播放页面

作者: sankemao | 来源:发表于2018-04-23 11:25 被阅读39次

    最近一直在学习Android的事件分发以及自定义View这块内容,正好看到虾米播放页面的交互效果挺炫酷的,就决定仿写一下练练手。
    本文参考自:http://blog.cgsdream.org/2016/12/30/android-nesting-scroll/
    一、效果预览

    虾米原版效果.gif 仿写效果.gif

    二、效果分析

    由预览图不难看出,该自定义view继承自ViewGroup,内部摆放了3个子view,顶部控制播放的hoverVeiw;
    存放音乐封面和播放控制的headerView;以及底部评论列表targetView。且hoverView随评论列表的滑动而显示或隐藏。
    

    三、自定义View的属性

        <declare-styleable name="XiamiPlayLayout">
            <attr name="header_view" format="reference"/>
            <attr name="target_view" format="reference"/>
            <attr name="hover_view" format="reference"/>
            <attr name="header_init_offset"/>
            <--targetView与headerView重合交接处距ViewGroup顶部偏移量-->
            <attr name="target_end_offset"/>
            <--targetView初始化时距ViewGroup顶部偏移量-->
            <attr name="target_init_offset"/>
        </declare-styleable>
    

    四、自定义view套路代码

    public class XiamiPlayLayout extends ViewGroup {
        public XiamiPlayLayout(Context context) {
            this(context, null);
        }
    
        public XiamiPlayLayout(Context context, AttributeSet attrs) {
            super(context, attrs);
            TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.XiamiPlayLayout, 0, 0);
            mHeaderViewId = array.getResourceId(R.styleable.XiamiPlayLayout_header_view, 0);
            mTargetViewId = array.getResourceId(R.styleable.XiamiPlayLayout_target_view, 0);
            mHoverViewId = array.getResourceId(R.styleable.XiamiPlayLayout_hover_view, 0);
    
            mHeaderInitOffset = array.getDimensionPixelSize(R.styleable.XiamiPlayLayout_header_init_offset, Util.dp2px(getContext(), 0));
            mTargetInitOffset = array.getDimensionPixelSize(R.styleable.XiamiPlayLayout_target_init_offset, Util.dp2px(getContext(), 200));
            mHeaderCurrentOffset = mHeaderInitOffset;
            mTargetCurrentOffset = mTargetInitOffset;
            //target滑动终止位置
            mTargetEndOffset = array.getDimensionPixelSize(R.styleable.XiamiPlayLayout_target_end_offset, Util.dp2px(context, 40));
            array.recycle();
    
            ViewCompat.setChildrenDrawingOrderEnabled(this, true);
    
            final ViewConfiguration vc = ViewConfiguration.get(getContext());
            mMaxVelocity = vc.getScaledMaximumFlingVelocity();
            mMinVelocity = vc.getScaledMinimumFlingVelocity();
            mTouchSlop = Util.px2dp(context, vc.getScaledTouchSlop()); //系统的值是8dp,太大了。。。
    
            mScroller = new Scroller(getContext());
            mScroller.setFriction(0.98f);
        }
    

    onFinishInflate当xml文件解析后调用,在该方法内找控件,初始化子view。

        @Override
        protected void onFinishInflate() {
            super.onFinishInflate();
            if (mHeaderViewId != 0) {
                mHeaderView = findViewById(mHeaderViewId);
            }
            if (mTargetViewId != 0) {
                mTargetView = findViewById(mTargetViewId);
                ensureTarget();
            }
            if (mHoverViewId != 0) {
                mHoverView = findViewById(mHoverViewId);
            }
        }
        //targetView必须实现ITargetView接口,下面会讲
        private void ensureTarget() {
            if (mTargetView instanceof ITargetView) {
                mTarget = (ITargetView) mTargetView;
            } else {
                throw new RuntimeException("TargetView should implement interface ITargetView");
            }
        }
    

    五、测量
    自身尺寸测量直接调用super.onMeasure(widthMeasureSpec, heightMeasureSpec);交给系统处理
    然后,测量它的子view的尺寸,调用系统方法即可。在测量之前,需要确保该ViewGrop设置了子view并初始化完毕。

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            ensureHeaderViewAndScrollView();
            final int resizeHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                    MeasureSpec.getSize(heightMeasureSpec) - mTargetEndOffset,
                    MeasureSpec.getMode(heightMeasureSpec));
            measureChild(mTargetView, widthMeasureSpec, resizeHeightMeasureSpec);
            measureChild(mHeaderView, widthMeasureSpec, heightMeasureSpec);
            measureChild(mHoverView, widthMeasureSpec, heightMeasureSpec);
        }
        /**
         * 确认子view,如果没有子view,即onFinishInflate中未找到,那么采用getChildAt(int position)方法寻找子view。
         */
        private void ensureHeaderViewAndScrollView() {
            if (mHeaderView != null && mTargetView != null && mHoverView != null) {
                return;
            }
            if (mHeaderView == null && mTargetView == null && mHoverView == null && getChildCount() >= 3) {
                mHoverView = getChildAt(0);
                mHeaderView = getChildAt(1);
                mTargetView = getChildAt(2);
                ensureTarget();
                return;
            }
            throw new RuntimeException("please ensure headerView and scrollView");
        }
    

    这里有个需要注意的地方,测量headerView,因为可能设置了target_end_offset,所以XiamiPlayLayout留给headerView的空间并不是自身全部高度,而是要减掉target_end_offset的尺寸。因此 measureChild(mTargetView, widthMeasureSpec, resizeHeightMeasureSpec);heightMeasureSpec改为->resizeHeightMeasureSpec

    六、修改子view绘制顺序
    首先在构造方法中开启允许重绘

    ViewCompat.setChildrenDrawingOrderEnabled(this, true);
    

    先绘制headerView,在绘制hoverView,最后绘制targetView。

        @Override
        protected int getChildDrawingOrder(int childCount, int i) {
            ensureHeaderViewAndScrollView();
            int hoverIndex = indexOfChild(mHoverView);
            int headerIndex = indexOfChild(mHeaderView);
            if (hoverIndex == i) {
                return 1;
            } else if (headerIndex == i) {
                return 0;
            } else {
                return 2;
            }
        }
    

    七、摆放
    计算好各个子view初始化时的坐标,一次调用它们的layout方法摆放,没啥好讲的。

        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            final int width = getMeasuredWidth();
            final int height = getMeasuredHeight();
            if (getChildCount() == 0) {
                return;
            }
            ensureHeaderViewAndScrollView();
    
            final int childLeft = getPaddingLeft();
            final int childTop = getPaddingTop();
            final int childWidth = width - getPaddingLeft() - getPaddingRight();
            final int childHeight = height - getPaddingTop() - getPaddingBottom();
            mTargetView.layout(childLeft, childTop + mTargetCurrentOffset,
                    childLeft + childWidth, childTop + childHeight + mTargetCurrentOffset);
    
            mHeaderViewWidth = mHeaderView.getMeasuredWidth();
            mHeaderViewHeight = mHeaderView.getMeasuredHeight();
            mHeaderView.layout((width / 2 - mHeaderViewWidth / 2), mHeaderCurrentOffset,
                    (width / 2 + mHeaderViewWidth / 2), mHeaderCurrentOffset + mHeaderViewHeight);
    
            mHoverViewWidth = mHoverView.getMeasuredWidth();
            mHoverViewHeight = mHoverView.getMeasuredHeight();
            mHoverView.layout((width - mHoverViewWidth) / 2, -mHoverViewHeight, (width / 2 + mHoverViewWidth /2), 0);
        }
    
    

    八、重头戏,事件拦截分发
    虽然所有事件都会走dispatchTouchEvent,但重写处理比较复杂,所以一般套路是重写onInterceptTouchEvent和onTouchEvent。
    onInterceptTouchEvent处理思路:

    ACTION_DOWN事件中记录手指按下坐标。
    ACTION_MOVE事件中,根据手指滑动而变化的实时坐标与按下坐标比对,决定是否拦截。
    在这里,先要弄清楚拦截事件意味着什么?当move事件拦截,此次事件及后续的事件就不会再向子view传递,转而都交给自身的onTouchEvent处理!
    什么情况下需要拦截呢?当需要滑动headerView的情况下拦截事件,所以只需要判决targetView需要滑动的时候的边界情况。
    1、下拉,且targetView中的滑动控件不可再下拉了,那么就拦截事件让XiamiPlayLayout自身处理。
    2、上拉,且targetView还没到顶的时候,那么就拦截事件让XiamiPlayLayout自身处理。
    
        @Override
        public boolean onInterceptTouchEvent(MotionEvent ev) {
            ensureHeaderViewAndScrollView();
            final int action = MotionEvent.getActionMasked(ev);
            int pointerIndex;
            //如果该控件不可用,不拦截,向下传递事件。
            if(!isEnabled()) return false;
            switch (action) {
                case MotionEvent.ACTION_DOWN:
                    //记录当前活动的pointerId(处理多指触摸)
                    mActivePointerId = ev.getPointerId(0);
                    mIsDragging = false;
                    pointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (pointerIndex < 0) {
                        return false;
                    }
                    //记录按下时的坐标
                    mInitialDownY = ev.getY(pointerIndex);
                    mInitialDownX = ev.getX(pointerIndex);
                    break;
                case MotionEvent.ACTION_MOVE:
                    pointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (pointerIndex < 0) {
                        Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
                        return false;
                    }
                    //记录触摸移动后的坐标
                    final float y = ev.getY(pointerIndex);
                    final float x = ev.getX(pointerIndex);
                    // mIsDragging置为true,拦截事件,交由自身处理
                    startDragging(y);
                    if (mIsDragging) {
                        //过滤掉左右滑动距离大于上下滑动距离的情况。不然左右滑动viewpager也会导致上下滑动。
                        if (Math.abs(x - mInitialDownX) > Math.abs(y - mInitialDownY)) {
                            mIsDragging = false;
                        }
                    }
                    break;
    
                case MotionEvent.ACTION_POINTER_UP:
                    //多指
                    onSecondaryPointerUp(ev);
                    break;
    
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    //取消拦截事件
                    mIsDragging = false;
                    mActivePointerId = INVALID_POINTER;
                    break;
            }
            return mIsDragging;
        }
    
        private void startDragging(float y) {
            //下拉且targetView不可再下拉了 或者 上拉且targetView可以往上拖拽
            if ((y > mInitialDownY && !mTarget.canChildScrollUp()) ||
                    (y < mInitialDownY && mTargetCurrentOffset > mTargetEndOffset)) {
                    final float yDiff = Math.abs(y - mInitialDownY);
                    if (yDiff > mTouchSlop && !mIsDragging) {
                        //初始移动的坐标
                        mInitialMotionY = y;
                        Log.e("startDragging: ", mInitialMotionY + " ");
                        mLastMotionY = mInitialMotionY;
                        mIsDragging = true;
                    }
            }
        }
    

    onTouchEvent处理思路:
    mIsDragging为true时,事件交给了XiamiPlayLayout处理,在onTouchEvent中移动targetView和hoverView。
    这里有两个坑点需要解决:

    1. 下拉,直接调用moveTargetView(float dy)方法移动两个子view即可。试想下这种特殊情况:刚开始是targetView内部滑动控件滑动,当内部滑动控件不可再下拉时,事件需要经过XiamiPlayLayout的onInterceptTouchEvent拦截,不再分发给内部滑动控件,转交给onTouchEvent处理,targetView和hoverView移动。
      但在这里有个坑点,注意加粗的部分,事件一定都经过onInterceptTouchEvent吗?不是的!当子View处理事件后,
      有一种情况是子View主动调用parent.requestDisallowInterceptTouchEvent(true)来告诉系统说:这个事件我要了,父View不要拦截了。这就是所谓的内部拦截法。在ListView的某些时刻它会去调用这个方法。因此一旦事件传递给了ListView,外部容器就拿不到这个事件了。因此我们要打破它的内部拦截:
    @Override
    public void requestDisallowInterceptTouchEvent(boolean b) {
        // 去掉默认行为,使得每个事件都会经过这个Layout
    }
    
    1. 上拉,上拉也有它特殊的地方,同样试想一下:刚开始是targetView在往上移动,当移至顶部(mTargetEndOffset位置)时,targetView不能继续往上滑了,转而要将事件交给targetView的内部滑动控件上拉滑动。但我们知道,android系统在事件派发时,如果事件被父View处理,即被父View拦截,那么之后的事件都将不会传递给子view了。其解决方案也很简单:在滚动到顶部时主动派发一次Down事件:
    if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
        moveTargetView(dy);
        // 重新dispatch一次down事件,使得列表可以继续滚动
        int oldAction = ev.getAction();
        ev.setAction(MotionEvent.ACTION_DOWN);
        dispatchTouchEvent(ev);
        ev.setAction(oldAction);
    } else {
        moveTargetView(dy);
    }
    

    好了,解决了这两个坑点后就能愉快的写代码了:

    @Override
        public boolean onTouchEvent(MotionEvent ev) {
            final int action = MotionEventCompat.getActionMasked(ev);
            int pointerIndex;
            if(!isEnabled()) return false;
            //初始化速度追踪器
            acquireVelocityTracker(ev);
            switch (action) {
                case MotionEvent.ACTION_DOWN:
                    //获取活动的pointerId
                    mActivePointerId = ev.getPointerId(0);
                    break;
                case MotionEvent.ACTION_MOVE: {
                    pointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (pointerIndex < 0) {
                        Log.e(TAG, "Got ACTION_MOVE event but have an invalid active pointer id.");
                        return false;
                    }
                    final float y = ev.getY(pointerIndex);
                    //自身开始targetView的拖动,非targetView中列表
                    if (mIsDragging) {
                        float dy = y - mLastMotionY;
                        if (dy >= 0) {
                            //下拉,直接移动targetView.
                            moveTargetView(dy);
                        } else {
                            //上拉
                            if (mTargetCurrentOffset + dy <= mTargetEndOffset) {
                                //如果偏移量减去移动距离后,偏移量小于等于0,移动targetView
                                moveTargetView(dy);
                                // 重新dispatch一次down事件,使得列表可以继续滚动
                                int oldAction = ev.getAction();
                                ev.setAction(MotionEvent.ACTION_DOWN);
                                dispatchTouchEvent(ev);
                                ev.setAction(oldAction);
                            } else {
                                //否则直接移动targetView.
                                moveTargetView(dy);
                            }
                        }
                        mLastMotionY = y;
                    }
                    break;
                }
                case MotionEvent.ACTION_POINTER_DOWN: {
                    pointerIndex = ev.getActionIndex();
                    if (pointerIndex < 0) {
                        Log.e(TAG, "Got ACTION_POINTER_DOWN event but have an invalid action index.");
                        return false;
                    }
                    mActivePointerId = ev.getPointerId(pointerIndex);
                    //初始按下坐标以及上次按下坐标都得转移到这根手指所处坐标。
                    mInitialMotionY = ev.getY(pointerIndex);
                    mLastMotionY = mInitialMotionY;
                    break;
                }
                case MotionEvent.ACTION_POINTER_UP:
                    onSecondaryPointerUp(ev);
                    break;
                case MotionEvent.ACTION_UP: {
                    pointerIndex = ev.findPointerIndex(mActivePointerId);
                    if (pointerIndex < 0) {
                        Log.e(TAG, "Got ACTION_UP event but don't have an active pointer id.");
                        return false;
                    }
                    if (mIsDragging) {
                        mIsDragging = false;
                        //计算瞬时速度
                        mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
                        final float vy = mVelocityTracker.getYVelocity(mActivePointerId);
                        finishDrag((int) vy);
                    }
                    mActivePointerId = INVALID_POINTER;
                    releaseVelocityTracker();
                    return false;
                }
                case MotionEvent.ACTION_CANCEL:
                    releaseVelocityTracker();
                    return false;
            }
    
            return mIsDragging;
        }
    

    下面是移动headerView以及hoverView的方法。

        private void moveTargetView(float dy) {
            int target = (int) (mTargetCurrentOffset + dy);
            moveTargetViewTo(target);
        }
    
    private void moveTargetViewTo(int target) {
            //设定最小偏移量
            target = Math.max(target, mTargetEndOffset);
            //设定最大偏移量
            target = Math.min(target, mTargetInitOffset);
            //偏移dy
            ViewCompat.offsetTopAndBottom(mTargetView, target - mTargetCurrentOffset);
            //记录当前偏移量
            mTargetCurrentOffset = target;
    
            //当targetView 偏移量为0(mTargetEndOffset)的时候,hover向下偏移mHoverViewHeight
            //当targetView 偏移量为mTargetInitOffset的时候,hover偏移量为0
            if (mTargetCurrentOffset <= mTargetInitOffset && mTargetCurrentOffset >= mTargetEndOffset) {
                int total = mTargetInitOffset - mTargetEndOffset;
                float percent =  (mTargetCurrentOffset - mTargetEndOffset) * 1.0f / total;
                ViewCompat.setTranslationY(mHoverView, mHoverViewHeight * (1-percent));
            }
        }
    

    九、接口补充说明
    上面提到了一个方法:

        private void ensureTarget() {
            if (mTargetView instanceof ITargetView) {
                mTarget = (ITargetView) mTargetView;
            } else {
                throw new RuntimeException("TargetView should implement interface ITargetView");
            }
        }
    

    将mTargetView强转为了ITargetView接口,ITargetView接口定义如下:

        public interface ITargetView {
            //报告targetView自身是否可以下拉。
            boolean canChildScrollUp();
            //交给targetView自身处理fling
            void fling(float vy);
        }
    

    因为我们并不知道targetView中子view是啥,可能是ScrollView、listView? 还是RecyclerView?所以我们索性定义了接口,让targetView实现。这也体现了面向对象的依赖倒置原则。

    十、惯性滚动
    当手指松开后,view的滑动会立即停止,显得十分生硬。为了让滑动看上去更自然些,需要加入惯性滑动效果。需要用到VelocityTracker以及Scroller这两个类来处理。
    onTouchEvent方法中,每个事件到来,都加入VelocityTracker中,在ACTION_UP以及ACTION_CANCEL中释放releaseVelocityTracker。

    public boolean onTouchEvent(MotionEvent ev) {
        ...
        acquireVelocityTracker(ev);
        ...
        case MotionEvent.ACTION_UP:
            ...
            releaseVelocityTracker();
            break;
        case MotionEvent.ACTION_CANCEL:
            releaseVelocityTracker();
            return false;
    }
    
    private void acquireVelocityTracker(final MotionEvent event) {
        if (null == mVelocityTracker) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);
    }
    
    private void releaseVelocityTracker() {
            if (null != mVelocityTracker) {
                mVelocityTracker.clear();
                mVelocityTracker.recycle();
                mVelocityTracker = null;
            }
        }
    

    ACTION_UP时,获取伪瞬时速度,并调用finishDrag(int vy)处理滚性滑动:

    mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity);
    final float vy = mVelocityTracker.getYVelocity(mActivePointerId);
    finishDrag((int) vy);
    
    private void finishDrag(int vy) {
            Log.i(TAG, "TouchUp: vy = " + vy);
            //手指滑动起点坐标 - 手指滑动终点坐标
            //有时我们下拉fling,手指抬起瞬间有轻微的反方向滑动,导致vy<0,targetView反向fling,,加上该判断过滤这种情况
            final float diffY = mLastMotionY - mInitialMotionY;
    
            if (vy > 0 && diffY > 0) {
                //下拉
                mNeedScrollToInitPos = true;
                mScroller.fling(0, mTargetCurrentOffset, 0, vy, 0, 0, mTargetEndOffset, Integer.MAX_VALUE);
                invalidate();
            } else if (vy < 0 && diffY < 0) {
                //上拉
                mNeedScrollToEndPos = true;
                mScroller.fling(0, mTargetCurrentOffset, 0, vy, 0, 0, mTargetEndOffset, Integer.MAX_VALUE);
                invalidate();
            } else {
                if (mTargetCurrentOffset <= (mTargetEndOffset + mTargetInitOffset) / 2) {
                    mNeedScrollToEndPos = true;
                } else {
                    mNeedScrollToInitPos = true;
                }
                invalidate();
            }
        }
    

    最后在computeScroll中移动View:

        @Override
        public void computeScroll() {
            if (mScroller.computeScrollOffset()) {
                //fling阶段
                Log.d(TAG, "computeScroll: " + "开始fling" + mScroller.getCurrY());
                int offsetY = mScroller.getCurrY();
                moveTargetViewTo(offsetY);
                invalidate();
            } else if (mNeedScrollToInitPos) {
                //fling结束后,回滚到初始位置
                mNeedScrollToInitPos = false;
                if (mTargetCurrentOffset >= mTargetInitOffset) {
                    return;
                }
                Log.d(TAG, "computeScroll: " + "fling结束后,回到下面");
                mScroller.startScroll(0, mTargetCurrentOffset, 0, mTargetInitOffset - mTargetCurrentOffset);
                invalidate();
            } else if (mNeedScrollToEndPos) {
                //fling结束后,回滚到顶点
                mNeedScrollToEndPos = false;
                if (mTargetCurrentOffset <= mTargetEndOffset) {
                    if (mScroller.getCurrVelocity() > 0) {
                        // 如果还有速度,则传递给子view
                        mTarget.fling(-mScroller.getCurrVelocity());
                        Log.d(TAG, "computeScroll: " + "传递速度" + -mScroller.getCurrVelocity());
                    }
                }
                mScroller.startScroll(0, mTargetCurrentOffset, 0, mTargetEndOffset - mTargetCurrentOffset);
                invalidate();
            }
        }
    

    十一、项目地址
    最后,该demo的github地址:https://github.com/sankemao/XiamiPlayView

    相关文章

      网友评论

      • deviche:用coordinatelayout实现应该代码简单点

      本文标题:仿虾米播放页面

      本文链接:https://www.haomeiwen.com/subject/ljaakftx.html