这是【Android 修炼手册】系列第 9 篇文章,若是尚未看过前面系列文章,欢迎点击 这里 查看~php
自定义 View 内容整体来讲仍是比较简单,更多的是要知足具体的需求,因此本文内容并不太难,看起来比较愉悦。html
在学习如何自定义 View 以前,须要先了解一下 Android 系统里,View 的绘制流程,熟悉了各个流程,咱们在自定义过程当中也就驾轻就熟了。java
Android View 的绘制流程是从 ViewRootImpl 的 performTraversals 开始的,会经历下面的过程。 android
因此一个 view 的绘制主要有三个流程,measure 肯定宽度和高度,layout 肯定摆放的位置,draw 绘制 view 内容。 下面就依次看看这三个步骤。git
onMeasure 是用来测量 View 宽度和高度的,通常状况下能够理解为在 onMeasure 之后 View 的宽度和高度就肯定了,而后咱们就可使用 getMeasuredWidth 和 getMeasuredHeight 来获取 View 的宽高了。 咱们先看看 View 默认的 onMeasure 里作了什么事情。github
class View {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) {
// ...
setMeasuredDimensionRaw(measuredWidth, measuredHeight);
}
private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) {
mMeasuredWidth = measuredWidth;
mMeasuredHeight = measuredHeight;
mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET;
}
}
复制代码
能够看到里面是调用了 setMeasuredDimension,这个方法是设置 View 的测量宽高的,其实内部就是给 mMeasuredWidth 和 mMeasuredHeight 设置了值。以后 getMeasuredWidth 和 getMeasuredHeight 就是获取的这两个值。canvas
这里说一下 getMeasuredWidth/getMeasuredHeight 和 getWidth/getHeight 的区别,getMeasuredWidth/getMeasuredHeight 是获取测量宽度和高度,也就是 onMeasure 之后肯定的值,至关因而通知了系统个人 View 应该是这么大,可是 View 最终的宽度和高度是在 layout 之后才肯定的,也就是 getWidth 和 getHeight 的值。而 getWidth 的值是 right - left,getHeight 也相似。
通常状况下 getMeasuredWidth/getMeasuredHeight 和 getWidth/getHeight 的值是相同的,可是要记住,这两个值是能够不一样的。咱们能够写个小 demo 看看。api
class MyView constructor(context: Context?, attributes: AttributeSet?, defaultAttrStyle: Int) : TextView(context, attributes, defaultAttrStyle) {
constructor(context: Context?, attributes: AttributeSet?) : this(context, attributes, 0)
constructor(context: Context?) : this(context, null)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(100, 100)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
setFrame(0, 0, 100, 20)
super.onLayout(changed, 0, 0, 100, 20)
}
}
复制代码
在上面的 MyView 中,onMeasure 里经过 setMeasuredDimension 设置了宽高是 100 * 100,可是在 onLayout 中咱们设置了 setFrame 为 (0, 0, 100, 20),经过计算,高度是 bottom - top。因此最后展现的高度就是 20。bash
在 onMeasure 函数中,有两个参数,widthMeasureSpec 和 heightMeasureSpec,这个是传入的父 View 能给予的最大宽高,和测量模式。 widthMeasureSpec 分为 mode 和 size,经过 MeasureSpec.getMode(widthMeasureSpec) 能够得到模式,MeasureSpec.getSize(widthMeasureSpec) 能够获取宽度。
其中 mode 有三种类型,UNSPECIFIED,AT_MOST,EXACTLY。
UNSPECIFIED 是不限制 View 的尺寸,根据实际状况,想多大能够设置多大。
AT_MOST 是最大就是父 View 的宽度/高度,也就是咱们在 xml 中设置。 wrap_content 的效果
EXACTLY 是肯定的 View 尺寸,咱们在 xml 中设置 一个固定的值或者父 View 是一个固定的值且子 View 设置了 match_parent。app
因此在 onMeasure 中,咱们要根据上面的状况,正确的处理对应 mode 下的尺寸。
这里咱们额外看一下 View 默认的 onMeasure 方法对各类 mode 的处理。
class View {
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
}
复制代码
默认对 AT_MOST 和 EXACTLY 的处理方式是同样的,因此咱们对一个 View 设置 wrap_content 和 match_parent 的效果实际上是同样的。
onLayout 是对 View 的位置进行摆放。在 layout 中经过 setFrame(left, top, right, bottom) 设置 View 的上下左右位置。这一步只要处理好 View 的位置便可。若是是 ViewGroup 及其子类,还要处理子 View 的位置。
onDraw 过程也比较简单,就是绘制 View 的内容。分为几个步骤(基于 Sdk 28 源码):
drawBackground 绘制背景
onDraw 绘制自身
dispatchDraw 绘制子 View
onDrawForeground 绘制前景
在绘制过程当中,有两个类 Canvas 和 Paint。 须要特别注意一下。这两个类是绘制过程当中经常使用的。
Canvas 中经常使用的一些 api 以下:
drawBitmap 绘制图片
drawCircle 绘制圆形
drawLine 绘制直线
drawPoint 绘制点
drawText 绘制文字
具体的 api 在 developer.android.com/reference/a… 这里查看。其实在开发中要养成查看官方文档的习惯,毕竟官方的才是权威的。
Paint 中一些经常使用的 api 以下:
setColor 设置颜色 setAntiAlias 抗锯齿
setStyle 设置线条或者填充风格
setStrokeWidth 设置线条宽度
setStrokeCap 设置线头形状
setStrokeJoin 设置线条拐角的形状
具体的 api 在 developer.android.com/reference/a… 在这里查看。
关于触摸事件以及滑动冲突,也是自定义 View 常常遇到的问题。在此以前,先要了解一下 View 的事件分发机制。
在 Android 系统中,触摸事件是以 MotionEvent 传递给 View 的。在其中定义了一些经常使用的操做,ACTION_DOWN,ACTION_UP,ACTION_MOVE,ACTION_CANCEL 等等。分别表明了按下,抬起,以及中间的移动,事件取消等操做。
而咱们处理事件的本质,就是对这些操做进行判断,在正确的时机去作正确的事情。而一些列事件操做就构成一次事件操做流,也就是一次用户完整的操做。
触摸事件的操做流都是以 ACTION_DOWN 为起始。以 ACTION_UP 或者 ACTION_CANCEL 结束。
ACTION_DOWN -> ACTION_UP
ACTION_DOWN -> ACTION_MOVE -> ACTION_MOVE -> ... -> ACTION_UP
ACTION_DOWN -> ACTION_MOVE -> ACTION_MOVE -> ... -> ACTION_CANCEL
复制代码
这里须要注意的就是事件和事件流的区别。
在 View 中,处理触摸事件的方法是 onTouchEvent(MotionEvent event),传入的参数就是操做,咱们要处理触摸事件的时候,就要重写 onTouchEvent 方法,在其中作自定义的处理。
这里值得注意的是,onTouchEvent 是有一个 boolean 类型的返回值的,这个返回值也很重要。返回值表明了本次【次事件流】是否要执行处理,若是返回 true,那么就表示本次事件流都由本身全权负责,后续的【事件】就不出再传递给其余 View 了。
由于表明的是整个事件流的处理,因此这个返回值只在 ACTION_DOWN 的时候有效,若是 ACTINO_DOWN 的时候返回 false,那么后面就不会收到其余的事件了。
若是 View 设置了 OnClickListener,那么在默认的 View 里的 onTouchEvent 中会在 ACTION_UP 的时候调用其 onClick。
上面说了 View 中触摸事件的处理,若是是在 ViewGroup 中,在 onTouchEvent 以前还会有一个校验 onInterceptTouchEvent,意思是是否拦截触摸事件。
若是 onInterceptTouchEvent 返回 true,那么说明须要拦截这次事件,就不会再分发事件给子 View 了。增长这个拦截之后,父 View 能够把一些事件下方给子 View,在合适的还能进行拦截,把事件收回来作本身的处理。典型的应用就是列表中 item 的点击和列表的滑动。
这里强调一点,onInterceptTouchEvent 是事件流中的每一个【事件】到来时都会调用,而 onTouchEvent 若是在 ACTION_DOWN 之后返回 false,那么【事件流】后续的事件就不会再收到了。
从上面的分析咱们知道了,ViewGroup 中若是遇到本身须要处理的事件,就会经过 onIntercepTouchEvent 拦截这个事件,这样这个事件就不会传递到子 View 里了。可是事情总有例外,若是某些事件子 View 想要本身来处理,不须要父 View 来插手,那么就能够调用 requestDisallowInterceptTouchEvent 告诉父 View 后面的事件不须要拦截。
这个只在一次【事件流】中有效,由于在父 View 收到 ACTION_DOWN 之后,会重置此标识位。
还有一个点是 onTouchListener,对于一个 View,能够设置 OnTouchListener,在其 onTouch 方法中也能够处理触摸事件。若是 onTouch 中返回了 true,就表明消耗了此次事件,就不会再去调用 onTouchEvent 了。
上面说的几个 View 以及 ViewGroup 的事件处理方法,都是在 dispatchTouchEvent 中进行分发的。整个事件分发机制能够用下面的伪代码来表示。
public boolean dispatchTouchEvent() {
boolean res = false;
if (onInterceptTouchEvent()) { // View 不会调用这个,直接执行下面的 touchlistener 判断
if (mOnTouchListener && mOnTouchListener.onTouch()) { // 处理 OnTouchListener
return true;
}
// 没有设置 OnTouchListener 或者其 onTouch 返回 false,就调用 onTouchEvent
res = onTouchEvent(); // -> clicklistener.onClick()
} else {
// 本次事件不须要拦截,就分发给子 View 去处理
for (child in childrenView) {
res = child.dispatchTouchEvent();
}
}
return res;
}
复制代码
了解了上面自定义 View 的一些基础知识,咱们看看自定义 View 经常使用的几种方法。
这种方式通常是已有的控件功能没法知足需求,须要在已有控件上进行扩展。一般只要实现咱们须要扩展的功能便可,比较简单。
这种方式通常是对已有的一些控件的封装,使用起来比较方便。
这种方式通常是已有控件没法知足需求,因此须要咱们本身来绘制 View
在自定义 View 的时候,咱们常常须要加一些自定义的属性,方便在 xml 中进行配置,相似 TextView 的 text。下面就看看自定义 View 属性的方法。咱们以建立一个 MyView 的 message 属性为例。
先在 res/valuse 目录下建立 attrs.xml,在其中添加自定义的属性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MyView">
<attr name="message" format="string" />
</declare-styleable>
</resources>
复制代码
<LinearLayout 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" android:orientation="vertical">
<com.zy.myview.MyView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="#ff6633" android:text="Hello World!" app:message="this is my view" />
</LinearLayout>
复制代码
在使用自定义的属性时,须要注意命名空间的问题,默认属性的命名空间是 android,咱们这里须要新增一个 xmlns:app="schemas.android.com/apk/res-aut…",使用自定义属性的时候须要用这个命名空间 app:message=""。不过命名空间这一步,通常 AndroidStudio 会自动加上。
class MyView constructor(context: Context?, attributes: AttributeSet?, defaultAttrStyle: Int) : TextView(context, attributes, defaultAttrStyle) {
constructor(context: Context?, attributes: AttributeSet?) : this(context, attributes, 0)
constructor(context: Context?) : this(context, null)
init {
// 获取 TypeArray
val typedArray = context?.obtainStyledAttributes(attributes, R.styleable.MyView)
// 获取 message 属性
val message = typedArray?.getString(R.styleable.MyView_message)
typedArray?.recycle() //注意回收
}
}
复制代码
经过上面三个步骤,咱们就把自定义属性用起来了。
经过上面的分析咱们知道了自定义 View 的关键点以及如何去自定义 View,下面就写个例子实战一下。
我这里简单写了一个相似音量条的控件,能够跟随手指滑动提升下降音量,仅作自定义控件的演示,因此里面的逻辑和 ui 可能比较丑,重点关注上面关键点的处理~
完整代码在这里查看
咱们这里先看一下如何使用的这个控件
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" tools:context=".MainActivity">
<com.zy.myview.VolumeBar android:layout_width="match_parent" android:layout_height="50dp" android:layout_marginTop="10dp" app:col_color="@color/colorPrimaryDark" app:count="20" app:tip="vol" />
</LinearLayout>
复制代码
这里咱们把 VolumeBar 做为一个总体控件来引用的,其中定义了 col_color,count,tip 三个属性,分别表示音量条的颜色,音量条的数量,提示文案。属性的定义以下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="VolumeBar">
<attr name="count" format="integer" />
<attr name="col_color" format="color" />
<attr name="tip" format="string" />
</declare-styleable>
</resources>
复制代码
而后咱们再来分析一下 VolumeBar 这个控件,因为这个控件内部还有音量条和文案,因此咱们采用了【继承特定 ViewGroup,组合各类 View】这种方式来实现控件。VolumeBar 继承自 LinearLayout,而后在内部组合了音量条控件和提示文案控件,咱们先看看关键代码。
class VolumeBar constructor(context: Context?, attributes: AttributeSet?, defaultAttrStyle: Int) : LinearLayout(context, attributes, defaultAttrStyle) {
init {
// 解析自定义属性
val typedArray = context?.obtainStyledAttributes(attributes, R.styleable.VolumeBar)
tip = typedArray?.getString(R.styleable.VolumeBar_tip) ?: ""
count = typedArray?.getInt(R.styleable.VolumeBar_count, 20) ?: 20
color = typedArray?.getColor(R.styleable.VolumeBar_col_color, context.resources.getColor(R.color.colorPrimary))
?: context!!.resources.getColor(R.color.colorPrimary)
typedArray?.recycle() //注意回收
gravity = Gravity.CENTER_VERTICAL
// 处理子 View
initVolumeView()
}
private fun initVolumeView() {
val params = LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
params.leftMargin = 10
// 添加音量条子 View
(0 until count).forEach { _ ->
val view = VolumeView(context)
addView(view, params)
viewList.add(view)
}
// 添加文案
text = TextView(context)
text.text = "$tip 0"
addView(text, params)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
// 处理触摸事件
when (event.action) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> {
handleEvent(event)
}
}
return true
}
// 触摸事件的处理逻辑,主要是在查找当前触摸事件的位置,肯定是在第几个子 View 上,而后将此子 View 以前的全部子 View 都设置成实心的
private fun handleEvent(event: MotionEvent) {
val index = getCurIndex(event.x)
// 设置子 View 为实心
}
private fun getCurIndex(x: Float): Int {
val pos = IntArray(2)
var res = -1
// 遍历子 View,肯定当前触摸事件的位置
viewList.forEachIndexed { index, view ->
view.getLocationOnScreen(pos)
if ((pos[0] + view.width) <= x) {
res = index
}
}
return res
}
}
复制代码
咱们这里主要关注自定义属性的解析,和 onTouchEvent 触摸事件的处理。其中 MotionEvent 携带了当前事件的位置,因此咱们遍历子 View,来肯定当前触摸的位置是在哪一个子 View 上,而后将其以前的 View 所有绘制成实心的。
而后再看看音量条 VolumeView 的实现。VolumeView 是采用【继承 View 重写 onDraw】方式来实现的。
class VolumeView constructor(context: Context?, attributes: AttributeSet?, defaultAttrStyle: Int) : View(context, attributes, defaultAttrStyle) {
val DEFAULT_LENGTH = 50
var color: Int = 0
var full: Boolean = false
var paint: Paint = Paint()
constructor(context: Context?, attributes: AttributeSet?) : this(context, attributes, 0)
constructor(context: Context?) : this(context, null)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val height = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(height / 5, height)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
color = if (color > 0) color else context.resources.getColor(R.color.colorPrimary)
paint.isAntiAlias = true
paint.color = color
if (full) {
paint.style = Paint.Style.FILL
} else {
paint.style = Paint.Style.STROKE
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
}
}
复制代码
这里的实现主要在演示 onMeasure 和 onDraw 的做用,咱们在 onMeasure 中设置了宽高,其中宽度是高度的五分之一,而后在 onDraw 中经过 Canvas.drawReact() 绘制了长方形的音量条。
其实这样看下来,自定义 View 也没有那么难,来本身动手试试吧~