本篇文章记录下Android
仿iOS
控件Switch
开关自定义过程。此控件实现的难度较小,但是在绘制文字过程中遇到一些问题,比如如何将文字摆放在正确的位置,文字绘制和位置的处理用到以下的知识点:
Canvas
的绘制文字drawText
Paint
获取文字边界getTextBounds()
Paint
的测量文字宽度measureText()
FontMetrics
(文字位置摆放关键)1、控件效果
绘制分析:
从图上可以看出,主要有以下几个元素以及事件:
drawRoundRect
drawCircle
drawText
onTouch
事件,处理开关状态2、实现步骤
控件的背景有两种颜色、有开和关两种状态,且控件的开关状态可以设置,圆形就使用白色填充,那么背景色、开关文字、开关状态使用自定义属性来定义增加扩展性。
2-1、声明自定义属性
res/values
下新建attrs.xml
文件,声明styleable
属性
<declare-styleable name="SwitchButtonView">
<attr name="status" format="boolean"/>
<attr name="switch_off_color" format="color"/>
<attr name="switch_on_color" format="color"/>
declare-styleable>
2-2、创建SwitchButtonView,绘制圆角矩形背景、绘制白色圆形
提前看下XML中使用方式:
<com.ho.customview.widget.SwitchButtonView
android:id="@+id/ivSwitchOn"
android:layout_width="@dimen/ppx_135"
android:layout_height="@dimen/ppx_72"
app:status="false"
app:switch_off_color="@color/switch_off"
app:switch_on_color="@color/switch_on" />
首先,需要绘制背景,背景是一个圆角的矩形,Canvas
中提供了对应的方法drawRoundRect
,只要传入所在的矩形范围、x
和y
方向的radius
半径来生成圆角,这里传同一个值rRectRadius
即可,再传入画笔对象。开和关的状态主要把画笔的颜色设置不同即可,色值可以定义在styleable
中。
下一步就是绘制白色圆形drawCircle
,圆形的半径比rRectRadius小一些,且绘制的时候根据开还是关,使小白圆距离圆角矩形有一定的距离rRRadiusMargin
即可。
上面两步就完成了基础的绘制,再通过onTouchEvent
处理点击后开和关状态的改变,重新绘制。
控件还需要对外提供获取当前的状态和设置开关状态两个方法,这里定义了setSwitchStatus
和getSwitchStatus
/**
* 仿ios开关
*/
class SwitchButtonView(context: Context, attributeSet: AttributeSet): View(context,attributeSet) {
//背景圆角矩形绘制区域
private val rect = RectF()
//控件宽
private var mWidth = 0f
//控件高
private var mHeight = 0f
//圆角矩形半径
private var rRectRadius = 0f
//圆角矩形距离白色圆距离
private var rRRadiusMargin = 8f
//开关状态
private var isSwitchOn = false
//开关文字
private var switchStatus = ""
//开状态-背景色
private var switchOnColor = context.getColor(R.color.switch_on)
//关状态-背景色
private var switchOffColor = context.getColor(R.color.switch_off)
/**
* 背景画笔
*/
private var mPaint = Paint().apply {
style = Paint.Style.FILL
color = context.getColor(R.color.switch_off)
strokeCap = Paint.Cap.BUTT
isAntiAlias = true
isDither = true
}
init {
val ta = context.obtainStyledAttributes(attributeSet,R.styleable.SwitchButtonView)
ta.apply {
switchOnColor = getColor(R.styleable.SwitchButtonView_switch_on_color,resources.getColor(R.color.switch_on))
switchOffColor = getColor(R.styleable.SwitchButtonView_switch_off_color,resources.getColor(R.color.switch_off))
isSwitchOn = getBoolean(R.styleable.SwitchButtonView_status,false)
switchStatus = if(isSwitchOn) context.getString(R.string.switch_on) else context.getString(R.string.switch_off)
recycle()
}
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//控件宽
mWidth = w.toFloat()
//控件高
mHeight = h.toFloat()
rect.apply {
left = 0f
top = 0f
right = mWidth
bottom = mHeight
}
//取宽和高中最小值的一半作为圆角矩形的半径
rRectRadius = mWidth.coerceAtMost(mHeight) / 2
//计算文字位置
calculateTextPos()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawSwitch(canvas)
}
/**
* 绘制开关
*/
private fun drawSwitch(canvas: Canvas) {
if(isSwitchOn){
canvas.apply {
mPaint.color = switchOnColor
drawRoundRect(rect, rRectRadius, rRectRadius, mPaint)
mPaint.color = context.getColor(R.color.color_white)
drawCircle(mWidth - rRectRadius, rRectRadius, rRectRadius - rRRadiusMargin, mPaint)
}
}else {
canvas.apply {
mPaint.color = switchOffColor
drawRoundRect(rect, rRectRadius, rRectRadius, mPaint)
mPaint.color = context.getColor(R.color.color_white)
drawCircle(rRectRadius, rRectRadius, rRectRadius - rRRadiusMargin, mPaint)
}
}
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when(event.action){
MotionEvent.ACTION_UP ->{
isSwitchOn = !isSwitchOn
updateSwitchText()
invalidate()
}
}
return true
}
/**
* 外部接口设置开关状态
*/
fun setSwitchStatus(isOn:Boolean){
isSwitchOn = isOn
invalidate()
}
/**
* 外部接口获取当前开关状态
*/
fun getSwitchStatus():Boolean{
return isSwitchOn
}
/**
* 更新开关状态对应文字
*/
private fun updateSwitchText(){
switchStatus = if(isSwitchOn) context.getString(R.string.switch_on) else context.getString(R.string.switch_off)
}
}
上面就完成了除了文字外所有元素和事件的处理,下面将着重介绍下文字的绘制。
3、绘制文字
从效果图上可以看出,关和开的文字位置以圆角矩形的左内边和右内边为参考,定义变量 textMargin
。我们先认识下使用的drawText
方法参数:
text
:需要绘制的文字x
: 要绘制文字的初始x坐标y
: 要绘制文字文本基线y坐标paint
:画笔从参数上看,文字的位置就由x
和y
值决定,如何摆放,只要把x
和y
值确定即可。
public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint) {
}
我们先把文字的绘制起点放到整个控件的中心位置,来看下效果。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawSwitch(canvas)
}
/**
* 绘制开关
*/
private fun drawSwitch(canvas: Canvas) {
if(isSwitchOn){
canvas.apply {
mPaint.color = switchOnColor
drawRoundRect(rect, rRectRadius, rRectRadius, mPaint)
mPaint.color = context.getColor(R.color.color_white)
drawText(switchStatusText,mWidth / 2,mHeight / 2,textPaint)
//竖线
drawLine(mWidth/2,0f,mWidth/2,mHeight,mPaint)
mPaint.color = context.getColor(R.color.color_red)
//横线
drawLine(0f,mHeight / 2,mWidth,mHeight / 2,basePointPaint)
drawCircle(mWidth / 2,mHeight / 2,2f,basePointPaint)
}
}else {
canvas.apply {
mPaint.color = switchOffColor
drawRoundRect(rect, rRectRadius, rRectRadius, mPaint)
mPaint.color = context.getColor(R.color.color_white)
drawText(switchStatusText,mWidth / 2,mHeight / 2,textPaint)
drawLine(mWidth/2,0f , mWidth/2,mHeight,mPaint)
drawPoint(mWidth / 2,mHeight / 2 , mPaint)
drawLine(0f,mHeight / 2,mWidth,mHeight/2,basePointPaint)
drawCircle(mWidth / 2,mHeight / 2,2f,basePointPaint)
}
}
}
明明是把文字的绘制起点放到了控件的正中心,然而文字的位置却跟预想的有些差别,说明了绘制文字起点并不是以起点为左上方或者正中位置。那么如何让文字能够垂直居中显示呢?下面就要介绍上面提到的getTextBounds()
、measureText()
、FontMetrics
。
看上图,文字要想垂直居中摆放,只要我们知道文字的总高度,将起始位置的纵坐标下移文字高度的一半就可以实现。
在以前的文章中,介绍过getTextBounds()
方法,是能够获取到文字的边框范围,这样就能获取到文字的高度,下面我们来实现下。
/**
* 仿ios开关
*/
class SwitchButtonView(context: Context, attributeSet: AttributeSet): View(context,attributeSet) {
/**
* 文字矩形范围
*/
private var textRect = Rect()
/**
* 开关文字画笔
*/
private var textPaint = Paint().apply {
style = Paint.Style.FILL
color = context.getColor(R.color.color_white)
isAntiAlias = true
isDither = true
textSize = 40f
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//控件宽
mWidth = w.toFloat()
//控件高
mHeight = h.toFloat()
......
textPaint.getTextBounds(switchStatusText,0 , switchStatusText.length,textRect)
Log.d("AAAAAA","l = ${textRect.left} , t = ${textRect.top} , r = ${textRect.right} , b = ${textRect.bottom}")
}
打印:
6368-6368/com.ho.customview D/AAAAAA: l = 2 , t = -30 , r = 38 , b = 5
}
从打印的结果看,文字的最小矩形范围的高度为35,我们将文字的绘制起点的y
坐标向下移动高度的一半。
//绘制文字y坐标优化
drawText(switchStatusText,mWidth / 2, mHeight / 2 + (textRect.bottom - textRect.top) / 2,textPaint)
文字的位置的确改变了,也接近我们要摆放的位置,但是还是可以看出两个文字并不是垂直居中的,这是为什么?其实是使用getTextBounds
方法获取的文字的矩形范围精度是不够的,我们仔细观察下图3中,文字内容是会超出水平的红色线段,说明一个问题,文字大小实际矩形框比getTextBounds
获取的要大。
重点来了!!!
下面正式介绍今天的主角 ---- 字体度量属性FontMetrics
了,我们先用一张图看下它有哪些属性。
图片是从网上截取下来的,这里感谢下原作者!!
图中所有的属性都和Baseline
有关,上面已经介绍了,drawText(text,x,y,paint)
中y
坐标就是绘制文字的baseline
(基准线)。上面所遇到的问题都是围绕这个Baseline
。下面介绍其他属性:
ascent
是baseline
之上至字符最高处的距离descent
是baseline
之下至字符最低处的距离top
是最高字符到baseline
的值,即ascent
的最大值bottom
最下字符到baseline
的值,即descent
的最大值leading
是上一行字符的descent
到下一行的ascent
之间的距离,如果只有一行这个值为0,计算字体高度有时也需要加上这个数据了解到了这几个属性含义后,我们再分析下图3就容易多了,图中红点(中心点)这个就是Baseline
的y
坐标,文字绘制的起点坐标y
轴,红色的横线就是Baseline
,可以看到,开和关字是超出Baseline
下方的,超出的部分就是Descent
,由此得出Descent + Ascent
值是文字比较精确的高度。
那么计算下使得文字垂直居中的正确值,首先是通过fontMetrics.descent
获取到Baseline
距离底部最大值Descent
,将文字高度向上移动Descent
,再获取文字的高度即fontMetrics.descent
的绝对值和fontMetrics.ascent
绝对值之和的一半textHalfHeight
,这才是文字真正的垂直中线值,将文字的基准线baseline
向下移动textHalfHeight
就能满足要求了。
绘制还要用到文字的宽度值,用于摆放"关"字,使用Paint
的measureText()
方法即可,方法较为简单不做过多介绍。
下面代码实现:
/**
* 仿ios开关
*/
class SwitchButtonView(context: Context, attributeSet: AttributeSet): View(context,attributeSet) {
//文字x坐标
private var textX = 0f
//文字y坐标
private var textY = 0f
//文字宽度
private var textWidth = 0f
//文字距离圆角矩形内边间距
private var textMargin = 20f
private var textRect = Rect()
private lateinit var fontMetrics: Paint.FontMetrics
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//控件宽
mWidth = w.toFloat()
//控件高
mHeight = h.toFloat()
rect.apply {
left = 0f
top = 0f
right = mWidth
bottom = mHeight
}
//取宽和高中最小值的一半作为圆角矩形的半径
rRectRadius = mWidth.coerceAtMost(mHeight) / 2
//计算文字位置
calculateTextPos()
}
/**
* 计算文本坐标位置
*/
private fun calculateTextPos(){
//获取文字宽度
textWidth = textPaint.measureText(switchStatusText)
//获取fontMetrics对象
fontMetrics = textPaint.fontMetrics
//获取文本的高度的一半,取文字垂直中线高度值
val textHalfHeight = (abs(fontMetrics.descent) + abs(fontMetrics.ascent)) / 2
//将文字的向上移动Descent,再向下移动文字高度一半
textY = mHeight / 2 - abs(fontMetrics.descent) + textHalfHeight
}
/**
* 绘制开关
*/
private fun drawSwitch(canvas: Canvas) {
if(isSwitchOn){
canvas.apply {
mPaint.color = switchOnColor
drawRoundRect(rect, rRectRadius, rRectRadius, mPaint)
mPaint.color = context.getColor(R.color.color_white)
drawCircle(mWidth - rRectRadius , mHeight / 2 , rRectRadius - rRRadiusMargin,mPaint)
//绘制文字,"开"字起点距左内边距textMargin
drawText(switchStatusText, textMargin,textY,textPaint)
//竖线
drawLine(mWidth / 2,0f,mWidth / 2,mHeight,mPaint)
mPaint.color = context.getColor(R.color.color_red)
//横线
drawLine(0f,mHeight / 2,mWidth,mHeight / 2,basePointPaint)
}
}else {
canvas.apply {
mPaint.color = switchOffColor
drawRoundRect(rect, rRectRadius, rRectRadius, mPaint)
mPaint.color = context.getColor(R.color.color_white)
drawCircle( rRectRadius, mHeight / 2 , rRectRadius - rRRadiusMargin,mPaint)
//绘制文字,"关"字起点距离有内边距textMargin,起始点为控件宽度减去边距再减去文字宽度
drawText(switchStatusText,mWidth - textMargin - textWidth ,textY ,textPaint)
drawLine(mWidth/2,0f , mWidth/2,mHeight,mPaint)
drawPoint(mWidth / 2,mHeight / 2 , mPaint)
drawLine(0f,mHeight / 2,mWidth,mHeight / 2,basePointPaint)
}
}
}
}
优化后的效果真正实现了文字垂直居中,这也基本完成了Switch
开发的自定义,过程中学习和掌握了文字绘制技巧。
END~