Android自定义控件(六) Andriod仿iOS控件Switch开关

前言

本篇文章记录下Android仿iOS控件Switch开关自定义过程。此控件实现的难度较小,但是在绘制文字过程中遇到一些问题,比如如何将文字摆放在正确的位置,文字绘制和位置的处理用到以下的知识点:

  • Canvas的绘制文字drawText
  • Paint获取文字边界getTextBounds()
  • Paint的测量文字宽度measureText()
  • 字体度量属性FontMetrics(文字位置摆放关键)

说明

1、控件效果

Android自定义控件(六) Andriod仿iOS控件Switch开关_第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,只要传入所在的矩形范围、xy方向的radius半径来生成圆角,这里传同一个值rRectRadius即可,再传入画笔对象。开和关的状态主要把画笔的颜色设置不同即可,色值可以定义在styleable中。

下一步就是绘制白色圆形drawCircle,圆形的半径比rRectRadius小一些,且绘制的时候根据开还是关,使小白圆距离圆角矩形有一定的距离rRRadiusMargin即可。

上面两步就完成了基础的绘制,再通过onTouchEvent处理点击后开和关状态的改变,重新绘制。

控件还需要对外提供获取当前的状态和设置开关状态两个方法,这里定义了setSwitchStatusgetSwitchStatus

/**
 * 仿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)
    }

}

Android自定义控件(六) Andriod仿iOS控件Switch开关_第2张图片

上面就完成了除了文字外所有元素和事件的处理,下面将着重介绍下文字的绘制。


3、绘制文字

从效果图上可以看出,关和开的文字位置以圆角矩形的左内边和右内边为参考,定义变量 textMargin。我们先认识下使用的drawText方法参数:

  • text:需要绘制的文字
  • x : 要绘制文字的初始x坐标
  • y : 要绘制文字文本基线y坐标
  • paint:画笔

从参数上看,文字的位置就由xy值决定,如何摆放,只要把xy值确定即可。


    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)
            }
        }
    }


Android自定义控件(六) Andriod仿iOS控件Switch开关_第3张图片
明明是把文字的绘制起点放到了控件的正中心,然而文字的位置却跟预想的有些差别,说明了绘制文字起点并不是以起点为左上方或者正中位置。那么如何让文字能够垂直居中显示呢?下面就要介绍上面提到的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)
   

Android自定义控件(六) Andriod仿iOS控件Switch开关_第4张图片
文字的位置的确改变了,也接近我们要摆放的位置,但是还是可以看出两个文字并不是垂直居中的,这是为什么?其实是使用getTextBounds方法获取的文字的矩形范围精度是不够的,我们仔细观察下图3中,文字内容是会超出水平的红色线段,说明一个问题,文字大小实际矩形框比getTextBounds获取的要大。

重点来了!!!

下面正式介绍今天的主角 ---- 字体度量属性FontMetrics了,我们先用一张图看下它有哪些属性。
Android自定义控件(六) Andriod仿iOS控件Switch开关_第5张图片
图片是从网上截取下来的,这里感谢下原作者!!

图中所有的属性都和Baseline有关,上面已经介绍了,drawText(text,x,y,paint)y坐标就是绘制文字的baseline(基准线)。上面所遇到的问题都是围绕这个Baseline。下面介绍其他属性:

  • ascentbaseline之上至字符最高处的距离
  • descentbaseline之下至字符最低处的距离
  • top是最高字符到baseline的值,即ascent的最大值
  • bottom最下字符到baseline的值,即descent的最大值
  • leading是上一行字符的descent到下一行的ascent之间的距离,如果只有一行这个值为0,计算字体高度有时也需要加上这个数据

了解到了这几个属性含义后,我们再分析下图3就容易多了,图中红点(中心点)这个就是Baseliney坐标,文字绘制的起点坐标y轴,红色的横线就是Baseline,可以看到,开和关字是超出Baseline下方的,超出的部分就是Descent,由此得出Descent + Ascent值是文字比较精确的高度。

那么计算下使得文字垂直居中的正确值,首先是通过fontMetrics.descent获取到Baseline距离底部最大值Descent,将文字高度向上移动Descent,再获取文字的高度即fontMetrics.descent的绝对值和fontMetrics.ascent绝对值之和的一半textHalfHeight,这才是文字真正的垂直中线值,将文字的基准线baseline向下移动textHalfHeight就能满足要求了。

绘制还要用到文字的宽度值,用于摆放"关"字,使用PaintmeasureText()方法即可,方法较为简单不做过多介绍。

下面代码实现:


/**
 * 仿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)
            }
        }
    }
}


Android自定义控件(六) Andriod仿iOS控件Switch开关_第6张图片
优化后的效果真正实现了文字垂直居中,这也基本完成了Switch开发的自定义,过程中学习和掌握了文字绘制技巧。


总结

END~

你可能感兴趣的:(Android自定义View,Android,android,kotlin,自定义View)