Android 自定义 View 基础篇

324 阅读8分钟

绘制流程

View 的绘制流程是从 ViewRootImpl 的 performTraversals 方法开始的,从上到下遍历整个视图树,每个 View 控件负责绘制自己,而 ViewGroup 还需要负责通知子 View 进行绘制操作。performTraversals 会依次调用 performMeasure,performLayout 和 performDraw 三个方法,分别完成 DecorView 的测量,布局和绘制三大流程。

DecorView 的加载

ViewRoot 对应于 ViewRootImpl 类,它是连接 WindowManager 和 DecorView 的纽带,View 的三大流程均是通过 ViewRoot 来完成的。在 ActivityThread 中,当 Activity 对象创建后,会将 DecorView 添加到 Window 中,同时会创建 ViewRootImpl 对象,并将 ViewRootImpl 对象和 DecorView 建立关联。

测量过程

我们首先要明白 MeasureSpec,它表示的是一个32位的整形值,它的高2位表示测量模式 SpecMode,低30位表示某种测量模式下的规格大小 SpecSize 。

测量模式有三类,分别是:

  • EXACTLY:精确测量模式,视图宽高指定为 match_parent 或具体数值时生效。
  • AT_MOST :最大值测量模式,当视图的宽高指定为 wrap_content 时生效,子视图的尺寸可以是不超过父视图允许的最大尺寸的任何尺寸。
  • UNSPECIFIED:不指定测量模式,父视图没有限制子视图的大小,子视图可以是想要的任何尺寸,通常用于系统内部,应用开发中很少用到。

直接继承 View 的控件需要重写 onMeasure 方法并设置 wrap_content 时的自身大小,否则在布局中使用 wrap_content 就相当于使用 match_parent 。

测量过程是从 performMeasure 开始的:

  1. 遍历测量 ViewGroup 中所有的 View,在此过程中,当 View 的可见性处于 GONE 时,则不对其进行测量。
  2. 测量某个指定的 View,根据父容器的 MeasureSpec 和子 View 的 LayoutParams 等信息计算子 View 的 MeasureSpec。
  3. 不同的 ViewGroup 子类有不同的布局特性,这导致它们的测量细节各不相同,如果需要自定义测量过程,则子类可以重写 onMeasure 方法,通过 setMeasureDimension 方法来设置 View 的测量宽高。
  4. 如果 View 没有重写 onMeasure 方法,则会默认调用 getDefaultSize 来获得 View 的宽高。这里会进行判断,如果 View 没有设置背景,那么返回 minWidth 这个属性所指定的值,如果 View 设置了背景,则返回 minWidth 和背景的最小宽度这两者中的最大值。

由于 View 的测量过程和 Activity 的生命周期方法不是同步执行的,如果 View 还没有测量完毕,那么获得的宽高就是 0,所以在 onCreate,onStart 和 onResume 中均无法正确得到某个 View 的宽高信息,而在 post 方法中执行,就是在线程里面获取宽高,这个线程会在视图没有绘制完成时放在一个等待队列里,等视图绘制完成后再去执行队列里面的线程,所以在 post 里面可以获取 View 的宽高。

view.post {
    val width = view.measuredWidth
    val height = view.measuredHeight
}

布局过程

布局过程是从 performLayout 开始的:

  1. 执行 layout 方法,通过 setFrame 方法来设定 View 的四个顶点的位置,即 View 在父容器中的位置。
  2. 执行 onLayout 方法,它是一个空方法,子类如果是 ViewGroup 类型,则重写这个方法,实现 ViewGroup 中所有 View 控件布局流程。
  3. 当 ViewGroup 的位置确定后,会在 onLayout 方法中遍历所有子 View 并调用子 View 的 layout 方法,确定子 View 自己的位置。

绘制过程

绘制过程是从 performDraw 开始的,流程是:

  1. 绘制 View 的背景
  2. 绘制 View 的内容
  3. 绘制 View 的子 View
  4. 绘制装饰,如滚动条等

具体实现方式

  1. 继承系统 View :继承 TextView 等系统控件,在此基础上进行扩展。
  2. 继承系统 ViewGroup :继承 LinearLayout 等系统控件,在此基础上进行扩展。
  3. 继承 View :不复用系统控件逻辑,继承 View 进行功能定义。
  4. 继承 ViewGroup :不复用系统控件逻辑,继承 ViewGroup 进行功能定义。
  5. 自定义组合控件 :多个控件组合成为一个新的控件,方便复用。

坐标系

以屏幕左上角作为原点,原点向右是 X 轴的正轴,向下是 Y 轴正轴。

000.png

111.png

所以,宽高计算规则为

val width = right - left
val height = bottom - top

构造函数

class MyView : View {

    //创建对象的时候用到
    constructor(context: Context?) : super(context) {}

    //在 xml 布局文件中使用时自动调用
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {}

    //不会自动调用,如果有默认 style 时,在第二个构造函数中调用
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    ) {
    }

    //API>21 时才会用到,不会自动调用,如果有默认 style 时,在第二个构造函数中调用
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    constructor(
        context: Context?,
        attrs: AttributeSet?,
        defStyleAttr: Int,
        defStyleRes: Int
    ) : super(context, attrs, defStyleAttr, defStyleRes) {
    }
}

自定义属性

    <declare-styleable name="MyView">
        <attr name="viewAttr1" format="string" />
        <attr name="viewAttr2" format="string" />
    </declare-styleable>
class MyView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {

    init {
        val typeArray = context?.obtainStyledAttributes(attrs, R.styleable.MyView)
        val viewAttr1 = typeArray?.getString(R.styleable.MyView_viewAttr1)
        val viewAttr2 = typeArray?.getString(R.styleable.MyView_viewAttr2)
        typeArray?.recycle()
    }

}
    <com.example.openglapp.MyView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:viewAttr1="hello"
        app:viewAttr2="world" />

继承系统 View

这种方式会复用系统的逻辑,大多数情况下希望复用系统的 onMeasure 和 onLayout 流程,所以我们通常只需重写 onDraw 方法。

举个例子,我们继承系统控件 AppCompatTextView ,为其绘制背景。

class MyView(context: Context, attrs: AttributeSet?) : AppCompatTextView(context, attrs) {

    private val paint: Paint = Paint()

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        paint.color = context.getColor(R.color.blue)
        val rect = Rect(0, 0, width, height)
        //绘制背景
        canvas?.drawRect(rect, paint)
    }

}

继承系统 ViewGroup

这里举个简单的例子,继承 LinearLayout,然后往里面加入 Button 和 TextView 。

class CusLinearLayout(context: Context) : LinearLayout(context) {

    private val button: Button = Button(context)
    private val textView = TextView(context)

    init {
        orientation = VERTICAL
        val layoutParam =
            LayoutParams(DensityUtils.dp2px(context, 300f), DensityUtils.dp2px(context, 300f))
        button.layoutParams = layoutParam
        textView.layoutParams = layoutParam
        textView.text = "welcome to pay attention to me"
        button.text = "button"
        button.setOnClickListener {
            Toast.makeText(context, "click", Toast.LENGTH_SHORT).show()
        }
        addView(button)
        addView(textView)
    }
}

继承 View

这种方式因为不复用系统控件的逻辑,所以 onDraw 和 onMeasure 方法都要重写。

class MyView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private val paint: Paint = Paint()

    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        paint.color = Color.RED
        val viewWidth = width - paddingStart - paddingEnd
        val viewHeight = height - paddingTop - paddingBottom
        canvas?.drawRect(
            paddingStart.toFloat(),
            paddingTop.toFloat(), (viewWidth - paddingStart).toFloat(),
            (viewHeight + paddingTop).toFloat(), paint
        )
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)

        if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
            //该方法用来设置 View 的宽高
            setMeasuredDimension(
                DensityUtils.dp2px(context, 300f),
                DensityUtils.dp2px(context, 300f)
            )
        } else if (widthMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(DensityUtils.dp2px(context, 300f), heightSize)
        } else if (heightMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSize, DensityUtils.dp2px(context, 300f))
        }
    }

}

顺便也贴上 dp 和 px 的转换工具类

class DensityUtils {

    companion object {
        fun px2dp(context: Context, pxValue: Float): Int {
            val scale = context.resources.displayMetrics.density
            return (pxValue / scale + 0.5f).toInt()
        }

        fun dp2px(context: Context, dpValue: Float): Int {
            val scale = context.resources.displayMetrics.density
            return (dpValue * scale + 0.5f).toInt()
        }
    }

}

继承 ViewGroup

对于自定义 ViewGroup,最典型的例子就是流式布局了,这里简单实现一下吧 ~

class FlowLayout : ViewGroup {

    // 记录每个 View 的位置
    private val childList = ArrayList<ChildPosition>()

    constructor(context: Context) : super(context)
    constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)
    constructor(context: Context, attributeSet: AttributeSet, defStyle: Int) : super(
        context,
        attributeSet,
        defStyle
    )

    // 子 View 的位置
    data class ChildPosition(
        val left: Int,
        val top: Int,
        val right: Int,
        val bottom: Int
    )

    /**
     * 让 ViewGroup 支持 margin 属性
     */
    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 获取宽高
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)

        // 流式布局的最终宽高
        var layoutWidth = 0
        var layoutHeight = 0

        // 记录每一行的宽度和高度
        var lineWidth = 0
        var lineHeight = 0

        childList.clear()

        // 循环遍历每个子 View
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            // 测量子 View 的宽高
            measureChild(child, widthMeasureSpec, heightMeasureSpec)
            val lp = child.layoutParams as MarginLayoutParams
            // 子 View 所占的宽度和高度
            val childWidth =
                child.measuredWidth + lp.leftMargin + lp.rightMargin
            val childHeight =
                child.measuredHeight + lp.topMargin + lp.bottomMargin

            if (lineWidth + childWidth > widthSize - paddingLeft - paddingRight) {
                // 如果当前所占的宽度大于总宽度,则换行
                layoutWidth = Math.max(layoutWidth, lineWidth)
                // 高度叠加
                layoutHeight += lineHeight
                // 重置行宽高为第一个 View 的宽高
                lineWidth = childWidth
                lineHeight = childHeight
                childList.add(
                    ChildPosition(
                        paddingLeft + lp.leftMargin,
                        paddingTop + layoutHeight + lp.topMargin,
                        paddingLeft + childWidth + lp.rightMargin,
                        paddingTop + layoutHeight + childHeight - lp.bottomMargin
                    )
                )
            } else { //不换行
                childList.add(
                    ChildPosition(
                        paddingLeft + lineWidth + lp.leftMargin,
                        paddingTop + layoutHeight + lp.topMargin,
                        paddingLeft + lineWidth + childWidth - lp.rightMargin,
                        paddingTop + layoutHeight + childHeight - lp.bottomMargin
                    )
                )
                // 叠加子 View 宽度得到新宽度
                lineWidth += childWidth
                lineHeight = Math.max(lineHeight, childHeight)
            }

            if (i == childCount - 1) { // 最后一个子 View
                layoutWidth = Math.max(layoutWidth, lineWidth)
                layoutHeight += lineHeight

            }
        }

        setMeasuredDimension(
            if (widthMode == MeasureSpec.EXACTLY) widthSize else width + paddingLeft + paddingRight,
            if (heightMode == MeasureSpec.EXACTLY) heightSize else height + paddingTop + paddingBottom
        )

    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        childList.forEachIndexed { index, it ->
            getChildAt(index).layout(it.left, it.top, it.right, it.bottom)
        }
    }
}

自定义组合控件

最常见应用场景就是自定义标题栏了,那就以此为例。

自定义属性

    <declare-styleable name="MyToolbar">
        <attr name="left_button_visible" format="boolean" />
        <attr name="right_button_visible" format="boolean" />
        <attr name="title_text" format="string" />
        <attr name="right_button_text" format="string" />
        <attr name="left_button_text" format="string" />
    </declare-styleable>

标题栏的布局如下

<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <Button
        android:id="@+id/toolbar_left"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentStart="true"
        android:layout_centerVertical="true"
        android:layout_marginStart="7dp"
        android:minWidth="45dp"
        android:minHeight="45dp"
        android:textSize="14sp" />

    <TextView
        android:id="@+id/toolbar_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:singleLine="true"
        android:textSize="17sp" />

    <Button
        android:id="@+id/toolbar_right"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_centerVertical="true"
        android:layout_marginEnd="7dp"
        android:minWidth="45dp"
        android:minHeight="45dp"
        android:textSize="14sp" />

</merge>

自定义标题栏

class MyToolbar(context: Context?, attrs: AttributeSet?) : RelativeLayout(context, attrs) {

    private var leftBtn: Button? = null
    private var rightBtn: Button? = null
    private var title: TextView? = null

    init {
        inflate(context, R.layout.toolbar, this)

        leftBtn = findViewById(R.id.toolbar_left)
        rightBtn = findViewById(R.id.toolbar_right)
        title = findViewById(R.id.toolbar_title)

        val attributes = context?.obtainStyledAttributes(attrs, R.styleable.MyToolbar)
        attributes?.let {
            //设置左边按钮是否显示
            val leftVisible = it.getBoolean(R.styleable.MyToolbar_left_button_visible, true)
            leftBtn?.visibility = if (leftVisible) View.VISIBLE else View.GONE
            //设置左边按钮的文字
            val leftText = it.getString(R.styleable.MyToolbar_left_button_text)
            if (!TextUtils.isEmpty(leftText)) {
                leftBtn?.text = leftText
            }

            //设置标题
            val titleText = it.getString(R.styleable.MyToolbar_title_text)
            if (!TextUtils.isEmpty(titleText)) {
                title?.text = titleText
            }

            //设置右边按钮是否显示
            val rightVisible = it.getBoolean(R.styleable.MyToolbar_right_button_visible, true)
            rightBtn?.visibility = if (rightVisible) View.VISIBLE else View.GONE
            //设置右边按钮文字
            val rightText = it.getString(R.styleable.MyToolbar_right_button_text)
            if (!TextUtils.isEmpty(rightText)) {
                rightBtn?.text = rightText
            }
        }
    }

}

使用

    <com.xzj.normalapp.MyToolbar
        android:layout_width="match_parent"
        android:layout_height="45dp"
        app:left_button_text="left"
        app:left_button_visible="true"
        app:right_button_text="right"
        app:right_button_visible="true"
        app:title_text="标题" />