Android 自定义 ViewGroup

01.自定义ViewGroup步骤

  • 自定义ViewGroup
    • 自定义ViewGroup一般是利用现有的组件根据特定的布局方式来组成新的组件,大多继承自ViewGroup或各种Layout,包含有子View。
  • 大概的步骤如下所示
    • 1、创建类继承ViewGroup
    • 2、测量View
    • 3、布局View
    • 4、绘制View
    • 5、事件分发处理
    • 6、与用户进行交互
  • 上面列出的五项就是android官方给出的自定义控件的步骤。
    • 每个步骤里面又包括了很多细小的知识点。可以记住这五个点,并且了解每个点里包含的小知识点。再加上一些自定义控件的练习。不断的将这些知识熟练于心,相信我们每个人都能够定义出优秀的自定义控件。接下来我们开始对上面列出的5个要点进行细化解说。

02.创建ViewGroup

2.1 重写构造方法

  • 代码如下所示,重写下面三个构造方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    /**
    * 练习自定义控件
    * 组合控件,把Android现有的控件组合在一起,实现想要的效果
    * 继承现有控件,做增强功能
    * 继承View,完全自定义控件TextView,Button,EditText
    * 继承ViewGroup,完全自定义控件LinearLayout,ScrollView
    * 让外界在代码中new对象时调用
    * @param context 上下文
    */
    public FlowLayout(Context context){
    this(context,null);
    }

    /**
    * 在布局文字中配置控件时调用
    * @param context 上下文
    * @param attrs 属性
    */
    public FlowLayout(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
    }

    /**
    * 使用样式时调用
    * @param context 上下文
    * @param attrs 属性
    * @param defStyle 样式
    */
    public FlowLayout(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    initView(attrs);
    }

2.2 定义自定义属性

  • 大部分情况我们的自定义ViewGroup需要有更多的灵活性

    • 比如我们在xml中指定了颜色大小等属性,在程序运行时候控件就能展示出相应的颜色和大小。所以我们需要自定义属性
  • 自定义属性通常写在在res/values/attrs.xml文件中 下面是自定义属性的标准写法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <declare-styleable name="FlowLayout">
    <attr name="hint_mode"> <!--轮播图红点是0,数字是1-->
    <enum name="point" value="0" />
    <enum name="number" value="1" />
    </attr>
    <attr name="hint_gravity"> <!--轮播图红点或数字的位置,左,中,右-->
    <enum name="left" value="0" />
    <enum name="center" value="1" />
    <enum name="right" value="2" />
    </attr>
    <attr name="hint_paddingRight" format="dimension"/>
    <attr name="hint_paddingLeft" format="dimension"/>
    <attr name="hint_paddingTop" format="dimension"/>
    <attr name="hint_paddingBottom" format="dimension"/>
    <attr name="play_delay" format="integer" />
    <attr name="hint_color" format="color" />
    <attr name="hint_alpha" format="integer" />
    </declare-styleable>
    • 这段代码声明了自定义属性,它们都是属于styleable,为了方便,一般styleable的name和我们自定义控件的类名一样。自定义控件定义好了之后就是使用了。
  • 使用代码示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <com.yc.cn.ycflowlib.flow.FlowLayout
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/banner"
    android:layout_width="match_parent"
    android:layout_height="200dp"
    app:hint_color="@color/colorAccent"
    app:hint_gravity="center"
    app:hint_mode="point"
    app:play_delay="2000"/>

03.测量View(Measure)

  • 测量

    • 一个View是在展示时总是有它的宽和高,测量View就是为了能够让自定义的控件能够根据各种不同的情况以合适的宽高去展示。提到测量就必须要提到onMeasure方法了。onMeasure方法是一个view确定它的宽高的地方。
    • 代码如下
    1
    2
    3
    4
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    }

3.1 widthMeasureSpec参数

  • onMeasure方法里有两个重要的参数,widthMeasureSpec,heightMeasureSpec。

    • 在这里你只需要记住它们包含了两个信息:mode和size
  • 可以通过以下代码拿到mode和size

    1
    2
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);
  • 那么获取到的mode和size又代表了什么呢?

    • mode代表了我们当前控件的父控件告诉我们控件,你应该按怎样的方式来布局。
      • mode有三个可选值:EXACTLY, AT_MOST, UNSPECIFIED。它们的含义是:
      • EXACTLY:父控件告诉我们子控件了一个确定的大小,你就按这个大小来布局。比如我们指定了确定的dp值和macth_parent的情况。
      • AT_MOST:当前控件不能超过一个固定的最大值,一般是wrap_content的情况。
      • UNSPECIFIED:当前控件没有限制,要多大就有多大,这种情况很少出现。
    • size其实就是父布局传递过来的一个大小,父布局希望当前布局的大小。

3.2 重写onMeasure伪代码

  • 下面是一个重写onMeasure的固定伪代码写法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    if mode is EXACTLY{
    父布局已经告诉了我们当前布局应该是多大的宽高, 所以我们直接返回从measureSpec中获取到的size
    }else{
    计算出希望的desiredSize
    if mode is AT_MOST
    返回desireSize和specSize当中的最小值
    else:
    返回计算出的desireSize
    }
    • 上面的代码虽然基本都是固定的,但是需要写的步骤还是有点多,如果你不想自己写,你也可以用android为我们提供的工具方法:resolveSizeAndState,该方法需要传入两个参数:我们测量的大小和父布局希望的大小,它会返回根据各种情况返回正确的大小。这样我们就可以不需要实现上面的模版,只需要计算出想要的大小然后调用resolveSizeAndState。之后在做自定义View的时候我会展示用这个方法来确定view的大小。
  • 实际案例中代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    //先取出FlowLayout的父view 对FlowLayout 的测量限制 这一步很重要噢。
    //你只有知道自己的宽高 才能限制你子view的宽高
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
      int usedWidth = 0;      //已使用的宽度
      int remaining = 0;      //剩余可用宽度
      int totalHeight = 0;    //总高度
      int lineHeight = 0;     //当前行高
    
      for (int i = 0; i < getChildCount(); i++) {
          View childView = getChildAt(i);
          LayoutParams lp = childView.getLayoutParams();
    
          //先测量子view
          measureChild(childView, widthMeasureSpec, heightMeasureSpec);
          //然后计算一下宽度里面 还有多少是可用的 也就是剩余可用宽度
          remaining = widthSize - usedWidth;
    
          //如果一行不够放了,也就是说这个子view测量的宽度 大于 这一行 剩下的宽度的时候 我们就要另外启一行了
          if (childView.getMeasuredWidth() > remaining) {
              //另外启动一行的时候,使用过的宽度 当然要设置为0
              usedWidth = 0;
              //另外启动一行了 我们的总高度也要加一下,不然高度就不对了
              totalHeight = totalHeight + lineHeight;
          }
    
          //已使用 width 进行 累加
          usedWidth = usedWidth + childView.getMeasuredWidth();
          //当前 view 的高度
          lineHeight = childView.getMeasuredHeight();
      }
    
      //如果FlowLayout 的高度 为wrap cotent的时候 才用我们叠加的高度,否则,我们当然用父view对如果FlowLayout 限制的高度
      if (heightMode == MeasureSpec.AT_MOST) {
          heightSize = totalHeight;
      }
      setMeasuredDimension(widthSize, heightSize);
    

    }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
        - **计算出height和width之后在onMeasure中别忘记调用setMeasuredDimension()方法。否则会出现运行时异常。**



    #### 3.3 onSizeChange()作用


    #### 3.4 测量子控件的方法比较

    - 要自定义ViewGroup就必须重写onMeasure方法,在这里测量子控件的尺寸。子控件的尺寸怎么测量呢?

    - ViewGroup中提供了三个关于测量子控件的方法:

    /**
    *遍历ViewGroup中所有的子控件,调用measuireChild测量宽高
    */
    protected void measureChildren (int widthMeasureSpec, int heightMeasureSpec) {
    final int size = mChildrenCount;
    final View[] children = mChildren;
    for (int i = 0; i < size; ++i) {
    final View child = children[i];
    if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
    //测量某一个子控件宽高
    measureChild(child, widthMeasureSpec, heightMeasureSpec);
    }
    }
    }

    /**

    • 测量某一个child的宽高
      */
      protected void measureChild (View child, int parentWidthMeasureSpec,
      int parentHeightMeasureSpec) {
      final LayoutParams lp = child.getLayoutParams();
      //获取子控件的宽高约束规则
      final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
      mPaddingLeft + mPaddingRight, lp. width);
      final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
      mPaddingTop + mPaddingBottom, lp. height);
      child.measure(childWidthMeasureSpec, childHeightMeasureSpec);

    }

    /**

    • 测量某一个child的宽高,考虑margin值
      */
      protected void measureChildWithMargins (View child,
      int parentWidthMeasureSpec, int widthUsed,
      int parentHeightMeasureSpec, int heightUsed) {
      final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
      //获取子控件的宽高约束规则
      final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
      mPaddingLeft + mPaddingRight + lp. leftMargin + lp.rightMargin
      + widthUsed, lp. width);
      final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
      mPaddingTop + mPaddingBottom + lp. topMargin + lp.bottomMargin
      + heightUsed, lp. height);
      //测量子控件
      child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
      }
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42

      - measureChildWithMargins跟measureChild的区别?

      - 区别就是父控件支不支持margin属性。支不支持margin属性对子控件的测量是有影响的,比如我们的屏幕是1080x1920的,子控件的宽度为填充父窗体,如果使用了marginLeft并设置值为100; 在测量子控件的时候,如果用measureChild,计算的宽度是1080,而如果是使用measureChildWithMargins,计算的宽度是1080-100 = 980。


      #### 3.5 LayoutParams介绍

      - ViewGroup中有两个内部类ViewGroup.LayoutParams和ViewGroup.MarginLayoutParams
      - MarginLayoutParams继承自LayoutParams,这两个内部类就是VIewGroup的布局参数类,比如我们在LinearLayout等布局中使用的layout_width\layout_hight等以“layout_ ”开头的属性都是布局属性。
      - 在View中有一个mLayoutParams的变量用来保存这个View的所有布局属性。
      - 为什么LayoutParams 类要定义在ViewGroup中?
      - 大家都知道ViewGroup是所有容器的基类,一个控件需要被包裹在一个容器中,这个容器必须提供一种规则控制子控件的摆放,比如你的宽高是多少,距离那个位置多远等。所以ViewGroup有义务提供一个布局属性类,用于控制子控件的布局属性。
      - 为什么View中会有一个mLayoutParams 变量?
      - 之前学习自定义控件的时候学过自定义属性,我们在构造方法中,初始化布局文件中的属性值,我们姑且把属性分为两种。一种是本View的绘制属性,比如TextView的文本、文字颜色、背景等,这些属性是跟View的绘制相关的。另一种就是以“layout_”打头的叫做布局属性,这些属性是父控件对子控件的大小及位置的一些描述属性,这些属性在父控件摆放它的时候会使用到,所以先保存起来,而这些属性都是ViewGroup.LayoutParams定义的,所以用一个变量保存着。


      #### 3.6 getChildMeasureSpec方法

      - measureChildWithMargins跟measureChild都调用了这个方法
      - 其作用就是通过父控件的宽高约束规则和父控件加在子控件上的宽高布局参数生成一个子控件的约束。我们知道View的onMeasure方法需要两个参数(父控件对View的宽高约束),这个宽高约束就是通过这个方法生成的。有人会问为什么不直接拿着子控件的宽高参数去测量子控件呢?打个比方,父控件的宽高约束为wrap_content,而子控件为match_perent,是不是很有意思,父控件说我的宽高就是包裹我的子控件,我的子控件多大我就多大,而子控件说我的宽高填充父窗体,父控件多大我就多大。最后该怎么确定大小呢?所以我们需要为子控件重新生成一个新的约束规则。只要记住,子控件的宽高约束规则是父控件调用getChildMeasureSpec方法生成。
      - getChildMeasure方法代码不多,也比较简单,就是几个switch将各种情况考虑后生成一个子控件的新的宽高约束,这个方法的结果能够用一个表来概括:
      - ![image](android-custom-viewgroup/1240.webp)



      ### 04.布局View(Layout)

      #### 4.1 onLayout方法参数

      - 关于left、right、top、bottom。
      - 它们都是坐标值,既然是坐标值,就要明确坐标系,这个坐标系是什么?我们知道,这些值都是ViewGroup设定的,那么,这个坐标系自然也是由ViewGroup决定的了。这个坐标系就是以ViewGroup左上角为原点,向右x,向下y构建起来的。
      - ViewGroup的左上角又在哪里呢?
      - 我们知道,在ViewGroup的parent(也是ViewGroup)眼中,我们的ViewGroup就是一个普通的View。假如我们的ViewGroup没有parent,它的左上角在屏幕上的位置又该如何确定?系统控制的Window都有一个DecorView,其实这个DecorView就是一个帧布局。
      - 如何理解ViewGroup那个方框
      - 代表ViewGroup的方框的宽是上述方法中的right-left,方框的高是bottom-top。


      #### 4.2 重写onLayout代码

      - 大概示例代码如下所示

      /**
    • layout的算法 其实就是 不够放剩下一行 那另外放一行 这个过程一定要自己写一遍才能体会,
    • 个人有个人的写法,说不定你的写法比开源的项目还要好
    • 其实也没什么夸张的,无法就是前面onMeasure结束以后 你可以拿到所有子view和自己的 测量宽高 然后就算呗
    • @param changed
    • @param l
    • @param t
    • @param r
    • @param b
      */

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childTop = 0;
    int childLeft = 0;
    int childRight = 0;
    int childBottom = 0;

    //已使用 width
    int usedWidth = 0;
    
    
    //customlayout 自己可使用的宽度
    int layoutWidth = getMeasuredWidth();
    Log.v("wuyue", "layoutWidth==" + layoutWidth);
    for (int i = 0; i < getChildCount(); i++) {
        View childView = getChildAt(i);
        //取得这个子view要求的宽度和高度
        int childWidth = childView.getMeasuredWidth();
        int childHeight = childView.getMeasuredHeight();
    
        //如果宽度不够了 就另外启动一行
        if (layoutWidth - usedWidth < childWidth) {
            childLeft = 0;
            usedWidth = 0;
            childTop += childHeight;
            childRight = childWidth;
            childBottom = childTop + childHeight;
            childView.layout(0, childTop, childRight, childBottom);
            usedWidth = usedWidth + childWidth;
            childLeft = childWidth;
            continue;
        }
        childRight = childLeft + childWidth;
        childBottom = childTop + childHeight;
        childView.layout(childLeft, childTop, childRight, childBottom);
        childLeft = childLeft + childWidth;
        usedWidth = usedWidth + childWidth;
    
    }
    

    }
    ```