Android自定义View的四种方式

目录

 

一 前言       

二 分类

1.组合控件

2.继承系统控件

3.继承View

4.继承ViewGroup

三 自定义View

1.构造函数

2.onMeasure

3.onLayout

4.onDraw

5.点击事件

四 自定义ViewGroup

五 总结


一 前言       

在Android开发中有很多业务场景,原生的控件是无法满足应用,并且经常也会遇到一个UI在多处重复使用情况,那么就需要通过自定义View的方式来实现这些UI效果。作为一个Android开发工程师自定义View属于一个必备技能。

       像前几天正好做了一个上传本地图片的网格UI,点击相机的icon就可以上传选择本地图片,选中图片之后就可以显示的界面上。本来可以使用Android原生GridView,但是这个控件由于嵌套到ScrollView中,所以导致滑动事件冲突,当然网上也有一些方法屏蔽GridView的滑动事件,但是因为考虑到这个需要显示的图片不会太多,使用GridView又比较重,所以通过自定义View的方式重新实现网格UI。UI如图所示:

功能已经顺利提测,在这过程中也遇到了之前没有遇到的问题,所以将常见的自定义View的方法和遇到的问题总结下。

二 分类

通常自定义View的方法通常有以下几种方式:

1.组合控件

应用场景:在项目中经常会遇到一些比较复杂的UI块需要用在多处使用,那么我们就可以通过五大布局和基本控件组合成一个新的布局View,这样就可以方便的将该UI用在项目的不同页面中,比如一个标题栏。这种方式比较简单,只要通过布局文件实现相应的UI,然后将该UI加到适合的五大布局中即可。代码如下:

  • (1)定义xxxView继承五大布局
public xxxView extends LinearLayout/RelativeLayout/….{
}
  • (2)将定义好的布局文件通过以下方式添加到父类中:
View viewContainer = inflater.inflate(R.layout.view_action_bar, this, true);

备注:其中inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot)中有三个参数:

参数 含义
resource 需要加入的布局文件
root 给resource外部嵌套的父容器,该字段与attachToRoot配合使用
attachToRoot 是否将resource添加到root中

root与attachToRoot需要配合使用,不同取值对应下面的情况:

  • (1)root null

 attachToRoot不起任何作用,该方法仅仅就是实例化了一个resource对应的View

  • (2)root 不为null attachToRoot true

resource被添加到root中,并且resource设置的根布局参数起作用(说明:resource设置的layout_width/layout_height等参数指的是该控件在父容器的大小,只有放到父容器这些参数才会起作用。所以当attachToRoot设置为true时候,resource对应的布局参数才会起作用)

  • (3)root不为null attachToRoot为false

resource对应的根节点参数有效,但是resource并没有添加到root中,需要手动调用root.addView(resource),而(2)已经不需要额外调用,否则会抛出异常

java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.

具体上面的三种情况后面在专门去研究下,包括如果resource中根节点为merge会有什么不同。

2.继承系统控件

应用场景:还是基于应用项目的UI,无法满足业务场景,但是基于原生控件拓展,如下面的EditText,像边框、左右icon的提示等,因为该控件在项目中多处都要使用,如果每次在使用的时候都去分别设置属性,非常麻烦。

所以只需要在原生EditText的基础上,通过设置setBackgroundResource()、setCompoundDrawablesWithIntrinsicBounds()就可以方便的在不同的地方使用该控件

public class XXXEditText extends EditText {
}

3.继承View

 通过继承View,复写View的生命周期函数来完成View的渲染。这种其实就类似于绝对布局,需要自行计算绘制到View的每一个子View。后面具体介绍

4.继承ViewGroup

通过继承ViewGroup,复写ViewGroup的生命周期函数来完成View的渲染。也类似于绝对布局,需要计算每个子View的大小和位置。后面具体介绍。区别于继承View,ViewGroup就是View的集合,而继承View就是单个View,另外需要涉及到生命周期函数不同。

三 自定义View

基于在前言提到的自定义控件GridView,详细说明下自定义View的绘制过程。

1.构造函数

在View中一共有四个构造函数

  • (1)如果该控件仅在代码中实例化,那么可以直接复写只有一个Context参数的构造函数
 public GridView(Context context) {

 }
  • (2)如果控件要应用在布局文件,那么必须要复写下面的构造函数
   public GridView(Context context, @Nullable AttributeSet attrs) {
     
   }

 其中attrs参数就是我们在布局文件中配置的各个属性

  • (3)View需要随着主题而改变属性值,那么就必须复写下面的构造函数
    public GridView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
 
    }

    public GridView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
      
    }

这两个构造函数与主题相关,即我们在项目中可以通过"android:theme"配置Activity/Application/控件的主题,使得View的某些属性值可以随着主题的变化而变化,不需要在布局文件中单独设置。其中四个参数的构造函数,仅API21才支持。一般来说,这两个构造函数都是通过在前两个构造函数串联调用的方式来调用到这两个构造函数。结合系统控件Button的源码中的构造函数,具体介绍下其他两个参数。

    public Button(Context context) {
        this(context, null);
    }

    public Button(Context context, AttributeSet attrs) {
        this(context, attrs, com.android.internal.R.attr.buttonStyle);
    }

    public Button(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public Button(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);

1)defStyleAttr:一个在自定义控件的时候会定义一个属性,该属性指向一个reference的属性Id。该属性会定义在Theme中,该属性一般指向R.style.xxx(style的引用),而这个R.style.xxx对应的资源中有一些需要设置了默认属性值的属性。

例如在该Button控件中,defStyleAttr为com.android.internal.R.attr.buttonStyle,就是在系统文件/data/res/values/attrs.xml定义一个单独的属性值    

  <!-- Normal Button style. -->
  <attr name="buttonStyle" format="reference" />

那么在给Activity或者Application中指定Theme的时候,就可以在Theme中指定对应buttonStyle的为不同的styleId。

例如在Theme.Holo中,引用该buttonStyle属性的时候:

<style name="Theme.Holo">
.......
    <!-- Button styles -->
    <item name="buttonStyle">@style/Widget.Holo.Button</item>
......
</style>

其中 buttonStyle对应的Widget.Holo.Button对应如下:

  <style name="Widget.Holo.Button" parent="Widget.Button">
        <item name="background">@drawable/btn_default_holo_dark</item>
        <item name="textAppearance">?attr/textAppearanceMedium</item>
        <item name="textColor">@color/primary_text_holo_dark</item>
        <item name="minHeight">48dip</item>
        <item name="minWidth">64dip</item>
 </style>

例如在Theme.Holo.Light中引用该buttonStyle属性的时候:

 <style name="Theme.Holo.Light" parent="Theme.Light">
.......
       <!-- Button styles -->
        <item name="buttonStyle">@style/Widget.Holo.Light.Button</item>
......
	</style>

其中 buttonStyle对应Widget.Holo.Light.Button对应如下:

  <style name="Widget.Holo.Light.Button" parent="Widget.Button">
        <item name="background">@drawable/btn_default_holo_light</item>
        <item name="textAppearance">?attr/textAppearanceMediumInverse</item>
        <item name="textColor">@color/primary_text_holo_light</item>
        <item name="minHeight">48dip</item>
        <item name="minWidth">64dip</item>
  </style>

从代码中可以看出通过不同的Theme下的 对应的Button的background、textAppearance、textColor是不一样的。

2)defStyleRes:指向R.style资源Id(style的引用)。但是该值相比较于defStyleAttr,只会在defStyleAttr为0(即在context.obtainStyledAttribute对应传入0),或者defStyleAttr不为0但defStyleAttr并没有对指定的属性进行赋值(即在Theme中定义defStyleAttr,但是defStyleAttr指向的style并没有给指定的属性赋值)起作用。

这两个值其实最后都是传入到下面的方法里面的。

obtainStyledAttributes(@Nullable AttributeSet set,
            @NonNull @StyleableRes int[] attrs, @AttrRes int defStyleAttr,
            @StyleRes int defStyleRes)

该实现过程通常在构造函数中,从构造函数的attrs(布局文件中设置的属性)中读取属性值。通常代码逻辑如下:

    private void initAttributes(AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        if (attrs == null) {
            return;
        }
        if (defStyleRes <= 0) {
            defStyleRes = R.style.DefaultGridViewStyleRes;
        }
        TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.GridView, defStyleAttr, defStyleRes);
        if (array == null) {
            return;
        }
    ........    
    }

关于obtainStyledAttributes这个方法也是一个很有意思的方法,后面专门总结一下。 

另外怎么去自定义属性,以及属性的设置优先级,参照另外一篇Android View属性设置优先级的一点总结

之前在Android View的生命周期函数总结,onMeasure、onLayout、onDraw三个方法在自定义View中起了比较关键的作用。后面主要介绍下这三个方法在使用的过程中遇到的问题和注意事项。

2.onMeasure

该方法就是测量绘制View的所需要的大小。具体的大小是有放到ViewGroup的layout_width、layout_height、layout_margin决定的。一般都需要复写下面的方法来计算该自定义的控件的大小。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)

其中参数widthMeasureSpec和heightMeasureSpec,都包括两部分的内容:

1)高32位为specMode,得到的是模式,可以通过MeasureSpec.getMode()获取

2)低32位为specSize,得到的是大小,可以通过MeasureSpec.getSize()获取。

上面的提到的specMode即:MeasureSpec.EXACTLY、MeasureSpec.AT_ALMOST、MeasureSpec.UNSPECIFIED。不同的specMode对应的specSize也不一样。

1)MeasureSpec.EXACTLY:对应的设置的是固定尺寸,specSize返回的就是设置的具体尺寸值;

2)MeasureSpec.AT_ALMOST:对应的设置的match_parent,specSize返回的就是父容器能存放的该View的具体尺寸值;

3)MeasureSpec.UNSPECIFIED:对应的设置的wrap_content,specSize返回的没有任何意义,需要重新根据绘制的内容计算高度或宽度。

那么通常在onMeasure中的步骤为:

  • (1)定义方法来得到View的高度和宽度

注意在计算的时候需要考虑到padding,否则当使用该控件的时候,设置的padding将不起作用。后面周期函数onLayout、onDraw同样需要考虑padding。

  • (2)设置高度和宽度

必须调用setMeasuredDimension(width,height)来设置高度和宽度。

具体代码如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //该控件的宽度
        int width = measureWidth(widthMeasureSpec);
        //根据控件的width和mNumColumns(默认为4个)的计算每个item的宽度
        updateColumnWidth();
        //该控件的高度
        int height = measureHeight(heightMeasureSpec);
        //设置ViewGroup的宽和高
        setMeasuredDimension(width, height);
    }

3.onLayout

就是放置每个子View的位置。其中返回的 left, top, right, bottom是该View的边界相对于父容器的距离。我们在计算放置到View中的子View的时候,其实可以直接从(paddingLeft,paddingTop)开始计算每个子View的位置即可。

那么在onLayout的步骤为:

  • (1)计算每个子View的坐标系leftItem, topItem, rightItem, bottomItem
  • (2)调用child.layout(leftItem, topItem, rightItem, bottomItem)设置每个子View的位置。

具体代码如下:


    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
  
        int leftItem, topItem, rightItem, bottomItem = 0;
        //每行
        for (int row = 0; row < mNumRow; row++) {
            //每列
            for (int col = 0; col < mNumColumns; col++) {

                int index = row * mNumColumns + col;
                if (index >= childGroup.size()) {
                    break;
                }
                ImageView child = childGroup.get(index).imageView;

                leftItem = paddingLeft + (mColumnWidth + mHorizontalSpacing) * col;
                topItem = paddingTop + (mColumnWidth + mVerticalSpacing) * row;
                rightItem = leftItem + mColumnWidth;
                bottomItem = topItem + mColumnWidth;

                child.layout(leftItem, topItem, rightItem, bottomItem);
            }
        }
    }

其中这些变量的关系图如下:

4.onDraw

就是将子View绘制到画布上面。而对于该自定义GridView控件,就需要将ImageView依次绘制到canvas中。在绘制子View的时候,是需要依次通过canvas.translate移动canvas的坐标系来依次绘制子View。注意的是在canvas平移过程中,都是相对当前位置进行平移,移动的坐标系的原则就是要和每个子View的坐标系重合。

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int transX, transY;
        //每行
        for (int row = 0; row < mNumRow; row++) {
            //将画布坐标系移到每一行的第一列.原canvas是从该view的(0,0)的位置开始的
            if (row > 0) {
                transX = -(mColumnWidth + mHorizontalSpacing) * (mNumColumns - 1);
                transY = mColumnWidth + mVerticalSpacing;

            } else {
                transX = paddingLeft;
                transY = paddingTop;
            }
            canvas.translate(transX, transY);
            //每列
            for (int col = 0; col < mNumColumns; col++) {
                int index = row * mNumColumns + col;
                if (index >= childGroup.size()) {
                    break;
                }

                ImageView child = childGroup.get(index).imageView;
                child.draw(canvas);

                Log.d(String.format("onDraw row = %d, col = %d, left =%d , top = %d, right = %d, bottom = %d", row, col, child.getLeft(), child.getTop(), child.getRight(), child.getBottom()));

                //只要不是最后一列,都要将画布的坐标系依次向后移动
                if (col < mNumColumns - 1) {
                    //平移的是相对于自身的位置
                    canvas.translate((mColumnWidth + mHorizontalSpacing), 0);
                }
            }
        }
    }

5.点击事件

通过上面的四个步骤,我们就可以实现了这种网格的UI。当然在实现前言中提到的这个UI,还需要有一个点击图片的效果。一开始还想着使用imageView.setOnClickListener的事件,但是没有效果,但是后来一想,这本来就是一个View,必须通过复写onTouchEvent来实现点击效果。当然这个判断点击哪个View的原则就是点击的区域是否有匹配的ImageView。那么这又涉及到MotionEvent的坐标系和View的坐标系之间的关系。如图

简单说明下这个页面几个View的关系,其中LinearLayout为当前Activity的根布局,中间的View为LinearLayout的子View。那么

(1)View.getTop/getBottom/getLeft/getRight:获取的是该子View相对于父控件的上/下/左/右的距离。也就是该子View相对于LinearLayout的上/下/左/右的距离。

(2)点击的MotionEvent:会返回两种坐标。第一种getRawX/getRawY获取的是该子View在屏幕的坐标系,也就是相对于屏幕的左上角的距离;第二种getX/getY获取的是点击点相对于该子View的坐标系,也就是相对于该子View的左上角的距离。

有了这个坐标系的话,那我们在该自定义GridView控件中的onTouchEvent中,就需要将event.getX/getY与每一个ImageView.getTop/getBottom/getLeft/getRight进行比较,看下该event是否在ImageView的坐标系范围内。就有了下面的代码:


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                int index = touchWhichItem(event);
                Log.d(String.format("onTouchEvent 第%d个", index));
                if (index < 0 || index > childGroup.size() || itemClickListener == null) {
                    return super.onTouchEvent(event);
                }
                itemClickListener.onItemClick(childGroup.get(index));
                break;
            default:
                break;
        }
        return super.onTouchEvent(event);
    }

    private int touchWhichItem(MotionEvent event) {
        //获取的该点击坐标系是以该View的坐标系,是相对于View的位置
        float eventX = event.getX();
        float eventY = event.getY();
        int left, top, right, bottom;
        int index = 0;
        Log.v(String.format("touchWhichItem  x =%f , y = %f", eventX, eventY));
        for (PhotoSelectorItem item : childGroup) {
            ImageView image = item.imageView;
            //返回的是该imageView在View中的坐标位置
            left = image.getLeft();
            top = image.getTop();
            right = image.getRight();
            bottom = image.getBottom();
            //event返回的是该点在View的坐标系中的位置
            Log.v(String.format("touchWhichItem  left =%d , top = %d, right = %d, bottom = %d", left, top, right, bottom));
            //所以这个是可以直接进行比较
            if (eventX >= left && eventX <= right && eventY <= bottom && eventY >= top) {
                return index;
            }
            index++;
        }
        return -1;
    }

经过上面的几个步骤之后,就可以实现了前言的UI。因为在项目中还有一些其他的功能,所以在github上简单的记录下实现过程。具体代码可以在github上查看。主要实现代码即WidgetPlaceholderlibrary库中的GridView控件。

四 自定义ViewGroup

继承于ViewGroup要简单一些,因为ViewGroup本身就是一个View的容器,相比较于继承View,再将子View绘制到ViewGroup的时候要简单很多。onMeasure()、onLayout()两个方法的逻辑与继承View的实现相同,区别在于:

(1)在onLayout()计算完每个子View的坐标之后,可以直接通过addView(child)的方式直接将该子View添加到ViewGroup中,无需复写onDraw()来将子View绘制到canvas。

(2)在实现每个子View的点击事件,可以直接通过child.setOnClickListener来设置点击事件。

具体代码可以在github上查看。主要实现代码即WidgetPlaceholderlibrary库中的GridLayout控件。

五 总结

在开发这个自定义控件的时候,还遇到一些其他小的知识点,简单的总结下。

1.requestLayout()和invalidate()

(1)requestLayout():会将mPrivateFlags 设置为PFLAG_FORCE_LAYOUT,从而调用到父控件的mParent.requestLayout()并标记为PFLAG_FORCE_LAYOUT,最后调用到ViewRootImpl的requestLayout(),这些被标记为PFLAG_FORCE_LAYOUT的View会重新调用到onMeasure()、onLayout()、onDraw();

(2)invalidate():并没有将mPrivateFlags设置为PFLAG_FORCE_LAYOUT,所以只会调用到onDraw(),而不会调用到onMeasure()、onLayout()。

这个问题出现就是在增加了ImageView的个数刷新View的时候,发现该View的所占的空间并没有发生变化,正是由于这两个方法没有合理使用引起的。

2.在复写onMeasure()、onLayout()、onDraw()的时候,要把padding计算在View控件中,否则设置的padding不起作用

3.在复写onLayout()的时候,其实可以直接将子View从paddingTop、paddingLeft开始放置子View,onLayout传入的left/top/right/bottom用处不大,因为这四个值返回的是该自定义控件本身在父容器中的坐标

4.继承于View的点击事件必须通过onTouchEvent来实现。

5.继承于View在复写onDraw()的时候,需要canvas.translate,才能将子View绘制到canvas上,并且canvas平移的时候都是相对于当前位置平移。

6.继承于ViewGroup在添加子View的时候,可以直接使用addView(child)就可以将子View添加到ViewGroup中。

 

 

 

 

猜你喜欢

转载自blog.csdn.net/nihaomabmt/article/details/109668801