0%

第12章:封装控件

自定义属性与自定义 Style

我们一般会自定义一个View,比如 MyCustomView,然后再设置自定义属性:在 res/values 目录下,创建一个 attrs.xml ,在其中编写 declare-styleable :

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MyCustomView">
<attr name="header" format="reference"/>
<attr name="age">
<flag name="child" value="10"/>
<flag name="young" value="18"/>
</attr>
</declare-styleable>
</resources>

这个过程有2点需要注意:

  • declare-styleable 旁边的name属性,这个属性的取值对应所定义的类名。因为我们自定义的类名叫做 MyCustomView ,所以这里的name 取值也是 MyCustomView
  • 自定义属性值可以组合使用,比如 表示既可以自定义 color 值(比如#ff0000),也可以利用 @color/xxx 来引用 color.xml 中已有的值

当然,我们有时候也需要使用常量来表示,比如上述的 flag,相当于代码中的常量,比如 young 就表示数字 18。

在xml中使用自定义属性

要记得以 xmlns 导入自定义的属性集:

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

在代码中获取自定义的属性主要使用 TypedArray

declare-styleable 标签其他属性的用法

暂略

测量与布局

ViewGroup 的绘制流程

View 和ViewGroup 基本相同,只不过ViewGroup 不仅要绘制自己,还要绘制其子控件,而View 只需要绘制自己即可,所以就以 ViewGroup 来讲解。

绘制流程分为 3 个步骤,测量(onMeasure)、布局(onLayout)、绘制(onDraw),需要注意的一点是:onMeasure 用于测量当前控件的大小,为正式布局提供建议(只是提供建议,至于用不用,需要看 onLayout 函数)。

onMeasure 函数与 MeasureSpec

测量过程通过 measure() 函数实现,是 View 树自顶向下的遍历,每个 View 在循环过程中将尺寸细节往下传递,当测量过程完成后,所有的 View 都存储了自己的尺寸。

并且,布局过程 layout 也是自顶向下实现的,在这个过程中,每个父 View 负责通过计算好的尺寸放置它的子 View。

onMeasure 函数

该函数有2个int类型的参数 widthMeasureSpec 和 heightMeasureSpec ,这两个参数都是父View传递过来给当前 View 的一个建议值

MeasureSpec 组成

虽然上述参数是int 类型的,但是它们是由 mode + size 两部分组成的。它们转换成二进制后,前2位表示模式(mode),后30位表示数值(size)。模式主要有3种:

  • UNSPECIFIED(mode位:00):父元素不对子元素施加任何约束,子元素可以得到任意想要的大小
  • EXACTLY(mode位:01):父元素决定子元素的确切大小,子元素将被限定在给定的便捷里而忽略它本身大小
  • AT_MOST(mode位:10):子元素至多能达到指定的大小值

由以上特性,我们就可以很简单地直到模式和数值的提取:MODE_MASK,它的二进制表示中,前2位是1,其余30位都是 0,这样,我们只需要将widthMeasureSpec (或 heightMeasureSpec) 位与(&) MODE_MASK 就可以得到 mode 值,将 他们 & ~MODE_MASK 即可得到数值

mode 的用处

参数 widthMeasureSpec 和 heightMeasureSpec 各自都有对应的mode,而这个mode 来自 XML 定义,简单来说xml 布局和 mode 有如下关系:

  • Wrap_content -> MeasureSpec.AT_MOST
  • match_parent -> MeasureSpec.EXACTLY
  • 具体值 -> MeasureSpec.EXACTLY
1
2
3
<com.example.FlowLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"/>

比如上述例子中,FlowLayout 在 onMeasure() 函数中传值时,widthMeasureSpec 的mode 是 MeasureSpec.EXACTLY ,即父窗口宽度值;heightMeasureSpec 的mode是 MeasureSpec.AT_MOST ,即值不确定。一定要注意的是:当模式是 MeasureSpec.EXACTLY 时,就不必设定我们计算的值了,因为这个大小是用户指定的,我们不应该改。但当模式是 MeasureSpec.AT_MOST 时,就需要将大小设定为我们计算的数值,因为用户使用的是 wrap_content,没有设置具体值

1
2
3
4
5
6
7
8
9
10
11
12
protected void onMeasure(int widthMeasureSpec,int heightMeasureSpec) {
//假设width 与 height 使我们计算得到控件应该占的宽和高,省略计算过程
int width = ...;
int height = ...;

//假设 measureWidth 与 measureHeight 是获取到的后30位的value
int measureWidth = ...;
int measureHeight = ...;

//在 onMeasure 函数中,最终我们必须要通过 setMeasuredDimension 来设置
setMeasuredDimension((widthMode == MeasureSpec.EXACTLY)?measureWidth: width, (heightMode == MeasureSpec.EXACTLY)? measureHeight: height,)
}

onLayout 函数

前面说了 ,onLayout 实现了所有子View的布局,注意,是所有子View。那它自己的布局怎么办?这个后续说。其实 onLayout 是个抽象函数,也就是所有继承 ViewGroup 的类都要自己去实现这个函数,LinearLayout 和 RelativeLayout 都是如此,均重写了。

简单示例(自己加的章节)

用一个简单示例说明 onMeasure 与 onLayout 的具体使用,比如要做如下效果图:

简单效果

这个效果图需要关注2点:(1)、三个TextView 竖直排列 (2)、背景Layout宽度是 match_parent ,高度是 wrap_content 。首先看下布局文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<com.example.myapplication.MyLinLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#ff00ff">

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#ff0000"
android:text="第一个VIEW" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#00ff00"
android:text="第二个VIEW" />

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#0000ff"
android:text="第三个VIEW" />

</com.example.myapplication.MyLinLayout>

自定义的 MyLinLayout 的宽高分别为 match_parent 和 wrap_content。

MyLinLayout 重写 onMeasure() 函数

前面提到 onMeasure 的作用就是根据 container 内部的子控件计算自己的宽和高 ,然后通过 setMeasuredDimension 方法设置进去。先看看完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int measureWidth = MeasureSpec.getSize(widthMeasureSpec);
int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
int measureWidthMode = MeasureSpec.getMode(widthMeasureSpec);
int measureHeightMode = MeasureSpec.getMode(heightMeasureSpec);

int height = 0;
int width = 0;
int count = getChildCount();
for (int i = 0; i < count; i++) {
//测量子控件
View child = getChildAt(i);
measureChild(child, widthMeasureSpec, heightMeasureSpec);
//获取子控件的宽高
int childHeight = child.getMeasuredHeight();
int childWidth = child.getMeasuredWidth();
//得到最大宽度,并且累加高度
height += childHeight;
width = Math.max(childWidth, width);
setMeasuredDimension((measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth : width,
(measureHeightMode == MeasureSpec.EXACTLY) ? measureHeight : height);
}

步骤如下:

  1. 获取从父类传过来的建议宽高,提取mode 和value
  2. 测量所有的子 View ,之后就能获取所有子View 的测量宽高
  3. 根据所有子View 的宽高,来获得自己的计算宽高,这个值计算的其实就是自己宽和高都被设置为 wrap_content 情况下的值,因为 Exactly 的时候,并不建议我们去改value 的。
  4. 因为是竖直排列,所以container 的高度应该是各个子View的高度和;宽度应该是各个子View 最大的宽度
  5. 最后,根据当前用户设置的mode来判断是否需要将这个计算宽高设置进去,用它来实现当前container 所在的位置

由于我们在上面的xml 布局文件中,将 MyLinLayout 的宽度设置为 match_parent ,高度为 wrap_content ,所以在onMeasure 里面,

int measureWidthMode 应该是MeasureSpec.EXACTLY; measureHeightMode 为 MeasureSpec.AT_MOST,换句话说,width 使用的是从父类传过来的 measureWidth ,高度是我们自己计算的 height,即实际情况应该等价于:

1
setMeasuredDimension(measureWidth, height);

MyLinLayout 重写 onLayout 函数

在这一部分是根据自己的医院把 container 内部的各个控件排列起来,先看完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int top = 0;
int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
int childHeight = child.getMeasuredHeight();
int childWidth = child.getMeasuredWidth();
//设置子View的位置
child.layout(0, top, childWidth, top + childHeight);
top += childHeight;
}
}

代码讲完,再讲一个非常容易混淆的问题:

getMeasuredWidth 与 getWidth

这两个值大部分时候是相同的,但是含义根本不一样,二者区别主要体现在:

  • getMeasuredWidth 函数在 measure 过程结束后就可以获取到宽度值,而getWidth 需要在 layout 完成后才能获取到高度值!
  • getMeasuredWidth 获取的值是通过 setMeasuredDimension 函数来设置的;而 getWidth 函数值则是通过 layout(left,top,right,bottom) 函数来进行设置的

看完两个函数后,我们也明白了 onMeasure 阶段提供的测量结果只是为布局提供建议的,最终要看 onLayout 函数。因为我们在 child.layout 的时候,直接通过 0 + child.measuredWidth 来计算right 的值,所以我 getMeasuredWidth 与 getWidth 函数返回的值就是一样的了

疑问:container 自己什么时候被布局

这其实要追溯到 View 的 layout 里面:

1
2
3
4
5
6
public void layout(int l, int t, int r, int b) {
...省略代码
boolean changed = setFrame(l, t, r, b);
...省略代码
onLayout(changed, l, t, r, b);
}

setFrame 设置的是自己的位置,结束后才调用 onLayout 。此时,measure 和 layout 都结束了,但是我们还没考虑 margin。

获取子控件margin

如果要自定义 ViewGroup 支持子控件的 layout_margin 参数,则自定义的 ViewGroup 类必须重写 generateLayoutParams() 函数,并且在该函数返回一个 ViewGroup.MarginLayoutParams 派生类对象!

为了验证,我们可以在之前的 MyLinLayout 例子基础上,为 TextView 添加 margin ,但是我们能看到,压根就没有起作用,代码就先略了。这是为什么呢?因为测量和布局都是我们自己实现的,我们在 onLayout() 函数中没有根据 margin 来布局。

需要注意的是,如果我们在 onLayout() 函数中根据 margin 来布局,那么 onMeasure() 函数中计算 container 的大小时,也要加上 layout_margin 参数,否则导致 container 太小而控件显示不全

实例展示

之前说的需要重写 generateLayoutParams() 函数,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
protected LayoutParams generateLayoutParams(LayoutParams p) {
return new MarginLayoutParams(p);
}

@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}

@Override
protected LayoutParams generateDefaultLayoutParams() {
return new MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
}

至于为什么要这么写,稍后再讲。

重写 onMeasure() 函数

实现 FlowLayout 容器

先略

当measure 完成之后,尺寸才会存到 View 属性中,我们才能获取到 View 的属性

谢谢你的鼓励