本文面向自定义view
新手,但是希望你最好有一定的理论知识,或基础概念,有的地方可能会一笔带过并不会细讲,细讲篇幅就太长了。
本文仿写自鸿洋的自定义View (一),尽管过去了将近快7年之久,我觉得依然有学习价值。
简单介绍一下自定义View
分类:
layout
,比如LinearLayout,然后通过LayoutInflater
引入布局,然后处理相关事件,这种方式的好处在于,不需要过度关注view内部的绘制机制,而且扩展性也很强。AppCompatTextView
view
或viewgroup
,这种相对要复杂一些,因为我们要自己控制绘制流程,但是相对的,也有更大的想象空间。先分析一下上图中的效果:
比较简单,老手稍微想一下就已经有思路了:
onDraw
计算坐标绘制onMeasure
测量宽高先分析效果图,然后构思,随后不断的调整优化。
这一步也不一定非要写在前面,可能有些人觉得不一定就能事先知道会用到哪些属性,由于例子比较简单,暂且放在前面吧,看个人习惯。
res/values/
下建立一个attrs.xml
文件 , 在里面定义我们的属性
和声明我们的整个样式
<?xml version&#61;"1.0" encoding&#61;"utf-8"?>
<resources><attr name&#61;"randomText" format&#61;"string"/><attr name&#61;"randomTextColor" format&#61;"color"/><attr name&#61;"randomTextSize" format&#61;"dimension"/><declare-styleable name&#61;"RandomTextView" ><attr name&#61;"randomText"/><attr name&#61;"randomTextColor"/><attr name&#61;"randomTextSize"/></declare-styleable></resources>
format
是值该属性的取值类型:
一共有&#xff1a;string&#xff0c;color&#xff0c;demension&#xff0c;integer&#xff0c;enum&#xff0c;reference&#xff0c;float&#xff0c;boolean&#xff0c;fraction&#xff0c;flag
xml
布局中的引用&#xff1a; <com.yechaoa.customviews.randomtext.RandomTextViewandroid:layout_width&#61;"wrap_content"android:layout_height&#61;"wrap_content"android:padding&#61;"20dp"app:randomText&#61;"1234"app:randomTextColor&#61;"&#64;color/colorAccent"app:randomTextSize&#61;"50sp" />
注意引入命名空间
&#xff1a;
xmlns:app&#61;"http://schemas.android.com/apk/res-auto"
新建一个RandomTextView
类&#xff0c;继承View
&#xff0c;并添加3
个构造方法
class RandomTextView : View {//文本private var mRandomText: String//文本颜色private var mRandomTextColor: Int &#61; 0//文本字体大小private var mRandomTextSize: Int &#61; 0private var paint &#61; Paint()private var bounds &#61; Rect()//调用两个参数的构造constructor(context: Context) : this(context, null)//xml默认调用两个参数的构造&#xff0c;再调用三个参数的构造&#xff0c;在三个参数构造里获取自定义属性constructor(context: Context, attributeSet: AttributeSet?) : this(context, attributeSet, 0)constructor(context: Context, attributeSet: AttributeSet?, defStyle: Int) : super(context, attributeSet, defStyle) {...}...
}
这里要注意的是&#xff0c;所有的构造方法&#xff0c;都指向的是第三个构造方法&#xff0c;前两个构造的继承是this
&#xff0c;而不是super
。
第一个构造比如我们可以是new创建的&#xff0c;第二个是xml中默认调用的&#xff0c;我们在第三个构造中去获取自定义属性。
constructor(context: Context, attributeSet: AttributeSet?, defStyle: Int) : super(context, attributeSet, defStyle) {//获取自定义属性val typedArray &#61; context.theme.obtainStyledAttributes(attributeSet,R.styleable.RandomTextView,defStyle,0)mRandomText &#61; typedArray.getString(R.styleable.RandomTextView_randomText).toString()mRandomTextColor &#61; typedArray.getColor(R.styleable.RandomTextView_randomTextColor, Color.BLACK)//默认黑色mRandomTextSize &#61; typedArray.getDimensionPixelSize(R.styleable.RandomTextView_randomTextSize,TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, 16F, resources.displayMetrics ).toInt())//获取完回收typedArray.recycle()paint.textSize &#61; mRandomTextSize.toFloat()//返回文本边界&#xff0c;即包含文本的最小矩形&#xff0c;没有所谓“留白”&#xff0c;返回比measureText()更精确的text宽高&#xff0c;数据保存在bounds里paint.getTextBounds(mRandomText, 0, mRandomText.length, bounds)}
通过obtainStyledAttributes
获取自定义属性&#xff0c;返回一个TypedArray
&#xff0c;这里用到了我们在attrs.xml
文件中声明的样式&#xff08;R.styleable.RandomTextView&#xff09;&#xff0c;返回的TypedArray即包含了这里面的属性。
拿到自定义view属性集合&#xff0c;然后赋值&#xff0c;赋值之后就可以用paint
去画了。
然后用到了paint的getTextBounds
方法&#xff1a;
paint.getTextBounds(mRandomText, 0, mRandomText.length, bounds)
简单理解就是&#xff0c;把文字放在一个矩形里&#xff0c;通过矩形的宽高即可知道文字的宽高&#xff0c;所以宽高会保存在bounds
里&#xff0c;bounds是一个矩形Rect
&#xff0c;为什么要这么做呢&#xff0c;因为后面我们要计算文字居中的时候会用到。
ok&#xff0c;接下来开始画布局。
&#64;SuppressLint("DrawAllocation")override fun onDraw(canvas: Canvas?) {super.onDraw(canvas)/*** 自定义View时&#xff0c;需要我们自己在onDraw中处理padding&#xff0c;否则是不生效的* 自定义ViewGroup时&#xff0c;子view的padding放在onMeasure中处理*//*** 矩形背景*/paint.color &#61; Color.YELLOW//计算坐标&#xff0c;因为原点是在文字的左下角&#xff0c;左边要是延伸出去就还要往左边去&#xff0c;所以是减&#xff0c;右边和下边是正&#xff0c;所以是加canvas?.drawRect((0 - paddingLeft).toFloat(),(0 - paddingTop).toFloat(),(measuredWidth &#43; paddingRight).toFloat(),(measuredHeight &#43; paddingBottom).toFloat(),paint)/*** 文本*/paint.color &#61; mRandomTextColor//注意这里的坐标xy不是左上角&#xff0c;而是左下角&#xff0c;所以高度是相加的&#xff0c;在自定义view中&#xff0c;坐标轴右下为正//getWidth 等于 measuredWidthcanvas?.drawText(mRandomText,(width / 2 - bounds.width() / 2).toFloat(),(height / 2 &#43; bounds.height() / 2).toFloat(),paint)}
上面的代码就是在onDraw
里面显示绘制了一个YELLOW颜色的矩形背景&#xff0c;然后绘制了一个自定义属性颜色的居中的文本。
这里要注意我们计算位置时的坐标
&#xff0c;在自定义view中&#xff0c;原点是view的左上角
&#xff0c;而在数学坐标系中&#xff0c;原点&#xff08;0,0&#xff09;是在中间
的&#xff0c;二者是有区别的。
其次&#xff0c;假如xml布局中有padding
&#xff0c;或者预判会使用到padding&#xff0c;在重写onDraw
的时候也要把padding的数据加上&#xff0c;否则padding是不生效的。如果是继承ViewGroup
时&#xff0c;子view
的padding放在onMeasure
中处理。
来看此时的效果&#xff1a;
此时是不是有疑惑&#xff0c;xml里面的宽高明明是wrap_content
&#xff0c;为什么会充满父布局呢&#xff1f;
这就涉及到onMeasure
的知识点了&#xff0c;往下看。
我们在xml设置view
宽高有3种方式&#xff1a;
onMeasure
中MeasureSpec
的 mode
也有3种模式&#xff1a;
由于我们xml用的是wrap_content
&#xff0c;也就是对应AT_MOST
&#xff0c;所以效果就是会占满父布局中的可用空间
&#xff0c;而父布局是填充屏幕&#xff0c;所以我们自定义的view也会占满全屏。
而我们实际想要的效果是view包裹自己&#xff0c;而不是铺满全屏&#xff0c;所以我们需要在onMeasure
中进行处理
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {super.onMeasure(widthMeasureSpec, heightMeasureSpec)/*** EXACTLY&#xff1a;一般是设置了明确的值或者是MATCH_PARENT* AT_MOST&#xff1a;表示子布局限制在一个最大值内&#xff0c;一般为WARP_CONTENT* UNSPECIFIED&#xff1a;表示子布局想要多大就多大&#xff0c;很少使用*/val widthMode &#61; MeasureSpec.getMode(widthMeasureSpec)val widthSize &#61; MeasureSpec.getSize(widthMeasureSpec)val heightMode &#61; MeasureSpec.getMode(heightMeasureSpec)val heightSize &#61; MeasureSpec.getSize(heightMeasureSpec)var width &#61; 0var height &#61; 0//如果指定了宽度&#xff0c;或不限制宽度&#xff0c;用可用宽度即可&#xff0c;如果是WARP_CONTENT&#xff0c;则用文本宽度&#xff0c;再加上左右paddingwhen (widthMode) {MeasureSpec.UNSPECIFIED,MeasureSpec.EXACTLY -> {width &#61; widthSize &#43; paddingLeft &#43; paddingRight}MeasureSpec.AT_MOST -> {width &#61; bounds.width() &#43; paddingLeft &#43; paddingRight}}//如果指定了高度&#xff0c;或不限制高度&#xff0c;用可用高度即可&#xff0c;如果是WARP_CONTENT&#xff0c;则用文本高度&#xff0c;再加上上下paddingwhen (heightMode) {MeasureSpec.UNSPECIFIED,MeasureSpec.EXACTLY -> {height &#61; heightSize &#43; paddingTop &#43; paddingBottom}MeasureSpec.AT_MOST -> {height &#61; bounds.height() &#43; paddingTop &#43; paddingBottom}}//保存测量的宽高setMeasuredDimension(width, height)}
上面的代码呢&#xff0c;主要做了两件事&#xff1a;
最后别忘记调用setMeasuredDimension
保存新测量的宽高&#xff0c;否则没用哦。
此时再看效果就是效果图中的样子了。
ok&#xff0c;到这&#xff0c;view已经绘制完成了&#xff0c;但是还没有事件&#xff0c;我们在构造中加一个点击事件
constructor(context: Context, attributeSet: AttributeSet?, defStyle: Int) : super(context, attributeSet, defStyle) {.../*** 添加点击事件*/this.setOnClickListener {mRandomText &#61; randomText()//更新postInvalidate()}}
randomText
方法&#xff1a;
/*** 根据文本长度 随意数字*/private fun randomText(): String {val list &#61; mutableListOf<Int>()for (index in mRandomText.indices) {list.add(Random.nextInt(10))}val stringBuffer &#61; StringBuffer()for (i in list) {stringBuffer.append("" &#43; i)}return stringBuffer.toString()}
触发事件之后&#xff0c;文字更新&#xff0c;然后view重绘更新
页面即可。
关于数据获取&#xff0c;也就是变化后的数字&#xff0c;可以写个onTextChanged
接口&#xff0c;也可以写个开放方法
获取。
其实看效果的话&#xff0c;还不如TextView
来的简单&#xff0c;而且TextView也可以轻松的实现效果图中的效果。
所以本文的重点并不是实现效果&#xff0c;而是学习理解
自定义View以及其绘制流程
。
理论看的再多也需要实践才行&#xff0c;不如跟着敲两遍&#xff0c;理解消化一下。
注释
还是非常详细的&#xff0c;甚至有点啰嗦。。
如果对你有一点帮助&#xff0c;点个赞呗 ^ _ ^
https://github.com/yechaoa/CustomViews