Android中自定义CommonShapeButton替换Shape标签

在Android开发中,使用Shape标签可以很方便的帮我们构建资源文件,跟传统的png图片相比:

  • shape标签可以帮助我们有效减小apk安装包大小。
  • 在不同手机的适配上面,shape标签也表现得更加优秀。

关于shape标签如何使用,在网上一搜一大把,笔者这里就不赘述了,今天我们要讨论的shape标签泛滥成灾以后带来的后果。这里先给大家看一个维护刚刚有3年的项目的drawable目录。

请注意右侧标红的滚动条,有没有感觉很酸爽,在这个目录下的文件现在已经接近400个了,并且还在不停的增加。我们分析这个目录下的xml构成,发现主要由两种类型构成:selector和shape。selector这里略过不提,重点是shape,发现shape文件已经超过一半多,并且还会不停的增加,我们在带着好奇的心态,随便点开几个shape看一看

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- solid的表示填充颜色 -->
    <solid android:color="#44ffffff"/>

    <stroke
        android:width="1px"
        android:color="#77ffffff"/>

    <!--为了展示半圆,就把角度设置的大点-->
    <corners android:radius="100dp"/>

</shape>

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="#ffffff" />
    <corners android:radius="1dip" />
    <stroke
        android:width="0.5dip"
        android:color="#ea474f" />
</shape>  


<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <stroke
        android:width="0.5dp"
        android:color="@color/color_de" />
    <solid android:color="@color/white" />
    <corners android:radius="5dp" />
</shape>

真的是不看不知道,一看吓一跳。原来我们项目中大量存在的shape文件其实都是大同小异的。涉及到最常见的shape变化:圆角,描边,填充以及渐变。进一步分析,我们又发现:

  • 有些时候填充颜色是相同的,只不过圆角半径不同,我们就得新增及一个shape文件。
  • 有些时候圆角半径是相同的,只不过填充颜色不同,我们又得增加一个shape文件。
  • 有些时候两个负责不同业务模块的同事,各自新增一个同样样式的shape文件。

等等一些情况,让我们陷入了shape文件的无限新增与维护中。我们不禁要思考,有没有办法可以把这些shape统一起来管理呢?xml书写出来的代码最终不都是会对应一个内存中的对象么?我们能不能从管理shape文件过渡到管理一个对象呢?

Talk is cheap 。 Show me the code

第一步,我们需要确定shape标签对应的泪到底是哪一个?第一反应就是ShapeDrawable,顾名思义嘛。然后残酷的事实告诉我们其实是GradientDrawable这兄弟。浏览GradientDrawable类的方法结构,从中我们也找到了setColor()、setCornerRadius()、setStroke()等目标方法。好吧,不管怎么样,先找到正主了。

第二部,继续思考如何来设计这个通用控件,主要从以下几个方面进行了考虑:

  • shape的应用场景有可能是文字标签,也有可能是响应按钮,所以需要文本和按钮两个样式,两者的主要区别在于按钮样式在普通状态下和按压状态下都具有阴影。
  • 为了提升用户体验,设计了通用控件的按压动效。针对5.0以上的用户,开启按压水波纹效果,针对5.0以下的用户开启按压变色效果。结合以上两点,通用控件的实现考虑直接继承AppCompatButton进行扩展。
  • 具体的业务场景中,通用控件的使用还有可能伴随着drawable,并且要求drawable和文字一起居中显示。其实这个问题本来是不需要单独考虑的,但是Android有个坑,在一个按钮控件中设置drawable以后,默认是贴着控件边缘显示的,所以这个坑需要单独填。
  • 自定义控件属性支持shape模式、填充颜色、按压颜色、描边颜色、描边宽度、圆角半径、按压动效是否开启,渐变开始颜色、渐变结束颜色、渐变方向、drawable方位。

第三步,思路已经梳理清楚了,那就开撸。

class CommonShapeButton @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
) : AppCompatButton(context, attrs, defStyleAttr)

这里实现了继承AppCompatButton进行扩展,默认样式defStyleAttr传递的是0。那么CommonShapeButton的默认表现形式就是文本样式。

如果想要采用按钮样式,则需要先自定义一个按钮样式,原因是系统按钮的样式自带了minWidth、minHeight以及padding,在具体业务中会影响到我们的按钮显示,所以在自定义按钮样式中重置了这3个属性:

    <!-- 自定义按钮样式 -->
    <style name="CommonShapeButtonStyle" parent="@style/Widget.AppCompat.Button">
        <item name="android:minWidth">0dp</item>
        <item name="android:minHeight">0dp</item>
        <item name="android:padding">0dp</item>
    </style>

到这里就可以实现简单的文本样式和按钮样式的切换了。接下来我们就要进行关键字shape渲染了:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //初始化normal状态
        with(normalGradientDrawable) {
            //渐变色
            if (mStartColor != 0xFFFFFFFF.toInt() && mEndColor != 0xFFFFFFFF.toInt()) {
                colors = intArrayOf(mStartColor, mEndColor)
                when (mOrientation) {
                    0 -> orientation = GradientDrawable.Orientation.TOP_BOTTOM
                    1 -> orientation = GradientDrawable.Orientation.LEFT_RIGHT
                }
            } else {//填充色
                setColor(mFillColor)
            }

            when (mShapeMode) {
                0 -> shape = GradientDrawable.RECTANGLE
                1 -> shape = GradientDrawable.OVAL
                2 -> shape = GradientDrawable.LINE
                3 -> shape = GradientDrawable.RING
            }
            //统一设置圆角半径
            if (mCornerPosition == -1) {
                cornerRadius = mCornerRadius.toFloat()
            } else { //根据圆角位置设置圆角半径
                cornerRadii = getCornerRadiusByPosition()
            }
            //默认的透明边框不绘制,否则会导致没有阴影
            if (mStrokeColor != 0)
                setStroke(mStrokeWidth, mStrokeColor)
        }

        //是否开启点击动效
        background = if (mActiveEnable) {
            //5.0 以上水波纹效果
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                RippleDrawable(ColorStateList.valueOf(mPressedColor), normalGradientDrawable, null)
            } else {//5.0 以下变色效果
                //初始化pressed状态
                with(pressedGradientDrawable) {
                    setColor(mPressedColor)
                    when (mShapeMode) {
                        0 -> shape = GradientDrawable.RECTANGLE
                        1 -> shape = GradientDrawable.OVAL
                        2 -> shape = GradientDrawable.LINE
                        3 -> shape = GradientDrawable.RING
                    }
                    cornerRadius = mCornerRadius.toFloat()
                    setStroke(mStrokeWidth, mStrokeColor)
                }
                //注意此处的add殊勋,normal必须在最后一个,否则其他状态无效
                // 设置pressed 状态
                stateListDrawable.apply {
                    addState(intArrayOf(android.R.attr.state_pressed), pressedGradientDrawable)
                    //设置normal状态
                    addState(intArrayOf(), normalGradientDrawable)
                }
            }
        } else {
            normalGradientDrawable
        }
    }


  /**
     * 根据圆角位置获取圆角半径
     */
    private fun getCornerRadiusByPosition(): FloatArray {
        val result = floatArrayOf(0f, 0f, 0f, 0f, 0f, 0f, 0f, 0f)
        val cornerRadius = mCornerRadius.toFloat()
        if (containsFlag(mCornerPosition, TOP_LEFT)) {
            result[0] = cornerRadius
            result[1] = cornerRadius
        }
        if (containsFlag(mCornerPosition, TOP_RIGHT)) {
            result[2] = cornerRadius
            result[3] = cornerRadius
        }
        if (containsFlag(mCornerPosition, BOTTOM_RIGHT)) {
            result[4] = cornerRadius
            result[5] = cornerRadius
        }
        if (containsFlag(mCornerPosition, BOTTOM_LEFT)) {
            result[6] = cornerRadius
            result[7] = cornerRadius
        }
        return result
    }

    /**
     * 是否包含对应flag
     */
    private fun containsFlag(flagSet: Int, flag: Int): Boolean {
        return flagSet or flag == flagSet
    }

这里的代码有点长,别着急,我们来慢慢分析一下:

  • 首先是选择在onMeasure方法中做shape渲染
  • 其次对normalGradientDrawable设置当前是渐变色渲染,还是填充色渲染,渐变色渲染还需要单独控制渲染的方向
  • 然后对normarlGradientDrawable设置shape模式、圆角以及描边
  • 最后对CommonShapeButton设置background。如果没有开启点击特效,则直接返回normarlGradientDrawable。如果开启了点击特效,那么5.0以上启用水波纹效果,5.0以下启用变色效果。在变色效果的设置中同样初始化了pressedGradientDrawable的shape属性,并且依次添加进了stateListDrawable用作背景显示。

到这里就可以实现了用自定义属性控制shape渲染显示CommonShapeButton的背景了,这里贴上全部的属性:

 <declare-styleable name="CommonShapeButton">
        <attr name="csb_shapeMode" format="enum">
            <enum name="rectangle" value="0"/>
            <enum name="oval" value="1"/>
            <enum name="line" value="2"/>
            <enum name="ring" value="3"/>
        </attr>
        
        <attr name="csb_fillColor" format="color"/>
        <attr name="csb_pressedColor" format="color"/>
        <attr name="csb_strokeColor" format="color"/>
        <attr name="csb_strokeWidth" format="dimension"/>
        <attr name="csb_cornerRadius" format="dimension"/>
        <attr name="csb_activeEnable" format="boolean"/>
        <attr name="csb_drawablePosition" format="enum">
            <enum name="left" value="0"/>
            <enum name="top" value="1"/>
            <enum name="right" value="2"/>
            <enum name="bottom" value="3"/>
        </attr>
        <attr name="csb_startColor" format="color"/>
        <attr name="csb_endColor" format="color"/>
        <attr name="csb_orientation" format="enum">
            <enum name="TOP_BOTTOM" value="0"/>
            <enum name="LEFT_RIGHT" value="1"/>
        </attr>
    </declare-styleable>

接下来我们还需要进行最后的工作,解决在一个button中添加drawable不居中显示的问题

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        super.onLayout(changed, left, top, right, bottom)
        //如果xml中配置了drawable则设置padding让文字移动到边缘与drawable靠在一起
        //button中配置的drawable默认贴着边缘
        if (mDrawablePosition > -1) {
            compoundDrawables?.let {
                val drawable: Drawable? = compoundDrawables[mDrawablePosition]
                drawable?.let {
                    //图片间距
                    val drawablePadding = compoundDrawablePadding
                    when (mDrawablePosition) {
                    //左右drawable
                        0, 2 -> {
                            //图片宽度
                            val drawableWidth = it.intrinsicWidth
                            //获取文字宽度
                            val textWidth = paint.measureText(text.toString())
                            //内容总宽度
                            contentWidth = textWidth + drawableWidth + drawablePadding
                            val rightPadding = (width - contentWidth).toInt()
                            //图片和文字全部靠在左侧
                            setPadding(0, 0, rightPadding, 0)
                        }
                    //上下drawable
                        1, 3 -> {
                            //图片高度
                            val drawableHeight = it.intrinsicHeight
                            //获取文字高度
                            val fm = paint.fontMetrics
                            //单行高度
                            val singeLineHeight = Math.ceil(fm.descent.toDouble() - fm.ascent.toDouble()).toFloat()
                            //总的行间距
                            val totalLineSpaceHeight = (lineCount - 1) * lineSpacingExtra
                            val textHeight = singeLineHeight * lineCount + totalLineSpaceHeight
                            //内容总高度
                            contentHeight = textHeight + drawableHeight + drawablePadding
                            //图片和文字全部靠在上侧
                            val bottomPadding = (height - contentHeight).toInt()
                            setPadding(0, 0, 0, bottomPadding)
                        }
                    }
                }
            }
        }
        //内容居中
        gravity = Gravity.CENTER
        //可点击
        isClickable = true
    }

我们继续来分析这里的代码:

  • 首先渲染的效率,我们选择在onLayout方法中计算一些数值
  • 其次由于我们是支持上下左右四个方向的drawable,所以需要在xml中指定属性drawablePosition
  • 然后我们判断是否设置了drawable并且drawable获取不为空
  • 然后判断drawable左右方位,则计算图片的宽度和文字的宽度,然后根据内容的总宽度把button的内容全部贴在左边缘显示
  • 最后判断drawable在上下方位,则计算图片的高度和文字的高度,然后根据内容的总高度把button的内容全部贴上边缘显示

到这里就做好了让drawable居中显示的准备工作,我们继续往下走:

   override fun onDraw(canvas: Canvas?) {
        //让图片和文字居中
        when {
            contentWidth > 0 && (mDrawablePosition == 0 || mDrawablePosition == 2) -> canvas!!.translate((width - contentWidth) / 2, 0f)
            contentHeight > 0 && (mDrawablePosition == 1 || mDrawablePosition == 3) -> canvas!!.translate(0f, (height - contentHeight) / 2)
        }
        super.onDraw(canvas)
    }

接下来我们就是在onDraw方法中,立永在onLayout方法中计算的数值,平移button的内容,从而实现让drawable和文字一起居中显示。

到这就完成了CommonShapeButton的全部设计和实现啦,来个结果图:

本文源于nanchen的分享,喜欢的可以去关注一下他哈,文章地址

猜你喜欢

转载自blog.csdn.net/lxm20819/article/details/82457031
今日推荐