安卓View学习Android开发UI效果仿写

仿虾米播放页面

2018-04-23  本文已影响39人  sankemao

最近一直在学习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

上一篇下一篇

猜你喜欢

热点阅读