android自定义view

自定义 View 总结

2018-07-31  本文已影响25人  5260fbd1e4e1

自定义 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。

上一篇下一篇

猜你喜欢

热点阅读