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


    前言

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

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

    说明

    1、控件效果

    Ho-SwitchButtonView 图 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)
        }
    
    }
    
    

    Ho-SwitchButtonView 图 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)
                }
            }
        }
    
    
    

    Ho-SwitchButtonView 图 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)
       
    

    Ho-SwitchButtonView 图 4
    文字的位置的确改变了,也接近我们要摆放的位置,但是还是可以看出两个文字并不是垂直居中的,这是为什么?其实是使用getTextBounds方法获取的文字的矩形范围精度是不够的,我们仔细观察下图3中,文字内容是会超出水平的红色线段,说明一个问题,文字大小实际矩形框比getTextBounds获取的要大。

    重点来了!!!

    下面正式介绍今天的主角 ---- 字体度量属性FontMetrics了,我们先用一张图看下它有哪些属性。
    在这里插入图片描述
    图片是从网上截取下来的,这里感谢下原作者!!

    图中所有的属性都和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)
                }
            }
        }
    }
    
    
    

    Ho-SwitchButtonView 图 5
    优化后的效果真正实现了文字垂直居中,这也基本完成了Switch开发的自定义,过程中学习和掌握了文字绘制技巧。


    总结

    END~

  • 相关阅读:
    什么是UML UML入门到放弃系列
    javaEE -7(网络原理初识 --- 7000字)
    『忘了再学』Shell基础 — 23、其他环境变量配置文件
    ASP.NET MVC多表示例题-酒店管理
    Python问题1:ModuleNotFoundError: No module named ‘numpy‘
    备战2023秋招,应届生应做好哪些准备
    UE5笔记【四】UE5主材质Master Materials和材质实例MI
    AI 引擎系列 6 - 在 Vitis 分析器中分析 AI 引擎编译结果(2022.1 更新)
    伦敦银怎么算自己的收益?
    解决 php post 而 gin 收不到问题
  • 原文地址:https://blog.csdn.net/qq_17470165/article/details/127030524