自定义 View 总结

作者: 5260fbd1e4e1 | 来源:发表于2018-07-31 20:59 被阅读25次

    自定义 View 是一个综合的技术体系,涉及到 View 的层次结构,事件分发机制和 View 的工作原理等技术细节。

    一、自定义 View 的分类

    大致可以分为如下四种:

    1、 继承 View 重写 onDraw 方法

    这种情况主要是实现一些复杂的效果,这些效果不方便通过布局的组合来实现,往往需要静态或者动态的显示一些不规则的图形。要实现这种效果就要自己继承 View 并重写onDraw 方法,这种方式往往要自己支持 wrap_content,并且 padding 等需要自己处理。

    2、继承 ViewGroup 派生特殊的 Layout

    这种方法主要是用于实现自定义布局,也就是除了 LinearLayout、RealativeLayout 和 FrameLayout 等这些系统布局之外,我们需要自己定义一种新的布局,这个布局看起来像几种 View 的组合。这种方法稍微复杂一点,需要合适的处理 ViewGroup 的测量、布局这两个过程,并要同时处理子元素的测量和布局过程。

    3、继承特定的 View(比如 TextView)

    这种方式比较常见,主要是扩展某个已知 View 的功能,比如 TextView。这种方法比较容易实现,主要是要自己支持 wrap_content 和 padding。

    4、继承特定的 ViewGroup (比如 LineayLayout)

    这种方法也比较常见,看起来就像是几种 View 组合在一起,这种方法不需要自己去处理 ViewGroup 的测量和布局这两个过程。

    二、自定义 View 的注意事项

    1、自定义 View 需要支持 wrap_content

    自定义 View 如果继承 View 或者 ViewGroup ,如果不在 onMeansure 中对 wrap_content 做特殊处理,那么在布局中使用 wrap_content 时,无法达到预期的效果。

    2、如果有必要,自定义 View 需要支持 padding

    如果直接继承 View 控件,如果不在 draw 方法中处理 padding,那么padding 属性将无法起作用。另外如果直接继承 ViewGroup,那么要考虑 onMeasure 和 onLayout 中的 padding 和子元素的 margin 对这个 View 的影响,不然将导致 padding 和子元素的 margin 失效。

    3、尽量不要在自定义 View 中使用 Handler

    因为 View 内部本身提供了 post 方法可以代替 Handler,除非是要明确必须用 Handler 发送消息。

    4、View 中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow

    当有线程或者动画需要停止时,那么 onDetachedFromWindow 是一个好时机,包含此 View 的 Activity 退出或者当前 View 被 remove 时,View 的 onDetachedFromWindow 会被调用,和此方法对用的是 onAttachedToWindow,当包含此 View 的 Activiyt 启动时,这个方法会被调用。需要注意的是,我们要及时处理线程和动画,否则会造成内存泄漏。

    5、View 需要嵌套滑动时,需要处理好滑动冲突。

    滑动冲突主要是发生在嵌套 View 中,而不止一个 View 需要对滑动做处理,此时要考虑滑动冲突的解决。

    三、 自定义 View 示例

    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, @Nullable AttributeSet attrs) {
            super(context, attrs);
            init();
        }
    
        public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int mColor) {
            super(context, attrs, defStyleAttr);
            init();
        }
        private void init(){
            mPaint.setColor(mColor);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            int  width = getWidth();
            int height =  getHeight();
            int radius = Math.min(width,height)/2;
            canvas.drawCircle(width/2,height/2,radius,mPaint);
        }
    }
    

    效果如下:


    circle.png

    上面实现的效果只是初级的实现,并不是一个规范的自定义 View 。下面考虑设置布局参数。
    布局如下:

    <?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"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="#ffffff"
        tools:context="com.hcworld.customview.MainActivity">
    
        <com.hcworld.customview.CircleView
            android:id="@+id/circleView1"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:background="#000000"
            />
    </LinearLayout>
    

    再次运行,效果如下:


    circle2.png

    再次调整布局参数,设置 20dp 的 margin,布局如下:

    <?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"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="#ffffff"
        tools:context="com.hcworld.customview.MainActivity">
    
        <com.hcworld.customview.CircleView
            android:id="@+id/circleView1"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:layout_margin="20dp"
            android:background="#000000"
            />
    </LinearLayout>
    

    效果如下:


    circle3.png

    如上效果,说明 margin 属性是生效的,这是因为 margin 属性是由父容器控制的,因此不需要在自定义 View 中做特殊处理,下面考虑设置 20dp 的padding。

    <?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"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="#ffffff"
        tools:context="com.hcworld.customview.MainActivity">
    
        <com.hcworld.customview.CircleView
            android:id="@+id/circleView1"
            android:layout_width="match_parent"
            android:layout_height="100dp"
            android:layout_margin="20dp"
            android:padding="20dp"
            android:background="#000000"
            />
    </LinearLayout>
    

    再次运行,效果如下:


    circle4.png

    可以看到,padding 属性没有生效,如上面说的注意事项,如果自定义 View 继承自 View 或者 ViewGroup,padding 属性是无法生效的,需要自己处理。再次调整布局参数,修改宽度为 wrap_content,布局如下:

    <?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"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="#ffffff"
        tools:context="com.hcworld.customview.MainActivity">
    
        <com.hcworld.customview.CircleView
            android:id="@+id/circleView1"
            android:layout_width="wrap_content"
            android:layout_height="100dp"
            android:layout_margin="20dp"
            android:padding="20dp"
            android:background="#000000"
            />
    </LinearLayout>
    

    效果如下:


    circle4.png

    也就是,wrap_content 也是没有生效的,其实这里的 wrap_content 和 match_parent 的效果是一样的。也就是,对于自定义 View,如果不对 wrap_content 做特殊处理,那么说使用 wrap_content 的效果和 match_parnet 是一样的。

    为了解决上面出现的问题,使 padding 和 wrap_content 生效,我们需要做如下处理:
    对于 wrap_content,我们需要重写onMeasure,为 wrap_content 指定一个默认值,这里选择 200px。

        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    
            // 获取宽-测量规则的模式和大小
            int widthMode = MeasureSpec.getMode(widthMeasureSpec);
            int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    
            // 获取高-测量规则的模式和大小
            int heightMode = MeasureSpec.getMode(heightMeasureSpec);
            int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    
            // 设置wrap_content的默认宽 / 高值
            // 默认宽/高的设定并无固定依据,根据需要灵活设置
            int mWidth = 200;
            int mHeight = 200;
    
            // 当布局参数设置为wrap_content时,设置默认值
            if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT && getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
                setMeasuredDimension(mWidth, mHeight);
                // 宽 / 高任意一个布局参数为= wrap_content时,都设置默认值
            } else if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
                setMeasuredDimension(mWidth, heightSize);
            } else if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
                setMeasuredDimension(widthSize, mHeight);
            }
    
        }
    

    对于 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 paddintBottom = getPaddingBottom();
    
            int  width = getWidth() - paddingLeft - paddingRight;
            int height =  getHeight() - paddingTop - paddintBottom;
            int radius = Math.min(width,height)/2;
            canvas.drawCircle(paddingLeft+width/2,paddingTop+ height/2,radius,mPaint);
        }
    

    布局如下:

    <?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"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="#ffffff"
        tools:context="com.hcworld.customview.MainActivity">
    
        <com.hcworld.customview.CircleView
            android:id="@+id/circleView1"
            android:layout_width="wrap_content"
            android:layout_height="100dp"
            android:layout_margin="20dp"
            android:padding="20dp"
            android:background="#000000"
            />
    
    </LinearLayout>
    

    效果如下:


    circle5.png

    也就是 wrap_content 和 padding 都生效了。
    最后,我们还要为我们的自定义 View 添加自定义属性。像 android:layout_width 和 android:padding 等,这些 android 开头的是系统自带属性,而要实现自定义属性,一般步骤如下:

    1、创建自定义属性 XML 文件,如 attrs.xml

    内容如下:

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <declare-styleable name="CircleView">
            <attr name="circle_color" format="color"/>
        </declare-styleable>
    </resources>
    

    上面只是声明了一个自定义属性集合 "CircleView",在这个集合里,只有一个属性 circle_color,其类型为 color 代表 颜色。基本类型还有:
    refercece:资源 id
    dimension: 尺寸大小
    还有就是一下基本类型如 string,boolea,float 等。可以根据需要自己实现,如:

    <atrr name = "circle_radius" format = "float"/> 
    
    2、在自定义 View 的构造方法中,解析自定义属性的值,这里示例只有一个值,其他情况类似
      public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            // 获取用到的这个属性组
            TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CircleView);
            // 获取布局中设置的值,默认值为 Red
            mColor = a.getColor(R.styleable.CircleView_circle_color,Color.RED);
            // 用完 recycle
            a.recycle();
            init();
        }
    
    3、在布局文件中使用属性
    <?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"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        android:background="#ffffff"
        tools:context="com.hcworld.customview.MainActivity">
    
        <com.hcworld.customview.CircleView
            android:id="@+id/circleView1"
            android:layout_width="wrap_content"
            android:layout_height="100dp"
            android:layout_margin="20dp"
            android:padding="20dp"
            android:background="#000000"
            app:circle_color="@color/colorPrimary"
            />
    </LinearLayout>
    

    在使用自定义属性的时候,需要注意,这里必须在布局文件中添加 schemas 声明:

     xmlns:app="http://schemas.android.com/apk/res-auto"
    

    也就是,只有声明了这个 schemas,才能找到我们自定义的属性字段。
    至此,自定义属性步骤就完成了,运行程序,效果如下:


    circle6.png

    完整的自定义 View 代码:

    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, @Nullable AttributeSet attrs) {
            this(context, attrs,0);
            init();
        }
    
        public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
            // 获取用到的这个属性组
            TypedArray a = context.obtainStyledAttributes(attrs,R.styleable.CircleView);
            // 获取布局中设置的值,默认值为 Red
            mColor = a.getColor(R.styleable.CircleView_circle_color,Color.RED);
            // 用完 recycle
            a.recycle();
            init();
        }
        private void init(){
            mPaint.setColor(mColor);
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    
            // 获取宽-测量规则的模式和大小
            int widthMode = MeasureSpec.getMode(widthMeasureSpec);
            int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    
            // 获取高-测量规则的模式和大小
            int heightMode = MeasureSpec.getMode(heightMeasureSpec);
            int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    
            // 设置wrap_content的默认宽 / 高值
            // 默认宽/高的设定并无固定依据,根据需要灵活设置
            int mWidth = 200;
            int mHeight = 200;
            // 当布局参数设置为wrap_content时,设置默认值
            if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT && getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
                setMeasuredDimension(mWidth, mHeight);
                // 宽 / 高任意一个布局参数为= wrap_content时,都设置默认值
            } else if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) {
                setMeasuredDimension(mWidth, heightSize);
            } else if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) {
                setMeasuredDimension(widthSize, mHeight);
            }
    
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            super.onDraw(canvas);
            //使 padding 生效
            final int paddingLeft = getPaddingLeft();
            final int paddingRight = getPaddingRight();
            final int paddingTop = getPaddingTop();
            final int paddintBottom = getPaddingBottom();
    
            int  width = getWidth() - paddingLeft - paddingRight;
            int height =  getHeight() - paddingTop - paddintBottom;
            int radius = Math.min(width,height)/2;
            canvas.drawCircle(paddingLeft+width/2,paddingTop+ height/2,radius,mPaint);
        }
    }
    
    2、继承 ViewGroup 派生特殊的 Layout

    这种方法主要实现自定义的布局,采用这种方式稍微复杂一点,需要合适的处理 ViewGroup 的测量、布局这两个过程,并同时处理子元素的测量和布局过程。在 Android View 事件体系 的滑动冲突的实例介绍中,自定义了一个 ViewGroup 控件 HorizontalScrollViewEx ,其代码如下:

    public class HorizontalScrollViewEx extends ViewGroup {
        public HorizontalScrollViewEx(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
            int measureWidth = 0;
            int measuredHeight = 0;
            final int childCount = getChildCount();
            measureChildren(widthMeasureSpec, heightMeasureSpec);
    
            int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
            int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
            int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
            int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    
            if (childCount == 0) {
                setMeasuredDimension(getLayoutParams().width, getLayoutParams().height);
            } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
                View childView = getChildAt(0);
                measureWidth = childView.getMeasuredWidth() * childCount;
                measuredHeight = childView.getMeasuredHeight();
                setMeasuredDimension(measureWidth, measuredHeight);
            } else if (heightSpecMode == MeasureSpec.AT_MOST) {
                View childView = getChildAt(0);
                measureWidth = widthSpaceSize;
                measuredHeight = childView.getMeasuredHeight();
                setMeasuredDimension(measureWidth, measuredHeight);
            } else if (widthSpecMode == MeasureSpec.AT_MOST) {
                View childView = getChildAt(0);
                measureWidth = childView.getMeasuredWidth() * childCount;
                measuredHeight = heightSpaceSize;
                setMeasuredDimension(measureWidth, measuredHeight);
            }
        }
    
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            int childLeft = 0;
            int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                View childView = getChildAt(i);
                if (childView.getVisibility() != View.GONE) {
                    int childWidth = childView.getMeasuredWidth();
                    childView.layout(childLeft, 0, childLeft + childWidth, childView.getMeasuredHeight());
                    childLeft += childWidth;
                }
            }
        }
    }
    

    现在主要分析一下其 measure 和 layout 的过程。首先我们需要知道的是,对于继承 ViewGroup 来实现自定义 View 是很复杂的,这里只是未来演示而只有 onMeasure 和 onLayout 两个方法。在这个示例中,HorizontalScrollViewEx 要实现的是一个类似 ViewPager 的控件,其内部元素可以水平滑动,并且子元素可以垂直滑动,这里不介绍滑动冲突如何解决,现在考虑自定义控件的实现。
    首先假设所有子元素的宽高都是一样的,代码如上所示。现在分析一下上面代码的逻辑。在 onMeasure 方法中,首先会判断是否有子元素,如果没有子元素就直接把自己的宽高设置为0,然后就是判断宽高是不是采用了 wrap_content,如果宽采用了 wrap_content,那么 HorizontalScrollViewEx 的宽度是所有子元素宽度之和,如果高度采用了 wrap_content,那么 HorizontalScrollViewEx 的高度是第一个子元素的高度。

    上面代码实现的自定义 View 有两个不规范的地方,首先没有子元素的时候不应该直接把宽高设置为 0,而是应该根据 LayoutParams 中的宽高来做相应的处理。第二个就是在测量 HorizontalScrollViewEx 的宽高时没有考虑它的 padding 和子元素的 margin,但是它的 padding 和 子元素的 margin 还有影响到它的宽高。

    接着分析一下 onLayout 方法,这个方法主要是完成子元素的定位。首先,会遍历所有子元素,如果子元素不是处于 GONE 这个状态,那么通过 layotu 这个方法将其放置到合适的位置,由代码可知,放置的顺序,由左向右。这个方法同样不规范的地方在于没有考虑 margin 和 padding ,这也是不对的,在自定义 View 中需要根据实际情况考虑这两个因素。

    3、继承 特定的 View 和 继承特定的 ViewGroup

    继承特定的 View 和继承特定的 ViewGroup (如 LinearLayout)这两个方式比较简单,就不在举例说明,在 Android View 事件体系 的滑动冲突的实例介绍中,有一个 StickyLayout 布局就是这个类型的,这里不在介绍。

    4、总结

    自定义 View 是一个综合的技术体系,这里可能设计到 View 的弹性滑动,滑动冲突以及绘制原理等,但是这些都是最基础的知识,只有把这些基础掌握了,才能根据需求设计出高水平的自定义 View。

    相关文章

      网友评论

        本文标题:自定义 View 总结

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