gongmingqm10

Life is a journey, not a destination

Android自定义View

| Comments

Android开发中经常用到各种各样的View,有时需要自定义View来满足当前的需求。这些自定义View主要是复写View绘制时的一些方法,从而产生新的View供项目中使用。

View的绘制流程

自定义控件从最基础的View开始,View有几个重要的函数:onMeasure(), onLayout(), onDraw(),与触摸动作相关的还有onTouchEvent(),View也和Activity一样具有一定的生命周期,从View被创建开始到创建完成,主要经历了 onMeasure onLayout onDraw() 等过程,这些过程都是一步步完成的。也代表着View从声明到被用户看到的具体步骤。通过对这些中间步骤的了解与Override,我们可以创造出一些特殊的View。

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

  public class CustomView extends View {

    private static final String TAG = "gongmingqm10";

    public CustomView(Context context) {
        super(context);
    }

    public CustomView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public CustomView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Log.i(TAG, "--onDraw--");
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        Log.i(TAG, "--onLayout--" + left + " - " + top + " - " + right + " - " + bottom);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        Log.i(TAG, "--onMeasure--" + widthMeasureSpec + " - " + heightMeasureSpec);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        Log.i(TAG, "--onTouchEvent--" + event.getX() + " - " + event.getY());
        return super.onTouchEvent(event);
    }
  }

在布局文件main.xml文件中这样使用自定义的CustomView

1
2
3
4
5
6
7
8
9
10
11
12

  <?xml version="1.0" encoding="utf-8"?>

  <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
      android:orientation="vertical" android:layout_width="match_parent"
          android:layout_height="match_parent">
      <org.gongming.common.swipelist.CustomView
          android:layout_width="match_parent"
          android:layout_height="600px"
          android:background="#333666"
          />
  </FrameLayout>

Logcat输出结果为

09-25 15:55:29.876    1535-1535/org.gongming.uikit I/gongmingqm10﹕ --onMeasure--1073742904    - 1073742424
09-25 15:55:29.876    1535-1535/org.gongming.uikit I/gongmingqm10﹕ --onMeasure--1073742904 - 1073742424
09-25 15:55:29.876    1535-1535/org.gongming.uikit I/gongmingqm10﹕ --onLayout--0 - 0 - 1080 - 600
09  -25 15:55:29.876    1535-1535/org.gongming.uikit I/gongmingqm10﹕ --onDraw--

通过上面的Logcat,我们可以看到在View的创建过程中, onMeasure被连续两次调用,调用完成之后紧接着onLayout(),最后进行onDraw(),于是一个View完成了从声明到创建的全过程。那么onMeasure() onLayout() onDraw()这几个方法究竟在绘制View的过程中起到了怎样的作用呢?

1. onMeasure(int widthMeasureSpec, int heightMeasureSpec)

从Log打印出来的信息来看,参数 widthMeasureSpec和heightMeasureSpec的值看起来没有特别实际的意义,但是Android本身提供了MeasureSpec.getMode(measureSpec)MeasudeSpec.getSize(measureSpec)方法,这两个方法能够分别拿到int类型的 modesizemodesize主要描述了当前控件在父控件中的占位方式,以及根据占位方法计算出来的大小。我们可以根据 mode size 的值对控件的实际大小进行自定义控制。如果我们不使用super.onMeasure(widthMeasureSpec, heightMeasureSpec),我们必须调用setMeasureDimense(width, height)来使这些设置生效。width, height分别表示当前这个控件真实的大小。

通过对onMeasure()函数的理解,我们基本知道onMeasure()的功能是告诉Android当前View的大小,并且此大小也是根据布局中父容器的约束生成的。当前我们也可以根据这些大小进行自定义设置当前View的大小。引用 stackoverflow上的回答:

onMeasure() is your opportunity to tell Android how big you want your custom view to be dependent the layout constraints provided by the parent; it is also your custom view’s opportunity to learn what those layout constraints are (in case you want to behave differently in a match_parent situation than a wrap_content situation). These constraints are packaged up into the MeasureSpec values that are passed into the method.

对于MeasureSpec的mode主要有EXACTLY, AT_MOST, UNSPECIFIED三种值:

EXACTLY

字面意义是准确的,也就是我们的View是的宽度和高度是固定准确的,mode为这种值时,通常是我们设置了layout_width或者layout_height的值为一个给定的值,或者我们设置View的宽度或高度为match_parent,而父容器的宽度或高度固定。

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent">

    <org.gongming.common.swipelist.CustomView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#333666"
        />

</FrameLayout>

这里的CustomView宽度和高度都是 match_parent,而parent也是充满整个view页面,因此parent的大小是固定的,所以CustomView的大小也是固定的,这里取到的mode值就为EXACTLY

如果把FrameLayout的宽度高度都设置成wrap_content,此时得到的mode同样都是EXACTLY,因为FrameLayout宽高收到屏幕大小的约束,其本身的大小是收到子view的影响,此时子View通过match_parent获得最大的空间,于是FrameLayout的宽高被撑到了最大值的水平。

AT_MOST

通过名词本身的描述,这种状态下意味着View存在一个最大值的约束,最大值约束一般来自父View,父View经过层层依赖,又会受到设备屏幕大小的约束。通过下面的例子看什么时侯View会呈现出AT_MOST状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent">

    <org.gongming.common.swipelist.CustomView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#333666"
        />

</FrameLayout>

这种情况下CustomView的高度是wrap_content,自适应内容,没有固定的值。但是由于父容器FrameLayout的宽度和高度是固定的,因此FrameLayout的宽高将会约束CustomView的宽高。CustomView的高度此时会有个最大值约束。这是 CustomView高度的Mode是AT_MOST.
如果把FrameLayout的layout_height设置为wrap_content,由于父容器自身的高度有个AT_MOST属性,子元素CustomView的最大值也不会超过父元素的最大值约束。所以此时CustomView的高度Mode仍然是AT_MOST
综合来看,只要View的当前宽度或者高度不是固定的,但是会存在一个最大值界限,则View 的Mode为AT_MOST

UNSPECIFIED

UNSPECIFIED意味着高度或者宽度值是不明确不具体的,即Android系统本身都很难决定元素的宽度或者高度值,也没有对其宽度高度的约束值。一个典型情况是ScrollView,由于ScrollView的可以自滑动,因此其滑动方向上可以进行无限的伸缩,于是View在绘制时就很难确定其具体约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="utf-8"?>

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <org.gongming.common.swipelist.CustomView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#333666"
        />

</ScrollView>

ScrollView默认是竖直滑动,因此其高度值是难以确定的,位于其里面的CustomView如果没有提供明确的竖直,那么无论使用match_parent,还是wrap_content,高度值都是不能确定的。高度的Mode为UNSPECIFIED
除了上述的MeasureSpec.getMode()外,在本函数中我们还可以通过MeasureSpec.getSize()得到View真实的值,这个真实值将会用来绘制当前View。我们自定义View的时侯也可以通过onMeasure进行恰当的重写,从而实现我们自己想要的功能。

2. onLayout(boolean changed, int left, int top, int right, int bottom)

onLayout()的参数中我们能够直接拿到当前view的位置,(left, top)描述了左上顶点的位置,而(right, bottom)确定了右下角的位置,在自定义View的时侯,可以根据父容器的位置,调用子View的layout()函数直接指定子view的位置。我们的LinearLayout, RelativeLayout等就是继承ViewGroup,然后根据子View设置的相关属性,从而确定子View应该被放在哪里,我们在app中也就看到了期望的界面。

3. onDraw(Canvas canvas)

Canvas是View的画布,有了canvasView才会真正的显示出来,才有我们看到的背景,图像,边框等元素。通过复写onDraw(),我们能够利用canvas做一些自定义的行为。比如我们通常看到的显示圆形ImageView头像等。

自定义View的实现

自动换行控件-FlowLayout

这个Demo中主要通过继承ViewGroup实现自动换行控件,这种通常被用来放置TextView,对文字长度和数量位置的TextView来说,自动换行控件能够实现很好的布局效果,我们只需要将TextView加载到FlowLayout中,便可实现TextView组的自动换行。我们也可以批量给TextView增加自定义事件,进而实现我们想做的事情。

自定义ViewGroup主要参考Google的ViewGroup,参照自定义控件写法,主要是人工对onMeasure()和onLayout进行重写。onMeasure()决定了控件本身的宽度和高度,而onLayout()则用来确定子类的位置摆放。子类在父类中的位置摆放通过layout(l, t, r, b),这几个值表示控件的左上坐标和右下坐标,坐标是相对于父容器的位置来确定的。

首先看具体效果:
FlowLayout

代码实现如下FlowLayout.java

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
package org.gongming.common;

import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;

public class FlowLayout extends ViewGroup {

    private static final int leftMargin = 6;
    private static final int rightMargin = 6;
    private static final int topMargin = 10;
    private static final int bottomMargin = 10;


    public FlowLayout(Context context) {
        super(context);
    }

    public FlowLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public FlowLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int maxWidth = 0;
        int maxHeight = 0;
        int childState = 0;
        int calcHeight = 0;

        int totalWidth = MeasureSpec.getSize(widthMeasureSpec);
        int count = getChildCount();

        for (int index = 0; index < count; index++) {
            final View child = getChildAt(index);
            if (child.getVisibility() != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
                int calcWidth = child.getMeasuredWidth() + leftMargin + rightMargin;
                calcHeight = Math.max(calcHeight, child.getMeasuredHeight() + topMargin + bottomMargin);
                if (maxWidth + calcWidth > totalWidth) {
                    maxWidth = totalWidth;
                    maxHeight += calcHeight;
                } else {
                    maxWidth += calcWidth;
                }
                childState = combineMeasuredStates(childState, child.getMeasuredState());
            }
        }
        maxHeight += calcHeight;
        maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight());
        maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth());
        setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState),
                resolveSizeAndState(maxHeight, heightMeasureSpec,
                        childState << MEASURED_HEIGHT_STATE_SHIFT));
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int totalWidth = getMeasuredWidth();
        int count = getChildCount();
        int width = 0;
        int height = 0;

        for (int index = 0; index < count; index++) {
            View view = getChildAt(index);
            int calcHeight = view.getMeasuredHeight() + topMargin + bottomMargin;
            int calcWidth = view.getMeasuredWidth() + leftMargin + rightMargin;
            if (width + calcWidth > totalWidth) {
                height += calcHeight;
                width = calcWidth;
            } else {
                width += calcWidth;
                height = Math.max(calcHeight, height);
            }
            view.layout(width - calcWidth + leftMargin, height - calcHeight + topMargin, width - rightMargin, height - bottomMargin);
        }
    }

    public void setOnItemClickListener(OnClickListener listener) {
        for (int index = 0; index < getChildCount(); index++) {
            View child = getChildAt(index);
            child.setOnClickListener(listener);
        }
    }
}

看看我们在xml中如果使用当前定义的View,主要通过包名访问:

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
43
<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical" android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:layout_height="wrap_content"
        android:layout_width="wrap_content"
        android:text="Root Swipe Layout"
        android:layout_gravity="center"
        />
    <org.gongming.common.FlowLayout
        android:layout_height="wrap_content"
        android:layout_width="match_parent"
        android:id="@+id/flowLayout"
        >
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="hahahah"
            android:background="#7E7EFF"
            />
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="This is a amazing layout"
            android:background="#7E7EFF"
            />
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Where is this layout"
            android:background="#7E7EFF"
            />
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="Oh, God. Please save me. I'm the cool layout"
            android:background="#7E7EFF"
            />
    </org.gongming.common.FlowLayout>
</LinearLayout>

在java代码里还可以定义item的单击事件,flowLayout.setOnItemClickListener(OnClickListener listener), 这样就能对用户的单击行为进行响应。

定比例长宽RelativeLayout-RatioRelativeLayout

Android设备的多样性使得我们要尽量做出适应多设备的可伸缩布局。在最佳实践中,我们谈到要尽量使用RelativeLayout相对布局。同样,我们在这里自定义了一种固定长宽比例的元素。通过自己指定的高度/宽度的比例值,最终确定View应该绘制的宽度和高度。定义如下,主要增加了一个ratio属性,以及重写了onMeasure()方法。
res/values/attrs.xml

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="RatioLayout">
        <!--The ratio defines the value of height / width -->
        <attr name="ratio" format="float" />
    </declare-styleable>

</resources>

RatioRelativeLayout.java

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
43
44
45
46
47
48
package org.gongming.common;

import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.RelativeLayout;

public class RatioRelativeLayout extends RelativeLayout {

    private float ratio = 0f;

    public RatioRelativeLayout(Context context) {
        super(context);
    }

    public RatioRelativeLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(attrs);
    }

    public RatioRelativeLayout(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
    }

    private void init(AttributeSet attrs) {
        TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.RatioLayout);
        ratio = typedArray.getFloat(R.styleable.RatioLayout_ratio, 0f);
        typedArray.recycle();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int measureWidth = MeasureSpec.getSize(widthMeasureSpec);
        int measureHeight = MeasureSpec.getSize(heightMeasureSpec);

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        if (widthMode == MeasureSpec.EXACTLY && ratio != 0f) {
            measureHeight = (int) (measureWidth * ratio);
        } else if (heightMode == MeasureSpec.EXACTLY && ratio != 0f) {
            measureWidth = (int) (measureHeight / ratio);
        } else {
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
        setMeasuredDimension(measureWidth, measureHeight);
    }
}

在xml使用时使用该控件并给ratio设置值即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="utf-8"?>

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"

    >

    <org.gongming.common.RatioRelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@android:color/darker_gray"
        app:ratio="0.5"
        >

    </org.gongming.common.RatioRelativeLayout>

</FrameLayout>

最后在界面上我们便可以看到一个高度是宽度一半的自定义View。

Comments