从事 Android 开发以来,很少有过自定义 View 的相关开发需求,大部分 UI 都是可以集成某些官方组件,在组件的基础上完成能够大大缩短开发时间。但今天我要讲的是:如何使用 Android 开发一个Compose、Xml都可以调用的组件?接下来请跟随我的脚步一起去学习 View 的自定义组件开发吧。
目录
Android 屏幕坐标
自定义 View 之前,需要先了解 View 的坐标,知道哪里是起点,哪里是终点,才能更好的展开工作。
经历过九年义务教育的朋友们,相信大家都见过下面的这幅图 👇
很眼熟吧?这张图片的东西被称之为 👉 平面直角坐标系。
在 Android 系统上,也是用的 平面直角坐标系 来确定 View 的方向、大小,只不过它是“倒”过来的平面直角坐标系,如下图👇
不明白?我们再把这个图片代入到 Android 屏幕上来
看懂了吧?在 Android 系统上,直角坐标系的原点就是屏幕的左上角,往右是 x 轴,往下是 y 轴,整个 Android 的屏幕就是处于 平面直角坐标系 的第四象限上,其坐标的单位则使用的是像素(px) 来表示。如果你的手机是 1080*1920 像素,则意味着,以原点为起点,至屏幕的右侧,共有 1080 像素 (px) ,以原点为起点,至屏幕的底部,共有 1920 个像素(px)。
自定义 View 的方式
在 Android 中,自定义 View 一般可分为两种方式:继承 ViewGroup 或 View 实现自定义。
- ViewGroup
自定义 ViewGroup 一般是利用现有的组件根据特定的布局方式来组成新的组件,大多继承自 ViewGroup 或各种 Layout ,包含子 View。 - View
在没有现成的View,需要自己实现的时候,就是用自定义 View,一般继承自 View、SurfaceView 或其它的 View。
本文只讲解通过继承 View 来实现自定义 View,通过继承 ViewGroup 实现自定义 View 的文章可参考往期文章👉【Android】实现自定义标题栏
自定义 View
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 无效的情况出现。具体参考上方的四个构造方法。
自定义 XML 属性
如果自定义的 View 在 XML 布局上用的到,自定义属性这一步则少不了,届时需要在 res/values/arrts.xml
文件夹添加 XML 的属性。若没有 arrts.xml
文件则需手动创建。创建完成后在其中编写的代码如下:
<resources>
<declare-styleable name="StepView">
<attr name="type">
<!-- 添加枚举,在布局样式使用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" >
<!-- 添加枚举,在布局样式使用style属性时可直接使用以下的选项 -->
<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()
}
测量大小 onMeasure
为什么要测量 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不起作用?
确定大小 onSizeChanged
在测量完 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 – Current width of this view.
- h – Current height of this view.
- oldw – Old width of this view.
- oldh – Old height of this view.
此函数相对简单,我们只需要关注其宽度(w)、高度(h) 即可,这两个参数就是 View 的最终大小。该函数只会在 View 的大小发生改变时自动触发,例如:初始化View、界面横竖屏的切换等。
确定子布局位置 onLayout
确定子布局的函数是 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")
}
绘制 onDraw
扯了这么多,终于来到自定义 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中引用
在 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
查看日志信息。
在Activity中引用
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)
在Compose中引用
如果你的 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