Android 开发艺术探索读书笔记 4 -- View 的工作原理(下)

本篇文章主要介绍以下几个知识点:

  • 自定义 View:分类、须知、实例
  • 自定义 View 的思想
hello,夏天 (图片来源于网络)

4.4 自定义 View

4.4.1 自定义 View 的分类

  自定义 View 的分类标准不唯一,这里将其分为 4 类:

(1)继承 View 重写 onDraw 方法
  主要用于实现一些不规则的效果,需要通过绘制的方式来完成,重写 onDraw。采用此方式需要自身支持 warp_content,并且处理 padding。

(2)继承 ViewGroup 派生特殊的 Layout
  主要用于实现自定义的布局,如实现某些看起来像几种 View 组合在一起的效果。采用此方式需要合适地处理 ViewGroup 的测量、布局这两个过程,并同时处理子元素的这两个过程。

(3)继承特定的 View
  主要用于实现扩展某种已有的 View 的功能,如 TextView。采用此方式不需要自己支持 warp_content 和 padding 等。

(4)继承特定的 ViewGroup(如 LinearLayout)
  和上述方式2 类似,区别在于方式2 更接近 View 的底层。

4.4.2 自定义 View 的须知

  自定义 View 的一些注意事项:

(1)让 View 支持 warp_content
  直接继承 View 或 ViewGroup 的控件,若不在 onMeasure 中对 wrap_content 做特殊处理,可能无法达到预期效果(具体情形看之前的 4.3.1)。

(2)如果有必要,让你的 View 支持 padding
  直接继承 View 的控件,若不在 draw 中处理 padding,则 padding 属性无效。继承 ViewGroup 的控件也要处理。

(3)尽量不要在 View 中使用 Handler
  View 本身提供了 post 系列的方法,完全可替代 Handler。

(4)View 中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow
  避免内存泄漏。

(5)View 带有滑动嵌套时,要处理好滑动冲突
  处理好滑动冲突,否则影响 View 的效果。

4.4.3 自定义 View 的实例

4.4.3.1 继承 View 重写 onDraw 方法

  下面来绘制一个简单的圆。在实现过程中需考虑 wrap_content 和 padding,代码如下:

public class CircleView extends View{

    // 颜色
    private int mColor = Color.RED;
    // 画笔样式
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

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

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

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

    // 初始化
    private void init() {
        mPaint.setColor(mColor);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // View 的宽
        int width = getWidth();
        // View 的高
        int height = getHeight();
        // 圆的半径
        int radius = Math.min(width, height) / 2;
        // 绘制圆
        canvas.drawCircle(width / 2, height / 2, radius, mPaint);
    }
}

  上面的代码就实现了一个圆的自定义 View,运行效果如下:

自定义圆效果

  上面的自定义圆代码很简单,只是一中初级的实现,并不是一个规范的自定义 View,若将布局参数调整如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.wonderful.androidartexplore.chapter04.CircleView
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#000000"/>

</LinearLayout>

  运行效果如下(符合预期效果):

圆效果 01

  接下来再调整一下,添加布局参数:

android:layout_margin="20dp"

  运行效果如下(符合预期效果,因为 margin 是由父容器控制的,不需要在 CircleView 中特殊处理):

圆效果 02

  接下来继续调整一下,添加布局参数:

 android:padding="20dp"

  发现运行效果和效果02 一样,即设置的 padding 无效。这是因为继承自 View 和 ViewGroup 的控件,padding 是默认无法生效的,需自己处理。

  将宽度设为 wrap_content,运行后也和效果02 一样,即使用 wrap_contentmatch_parent 无区别。这是因为继承自 View 的控件,若不对 wrap_content 做特殊处理,则 wrap_content 相当于 match_parent

  为解决上述的问题,可做如下处理:

  针对 wrap_content 问题,只需指定一个 wrap_content 模式的默认宽高即可(如 200px)。

  针对 padding 问题,只需绘制时考虑,修改 onDraw 如下:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // padding 的值
        final int paddingLeft = getPaddingLeft();
        final int paddingRight = getPaddingRight();
        final int paddingTop = getPaddingTop();
        final int paddingBottom = getPaddingBottom();
        // View 的宽
        int width = getWidth() - paddingLeft - paddingRight;
        // View 的高
        int height = getHeight() - paddingTop - paddingBottom;
        // 圆的半径
        int radius = Math.min(width, height) / 2;
        // 绘制圆
        canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
    }

  运行效果如下:

圆效果 03

  接下来,介绍如何提供一些自定义的属性。

  第一步,在 values 目录下创建自定义属性的 XML,创建 attrs.xml 文件如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 声明自定义属性集合 CircleView
         其中 format 是指类型,如 颜色 "color"、资源id "reference" 等-->
    <declare-styleable name="CircleView">
        <!-- 颜色 -->
        <attr name="circle_color" format="color" />
    </declare-styleable>

</resources>

  第二步,在 View 的构造方法中解析自定义属性的值并做相应的处理,如下:

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 1. 加载自定义属性集合 CircleView
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
        // 2. 解析 CircleView 集合中的属性
        // 这里解析其 circle_color 属性(若没指定,则默认红色)
        mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED);
        // 3. 实现资源
        a.recycle();
        init();
    }

  第三步,在布局文件中使用自定义属性,如下:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <com.wonderful.androidartexplore.chapter04.CircleView
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="#000000"
        android:layout_margin="20dp"
        android:padding="20dp"
        app:circle_color="@color/colorAccent"/>

</LinearLayout>

  上面值得注意的是,为了使用自定义属性,必须在布局文件中添加 schemas 声明:xmlns:app="http://schemas.android.com/apk/res-auto"。运行效果如下:

圆效果 04

  附:完整代码如下:

public class CircleView extends View{

    // 颜色
    private int mColor = Color.RED;
    // 画笔样式
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

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

    public CircleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        // 1. 加载自定义属性集合 CircleView
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
        // 2. 解析 CircleView 集合中的属性
        // 这里解析其 circle_color 属性(若没指定,则默认红色)
        mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED);
        // 3. 实现资源
        a.recycle();
        init();
    }

    // 初始化
    private void init() {
        mPaint.setColor(mColor);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
        // 针对 wrap_content 模式,指定默认宽高 200px
        if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(200, 200);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(200, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, 200);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // padding 的值
        final int paddingLeft = getPaddingLeft();
        final int paddingRight = getPaddingRight();
        final int paddingTop = getPaddingTop();
        final int paddingBottom = getPaddingBottom();
        // View 的宽
        int width = getWidth() - paddingLeft - paddingRight;
        // View 的高
        int height = getHeight() - paddingTop - paddingBottom;
        // 圆的半径
        int radius = Math.min(width, height) / 2;
        // 绘制圆
        canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2, radius, mPaint);
    }
}

4.4.3.2 继承 ViewGroup 派生特殊的 Layout

  采用此方式需要合适地处理 ViewGroup 的测量、布局这两个过程,并同时处理子元素的这两个过程。

  需要说明的是,此方法实现一个很规范的自定义 View,是有一定的代价的(通过看 LinearLayout 等的源码可知其实现很复杂)。

  在 3.5.3 节中,HorizontalScrollViewEx 就是通过继承自 ViewGroup 的自定义 View,它类似水平方向的 LinearLayout 的控件,它内部的子元素可以水平或竖直滑动(滑动冲突请参考)。这里实现其主要功能,不规范的地方会说明。

  这里假设所有子元素的宽高都一样,先看其 onMeasure 方法如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidth = 0;
        int measureHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
        // 1. 判断是否有子元素,若无子元素,则把自己的宽/高设置为 0
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
        }
        // 2. 判断宽和高是否采用 wrap_content
        // 若宽采用 wrap_content,则 HorizontalScrollViewEx 的宽度是所有子元素的宽度之和
        // 若高采用 wrap_content,则 HorizontalScrollViewEx 的高度是第一个子元素的高度
        else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            measureHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measureWidth, measureHeight);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measureWidth, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpecSize, measureHeight);
        }
    }

  上述代码不规范的地方有两点,第一点是无子元素时不该直接把宽/高设置为 0,而应该根据 LayoutParams 的宽/高来做相应处理;第二点是测量 HorizontalScrollViewEx 的宽/高时没有考虑到 padding 和子元素的 margin,这会影响到其宽/高。

  接着看其 onLayout 方法如下:

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;
        // 遍历所有的子元素,若子元素不是 GONE 状态,则通过 layout 方法将其放在合适的位置上
        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            final int childWidth = childView.getMeasuredWidth();
            mChildWidth = childWidth;
            childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
            childLeft += childWidth;
        }
    }

  上述代码作用是完成子元素的定位,不规范的地方仍是没有考虑到 padding 和子元素的 margin。

  最后,给出 HorizontalScrollViewEx 的完整代码如下:

public class HorizontalScrollViewEx extends ViewGroup {

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

    // 分别记录上次滑动的坐标
    private int mLastX = 0;
    private int mLastY = 0;

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

    private Scroller mScroller;

    private VelocityTracker mVelocityTracker;

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

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

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

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

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercepted = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();

        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercepted = false;
                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;
                } else {
                    // 而竖直距离大于就不拦截,事件就传递给了ListView,
                    // 从而 ListView能上下滑动,这就解决了冲突
                    intercepted = false;
                }
                break;
            case MotionEvent.ACTION_UP:
                intercepted = false;
                break;
        }
        mLastX = x;
        mLastY = y;
        mLastXIntercept = x;
        mLastYIntercept = y;
        return intercepted;
    }

    @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 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;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measureWidth = 0;
        int measureHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
        // 1. 判断是否有子元素,若无子元素,则把自己的宽/高设置为 0
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
        }
        // 2. 判断宽和高是否采用 wrap_content
        // 若宽采用 wrap_content,则 HorizontalScrollViewEx 的宽度是所有子元素的宽度之和
        // 若高采用 wrap_content,则 HorizontalScrollViewEx 的高度是第一个子元素的高度
        else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            measureHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measureWidth, measureHeight);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measureWidth, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measureHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpecSize, measureHeight);
        }
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;
        // 遍历所有的子元素,若子元素不是 GONE 状态,则通过 layout 方法将其放在合适的位置上
        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            final int childWidth = childView.getMeasuredWidth();
            mChildWidth = childWidth;
            childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
            childLeft += childWidth;
        }
    }

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

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

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

4.4.4 自定义 View 的思想

  面对陌生的自定义 View 时,需要用这种自定义 View思想去解决问题:首先掌握基本功,如 View 的弹性滑动、滑动冲突、绘制原理等;掌握基本功后,在面对新的自定义 View 时,要对其进行分类并选择合适的实现思路;另外,平时多积累一些自定义 View 的相关经验,慢慢做到融会贯通。

  本篇文章就介绍到这。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260

推荐阅读更多精彩内容