从事 Android 开发以来,很少有过自定义 View 的相关开发需求,大部分 UI 都是可以集成某些官方组件,在组件的基础上完成能够大大缩短开发时间。但今天我要讲的是:如何使用 Android 开发一个Compose、Xml都可以调用的组件?接下来请跟随我的脚步一起去学习 View 的自定义组件开发吧。
自定义 View 之前,需要先了解 View 的坐标,知道哪里是起点,哪里是终点,才能更好的展开工作。
经历过九年义务教育的朋友们,相信大家都见过下面的这幅图
在 Android 系统上,也是用的 平面直角坐标系 来确定 View 的方向、大小,只不过它是“倒”过来的平面直角坐标系,如下图
看懂了吧?在 Android 系统上,直角坐标系的原点就是屏幕的左上角,往右是 x 轴,往下是 y 轴,整个 Android 的屏幕就是处于 平面直角坐标系 的第四象限上,其坐标的单位则使用的是像素(px) 来表示。如果你的手机是 1080*1920 像素,则意味着,以原点为起点,至屏幕的右侧,共有 1080 像素 (px) ,以原点为起点,至屏幕的底部,共有 1920 个像素(px)。
在 Android 中,自定义 View 一般可分为两种方式:继承 ViewGroup 或 View 实现自定义。
本文只讲解通过继承 View 来实现自定义 View,通过继承 ViewGroup 实现自定义 View 的文章可参考往期文章【Android】实现自定义标题栏
View 完成自定义的过程需要经历:初始化 → onMeasure → onSizeChanged → onLayout → onDraw 五个阶段。
View 的初始化方式可通过四种构造函数进行初始化,如下:
constructor(context: Context?) : this(context, null)
constructor(context: Context?, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : this(context, attrs, defStyleAttr, 0)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {}
构造函数 | 使用场景 |
---|---|
public View(Context context) | 一般在 Activity、Fragment 中使用(本文使用该构造函数): View view = new View(context); |
public View(Context context, AttributeSet attrs) | 当从XML文件构造视图,提供XML文件中指定的属性时,会调用此函数(本文使用该构造函数): < View android:layout_width=“wrap_content” android:layout_height=“wrap_content”/> |
public View(Context context, AttributeSet attrs, int defStyleAttr) | 从XML执行膨胀,并从主题属性应用特定于类的基本样式。View的这个构造函数允许子类在膨胀时使用自己的基本样式。 |
public View(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) | 从XML执行膨胀,并从主题属性或样式资源应用特定于类的基本样式。View的这个构造函数允许子类在膨胀时使用自己的基本样式。(本文使用该构造函数) |
注意:重写构造方法时,拥有四个参数的那个构造函数必须使用
super
用于访问父类的构造方法,另外三个构造方法,则需要使用this
指引下一个构造方法。这样,当调用第一、二、三个构造方法时,就会执行第四个构造方法,使用该方式才能使UI渲染上,否则会出现实例化 View 无效的情况出现。具体参考上方的四个构造方法。
如果自定义的 View 在 XML 布局上用的到,自定义属性这一步则少不了,届时需要在 res/values/arrts.xml
文件夹添加 XML 的属性。若没有 arrts.xml
文件则需手动创建。创建完成后在其中编写的代码如下:
<resources>
<declare-styleable name="StepView">
<attr name="type">
<enum name="start" value="0" />
<enum name="middle" value="1" />
<enum name="stop" value="2" />
attr>
<attr name="text" format="string" localization="suggested" />
<attr name="textSize" format="dimension" />
<attr name="style" format="integer" >
<enum name="selected" value="10"/>
<enum name="not_selected" value="11"/>
attr>
declare-styleable>
resources>
编写了名为StepView
的属性样式,需要在自定义 View 类里的构造函数中使用 Context.obtainStyledAttributes
函数引用,通过循环遍历的方式找到属性赋值给对应的值,这时,就完成了 XML 布局属性的自定义。
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int, ) : super(context, attrs, defStyleAttr, defStyleRes) {
//获取定义的一些属性
val styleAttrs = context!!.obtainStyledAttributes(attrs, R.styleable.StepView, defStyleAttr, 0)
//数一数有多少个属性呢
val indexCount = styleAttrs.indexCount
// 循环遍历的方式,找到我们所定义的一些属性
for (i in 0..indexCount) {
//根据索引值给java代码中的成员变量赋值
when (val index = styleAttrs.getIndex(i)) {
R.styleable.StepView_text -> text = styleAttrs.getString(index).toString()
R.styleable.StepView_textSize -> textSize = styleAttrs.getDimension(index, 2f)
R.styleable.StepView_type -> type = styleAttrs.getInt(index, type)
R.styleable.StepView_style -> style = styleAttrs.getInteger(index, STYLE_NOT_SELECTED)
}
}
//资源文件中的属性回收
styleAttrs.recycle()
}
为什么要测量 View 大小?
View 的大小不仅由自身大小所决定,同时也受到父控件的影响,为了我们的控件能更好的适应各种情况,一般需要自己进行测量。
测量 View 大小使用的是 View 的 onMeasure(int widthMeasureSpec, int heightMeasureSpec)
方法进行测量,为了更好的适配各种分辨率,自定义 View 的过程中,必须由重写该方法。
重写方法,还需要使用setMeasuredDimension(int measuredWidth, int measuredHeight)
方法使测量好的宽高产生效果,如下:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 获取宽高的测量模式
val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
// 获取宽高的测量大小
val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
val heithtSpecSize = MeasureSpec.getSize(heightMeasureSpec)
if (layoutParams.width == ViewGroup.LayoutParams.WRAP_CONTENT && layoutParams.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
val fl = mWidthTotal.toFloat() / 1.5f
setMeasuredDimension(fl.toInt() + indentAndBulge.toInt(), mHeight.toInt())
} else if (layoutParams.width == ViewGroup.LayoutParams.WRAP_CONTENT) {
// 宽 / 高任意一个布局参数为= wrap_content时,都设置默认值
val fl = mWidthTotal.toFloat() / 1.5f
setMeasuredDimension(fl.toInt() + indentAndBulge.toInt(), heithtSpecSize)
} else if (layoutParams.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
// 宽 / 高任意一个布局参数为= wrap_content时,都设置默认值
setMeasuredDimension(widthSpecSize, mHeight.toInt())
}
}
注意:若是没有重写
onMeasure
方法并完成测量,在给该组件定义宽高为wrap_content
时,组件的宽、高度会默认跟随父类。具体原因请查看Android 自定义View:为什么你设置的wrap_content不起作用?
在测量完 View 并使用 setMeasuredDimension(int measuredWidth, int measuredHeight)
函数之后,View 的大小基本上已经确定,那为什么还需要再次确定 View 的大小呢?
这是因为 View 的大小不仅由 View 本身控制,而且受父控件的影响,所以我们在确定 View 大小的时候最好使用系统提供的 onSizeChanged(int w, int h, int oldw, int oldh)
回调函数。
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
Log.e("onSizeChanged", "View的宽度:$w,高度$h")
}
onSizeChanged
方法的四个参数分别为:
此函数相对简单,我们只需要关注其宽度(w)、高度(h) 即可,这两个参数就是 View 的最终大小。该函数只会在 View 的大小发生改变时自动触发,例如:初始化View、界面横竖屏的切换等。
确定子布局的函数是 onLayout(boolean changed, int left, int top, int right, int bottom)
,它用于确定子 View 在父 View 的位置。
当此视图应为其每个子级分配大小和位置时,从布局调用。具有子级的派生类应重写此方法,并在其每个子级上调用布局。
/**
* 如果自定义的 View 有子组件时,必须重写该方法,用以确定子组件在 View 中的位置.
* 如果有需要自定义有子 View 的组件时,应该继承 ViewGroup 而不是 View,具体看情况自己分析
*/
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
Log.e("onLayout","$left,$top,$right,$bottom")
}
扯了这么多,终于来到自定义 View 的最后一步了!
在上文我们说到 Android 的 平面直角坐标系 以及 Android 屏幕在 平面直角坐标系 中的位置是位于第四象限。那接下来我们如何在 Canvas 画布上绘制出自己想要的样式呢?
在 Android
中,提供了 Canvas
作为画画的载体,所绘制的东西最终呈现在 Canvas
上,因此也可以理解为 Canvas
是一张纸,Paint
则是五颜六色的笔。Canvas
作为画布(白纸),提供了多个在画布上画的函数给我们调用:
Canvas Function | Describe |
---|---|
drawARGB | 使用 srcover porterduff 模式,用指定的 ARGB 颜色填充整个画布的位图(仅限于当前剪辑)。 |
drawArc | 绘制指定的圆弧,该圆弧将缩放以适合指定的椭圆形。 |
drawBitmap | 使用指定的矩阵绘制位图。 |
drawBitmapMesh | 通过网格绘制位图,其中网格顶点均匀分布在位图上。 |
drawCircle | 使用指定的颜料绘制指定的圆。 |
drawColor | 使用指定的颜色和混合模式填充整个画布的位图(仅限于当前剪辑)。 |
drawDoubleRoundRect | 使用指定的 paint 绘制双圆角矩形。 |
drawGlyphs | 使用指定字体绘制字形数组。 |
drawLine | 使用指定的绘图绘制具有指定起点和终点 x,y 坐标的线段。 |
drawMesh | 将网格对象绘制到屏幕上。 |
drawOval | 使用指定的颜料绘制指定的椭圆形。椭圆形将根据绘画中的样式进行填充或加框。 |
drawPaint | 用指定的绘画填充整个画布的位图(仅限于当前剪辑)。 |
drawPatch | 将指定的位图绘制为 N 面片(最常见的是 9 面片)。 |
drawPath | 使用指定的油漆绘制指定的路径。路径将根据绘画中的样式进行填充或加框。 |
drawPicture | 绘制图片,拉伸以适合 dst 矩形。 |
drawPoint | 用于绘制单个点的 drawPoints() 的帮助程序。 |
drawRGB | 使用 srcover porterduff 模式,用指定的 RGB 颜色填充整个画布的位图(仅限于当前剪辑)。 |
drawRect | 使用指定的绘制绘制指定的矩形,矩形将根据绘画中的样式进行填充或加框。 |
drawRenderNode | 绘制给定的 RenderNode。 |
drawRoundRect | 使用指定的油漆绘制指定的圆角矩形,圆角矩形将根据绘画中的样式进行填充或加框。 |
drawText | 在指定的 Paint 中绘制指定范围的文本,由开始/结束指定,其原点位于 (x,y)。 |
drawTextOnPath | 使用指定的绘画,沿着指定的路径绘制文本,原点位于 (x,y)。 |
drawTextRun | 绘制一系列文本,全部在一个方向上,并带有用于复杂文本形状的可选上下文。 |
drawVertices | 绘制顶点数组,解释为三角形(基于模式)。 |
此处展示的函数并非全部,详情请参考Canvas
本文自定义 View 绘制的 UI 只需要使用到 drawText
、drawPath
两个 Canvas 函数进行绘制。drawPath
在这里用来绘制背景,drawText
绘制文字。
接下来绘制的图形:
使用 drawPath
进行绘制前,需要先使用 Path
定义好 drawPath
在 canvas
经过的几个点,这里画个图,以便参考:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 新建绘制的路径
val path = Path()
// 第一步,定原点
path.moveTo(0f, 0f)
// 第二步
path.lineTo(totalWidth, 0f)
// 第三步
val rightBulge = totalWidth + indentAndBulge
path.lineTo(rightBulge, halfHeight)
// 第四步
path.lineTo(totalWidth, mHeight)
// 第五步
path.lineTo(0f, mHeight)
// 回到原点,闭合图形
path.lineTo(0f, 0f)
path.close()
// 将图形绘制出来
canvas.drawPath(path, mPaint)
}
完成了背景的绘制,接下来绘制 Text
mPaint.color = if (style == STYLE_NOT_SELECTED) context.getColor(R.color.font_black) else context.getColor(R.color.white)
canvas.drawText(text, startPointText, mHeight / 1.5f, mPaint)
至此,完成了自定义的全部过程。
在 xml 中引用和官方的 View 组件引用没有太大的差别,如下:
<com.miyue.stepdemo.StepView
android:id="@+id/step1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
我们自定义开始时,在attrs.xml
文件里自定义了一些组件的属性,添加后如下:
<com.miyue.stepdemo.StepView
android:id="@+id/step1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:style="selected"
app:text="第一步"
app:type="start"/>
注意:在 xml 中初始化的组件,是无法使用
Debug
断点调试的,但可通过Logcat
查看日志信息。
val step = StepView(this)
step.setStyle(StepView.STYLE_SELECTED)
step.setType(StepView.TYPE_START)
step.setText("第一步")
step.setTextSize(30f)
val linearLayout = findViewById<LinearLayout>(R.id.ll_step2)
linearLayout.addView(step)
如果你的 Android 项目是 Java 项目,建议你创建一个 Kotlin 项目或者 Compose 项目。如果你的项目是 Kotlin 项目,则可以通过以下 的方式创建一个 Compose 界面:鼠标右键包名 → New → Compose → Empty Activity。
完成上述步骤即可获得一个 Compose 的 Activity,通过以下代码即可完成调用继承自 View 的组件调用。
@Composable
fun CustomView() {
AndroidView(
modifier = Modifier.fillMaxSize(), // Occupy the max size in the Compose UI tree
factory = { context ->
// Creates view
StepView(context).apply {
setText("第一步")
setTextSize(30f)
setStyle(StepView.STYLE_SELECTED)
setType(StepView.TYPE_START)
}
},
update = { view ->
// 视图已膨胀或此块中读取的状态已更新
// 如有必要,在此处添加逻辑
// 由于selectedItem在此处阅读,AndroidView将重新组合
// 每当状态发生变化时
// 撰写示例->查看通信
// 更新样式
view.setText("第二步")
}
)
}
点击前往下载代码StepDemo
总结
能找美工处理的就找美工处理,能不碰 View 自定义就不要碰 View 自定义,一旦开始自定义,意味着需要花很长的时间去处理自定义产生的各种适配问题,投入的时间用与产生的收益不成正比。
参考文档
1、【扔物线】UI-1 Drawing
2、HenCoder Android 开发进阶: 自定义 View 1-1 绘制基础
3、HenCoder Android 开发进阶: 自定义 View 1-2 Paint 详解
4、【Android Developer】在 Compose 中使用 View