一个简单的Android自定义view详解

众所周知,自定义view是衡量一个安卓开发人员水平的指标之一,但是自定义view的难度还是有的,需要我们熟知view的加载的三大流程:measure,layout,draw。

对于初涉安卓的技术小白来说,想要学习这些内容是有点困难的,阅读源码需要有相当足够的耐心,和不算差的英语水平。在这里博主向大家推荐任玉刚大神的《Android开发艺术探索》,书中详解了view的事件体系还有工作原理。本篇文章,就是对于其中一个自定义view的demo的详细解读,因为demo中注释较少,向我这种技术菜分析起来还是有点困难的。友情提示,阅读本文的前提是您已经对view的加载机制有有所了解,同时,本例中没有对自定义view的margin和padding属性进行处理

好了,闲话少说,先把书中的demo贴出来(有些许改动):

package com.quicker.customview.custom;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;

/**
 * Created by ZhangYang on 2017/3/20.
 */

public class MyScrollView extends ViewGroup {

    private static final String TAG = "MyScrollView";

    private int mChildrenSize;
    private int mChildWidth;
    private int mChildIndex;

    //分别记录上次滑动的坐标
    private int mLastX;
    private int mLastY;
    //分别记录上次滑动的坐标(onInterceptTouchEvent)
    private int mLastXIntercept = 0;
    private int mLastYIntercept = 0;

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;

    private void init() {
        if (mScroller == null) {
            mScroller = new Scroller(getContext());
            mVelocityTracker = VelocityTracker.obtain();
        }
    }

    /**
     * 在这个方法中解决滑动冲突
     *
     * @param ev 手指在屏幕上产生的事件
     * @return 如果返回true,则改控件拦截事件的分发,同时自己消费掉事件
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {

        boolean intercepted = false;
        int x = (int) ev.getRawX();             //相对于屏幕左上角的坐标
        int y = (int) ev.getRawY();              //相对于屏幕左上角的坐标
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastXIntercept;                    //获取水平方向滑动的距离
                int deltaY = y - mLastYIntercept;                    //获取垂直方向滑动的距离
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    intercepted = true;                              //解决和ListView的滑动冲突
                } else {
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        mLastYIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

    //解决外部的自定义view是如何消费滑动事件的
    @Override
    public boolean onTouchEvent(MotionEvent event) {

        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                scrollBy(-deltaX, 0);
                Log.d("SCROLL", "move    " + deltaX);
                break;

/*----------------------------------------手指离开屏幕时,滑动方式的算法--------------------------------------*/
            case MotionEvent.ACTION_UP:
                int scrollX = getScrollX();
                Log.d("RXRX", "scrollX   " + scrollX);                   //没有什么事情是log解决不了的
                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                if (Math.abs(xVelocity) >= 50) {
                    mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                } else {
                    mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                //精妙
                mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                smoothScrollBy(dx, 0);
                mVelocityTracker.clear();
                break;
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

    /**
     * 两个参数是根据父view的MeasureSpec和此view的layoutParams计算得出
     * @param widthMeasureSpec
     * @param heightMeasureSpec
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        //调用此方法去测量所有子view的宽高
        //验证了此onMeasure上面的注释
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        final int childCount = getChildCount();
        final View childView = getChildAt(0);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);

        if (childCount == 0) {
            setMeasuredDimension(0, 0);         //如果没有子view,这设置父view为空
        } else {
            if (MeasureSpec.AT_MOST == widthMode && MeasureSpec.AT_MOST == heightMode) {
                //这样做的前提是所有的子元素的宽高都相同,要不然需要分别计算
                setMeasuredDimension(childView.getMeasuredWidth() * childCount, childView.getMeasuredHeight());
            } else if (MeasureSpec.AT_MOST == widthMode) {
                setMeasuredDimension(childView.getMeasuredWidth() * childCount, heightSize);
            } else if (MeasureSpec.AT_MOST == heightMode) {
                setMeasuredDimension(widthSize, childView.getMeasuredHeight());
            }
        }
    }

    //根据子view的数量,宽高来放置
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {

        int childLeft = 0;
        mChildrenSize = getChildCount();
        for (int i = 0; i < getChildCount(); i++) {
            final View childView = getChildAt(i);
            if (View.GONE != childView.getVisibility()) {
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
                childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }

    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
    }

    private void smoothScrollBy(int dx, int dy) {
        mScroller.startScroll(getScrollX(), 0, dx, 0, 2000);
        invalidate();
    }

    @Override
    protected void onDetachedFromWindow() {
        mVelocityTracker.recycle();
        super.onDetachedFromWindow();
    }

    public MyScrollView(Context context) {
        super(context);
        init();
    }


    public MyScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public MyScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }


}

完整代码如上,本例是对ViewPager的简单模仿,运行效果是在这个自定义view里面add三个宽高相等的,可以上下滑动的布局。左右滑动切换页面,上下滑动展示更多数据。因为本view可以左右滑动,而子view中的listView可以上下滑动,因此我们需要结局滑动冲突的问题。

我们首先来看滑动冲突的解决方法:

public boolean onInterceptTouchEvent(MotionEvent ev) {

        boolean intercepted = false;
        int x = (int) ev.getRawX();
        int y = (int) ev.getRawY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                    intercepted = true;
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastXIntercept;                               //获取水平方向滑动的距离
                int deltaY = y - mLastYIntercept;                               //获取垂直方向滑动的距离
                if (Math.abs(deltaX) > Math.abs(deltaY)) {
                    intercepted = true;                                         //解决和ListView的滑动冲突
                } else {
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        mLastYIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

采用外部拦截法,本自定义view就是所谓的外部的view,因此我们需要在此view的onInterceptTouchEvent中根据实际的滑动操作,来判断外部的view是否要响应该事件。因为是个左右滑动的view的里面嵌套了上下滑动的listView,因此很容易想到,当用户左右滑动的时候,外部的view是应该跟随手指移动的,也就是响应了滑动事件。因此,当ACTION_MOVE时我们计算出来手指在屏幕水平、垂直方向的滑动距离,如果水平方向大于垂直方向,则设置intercepted为true,表示拦截掉事件,自己在onTouchEvent方法中消费掉,不向子view传递事件。

if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                    intercepted = true;
                }

上述代码的意思是当手指落在屏幕的时候,如果当前view正在滑动,则停止滑动,并且拦截事件。

实现滑动
上述讲了如何解决滑动冲突,那么,当我们拦截了事件之后,父view又该如何消费事件呢?放在场景中分析就是,如何像ViewPager一样实现以下功能:
1、view跟随手指滑动
2、手指在屏幕上大距离滑动时,view的切换
3、view的惯性滑动

 @Override
    public boolean onTouchEvent(MotionEvent event) {

        mVelocityTracker.addMovement(event);
        int x = (int) event.getX();
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                scrollBy(-deltaX, 0);
                Log.d("SCROLL", "move    " + deltaX);
                break;

/*----------------------------------------手指离开屏幕时,滑动方式的算法--------------------------------------*/
            case MotionEvent.ACTION_UP:
                int scrollX = getScrollX();

                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                if (Math.abs(xVelocity) >= 50) {
                    mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                } else {
                    mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                smoothScrollBy(dx, 0);
                mVelocityTracker.clear();
                break;
            default:
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

首先,ACTION_DOWN不用讲,和onInterceptTouchEvent中一样,使用,但是朋友会对

int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                scrollBy(-deltaX, 0);

这个地方产生疑问,我来解释一下,scrollBy是view方法,此处省略了this. ,你懂得,参数是水平方向和垂直方向的偏移量,这个偏移量,和以往的认知相反,计算方式是起始位置减去结束位置,所以前面加了个负号。VelocityTracker是速度追踪,他能够追踪某个事件的速度,在本例中就是手指在屏幕上滑动时的速度。通过addMovement方法添加要追踪的事件。

接下来就到了最重要的ACTION_UP环节啦:

case MotionEvent.ACTION_UP:
                int scrollX = getScrollX();

                mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                if (Math.abs(xVelocity) >= 50) {
                    mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                } else {
                    mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                smoothScrollBy(dx, 0);
                mVelocityTracker.clear();
                break;

在ACTION_MOVE中我们实现了view跟随手指进行移动,使用的方法是view的scrollBy方法,很简单,但是当手指松开的时候,需要参考viewPager的实现效果—-如果手指离开屏幕时,手指在屏幕上横向移动了很大的距离,假设超过屏幕宽度一半,view会进行惯性滑动翻页,或者是手指离开屏幕时没有滑动很大距离,但是离开的瞬间横向滑动的速度足够快,view也会进行横向的惯性滑动进行翻页(以上为了表述方便忽略了临界时不翻页的情况,但是代码中有涉及,我也会进行相应的分析)。关于惯性滑动或者说是回弹到当前页面,任玉刚大神使用一个方法实现,那就是smoothScrollBy(dx, 0)。我们来看他的实现方法:

 private void smoothScrollBy(int dx, int dy) {
        mScroller.startScroll(getScrollX(), 0, dx, 0, 2000);
        invalidate();
    }

小伙伴也许会惊讶:WTF?这什么鬼,这样就可以实现惯性滑动和回弹到当前页面?不多说,上源码。

/**
     * Start scrolling by providing a starting point, the distance to travel,
     * and the duration of the scroll.
     * 
     * @param startX Starting horizontal scroll offset in pixels. Positive
     *        numbers will scroll the content to the left.
     * @param startY Starting vertical scroll offset in pixels. Positive numbers
     *        will scroll the content up.
     * @param dx Horizontal distance to travel. Positive numbers will scroll the
     *        content to the left.
     * @param dy Vertical distance to travel. Positive numbers will scroll the
     *        content up.
     * @param duration Duration of the scroll in milliseconds.
     */
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        mMode = SCROLL_MODE;
        mFinished = false;
        mDuration = duration;
        mStartTime = AnimationUtils.currentAnimationTimeMillis();
        mStartX = startX;
        mStartY = startY;
        mFinalX = startX + dx;
        mFinalY = startY + dy;
        mDeltaX = dx;
        mDeltaY = dy;
        mDurationReciprocal = 1.0f / (float) mDuration;

其实主要看方法注释,意思是说提供的位置点坐标,和滑动的距离,持续时间,开始滑动,怎么样,是不是感觉view滑动的神秘面纱被揭开了?
然后调用invaliafate方法通知view刷新即可。
方法很简单,那么重点就是smoothScrollBy方法的参数了。那么我们就在以下代码代码中来看一看参数dx代表的什么意思:

mVelocityTracker.computeCurrentVelocity(1000);
                float xVelocity = mVelocityTracker.getXVelocity();
                if (Math.abs(xVelocity) >= 50) {
                    mChildIndex = xVelocity > 0 ? mChildIndex - 1 : mChildIndex + 1;
                } else {
                    mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth;
                }
                mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));
                int dx = mChildIndex * mChildWidth - scrollX;
                smoothScrollBy(dx, 0);

首先mVelocityTracker.computeCurrentVelocity(1000)的意思是计算速度,然后有了这一部才可以获取速度float xVelocity = mVelocityTracker.getXVelocity(),速度的单位就是上面的参数1000毫秒内滑动像素数。然后获取滚动偏移量,这是我起得名字,他代表的确切含义不好说明白,需要读者通过log查看和思考。如果水平方向的速度大于50的话,我们就让view惯性滑到另一页,怎么指定滑到下一页呢?暂时,注意我们用的是暂时,使用全局变量mChildIndex,来保存要跳转到的view的索引。这时候值可能为-1和3,待会会进行处理。如果手指离开屏幕时速度不够快的话,我们就要判断当前的view的滑动偏移量是否超过了屏幕的一般。mChildIndex = (scrollX + mChildWidth / 2) / mChildWidth。理解这个公式的前提是理解getScroolX所代表的意义。
mChildIndex = Math.max(0, Math.min(mChildIndex, mChildrenSize - 1));这句话使用比较优雅的方式过滤掉了超出子view索引值的mChildIndex。

由于时间关系,就介绍到这里了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值