本篇文章记录下Android仿iOS控件Switch开关自定义过程。此控件实现的难度较小,但是在绘制文字过程中遇到一些问题,比如如何将文字摆放在正确的位置,文字绘制和位置的处理用到以下的知识点:
Canvas的绘制文字drawTextPaint获取文字边界getTextBounds()Paint的测量文字宽度measureText()FontMetrics(文字位置摆放关键)1、控件效果

绘制分析:
从图上可以看出,主要有以下几个元素以及事件:
drawRoundRectdrawCircledrawTextonTouch事件,处理开关状态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~