Author: Gu Dong Mobile Technology team -Blue

In Android development, using the Shape tag can easily help us build resource files, compared to traditional PNG images:

  • Shape helps reduce the size of the APK installation package.
  • Shape also performs better in different mobile phone adaptations.

Today, we are going to discuss the consequences of the spread of Shape tags. Here’s a drawable directory for projects that have been maintained for more than 5 years

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

    <solid android:color="# 66000000" />
    <corners android:radius="15dp" />

</shape>
Copy the code
<?xml version="1.0" encoding="utf-8"? >
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <gradient
        android:startColor="#0f000000"
        android:endColor="# 00000000"
        android:angle="270"
        />
</shape>
Copy the code
<?xml version="1.0" encoding="utf-8"? >
<shape xmlns:android="http://schemas.android.com/apk/res/android" >

    <solid android:color="#fbfbfd" />
    <stroke
        android:width="1px"
        android:color="#dad9de" />
    
    <corners
        android:radius="10dp" />

</shape>
Copy the code

Really don’t see don’t know, a look startled. It turns out that most of the shape files in our project are pretty much the same, covering the most common shape changes: rounded corners, strokes, fills, and gradients. Further analysis shows that:

  • Sometimes the fill color is the same, but the radius is different, so we have to add a shape file.
  • Sometimes the radius is the same, but the fill color is different, so we have to add a shape file.
  • Sometimes two colleagues in charge of different business modules each add a shape file of the same style.

And so on, let us fall into the shape file unlimited addition and maintenance. We can’t help thinking, is there any way to unify these shapes for management? Doesn’t XML write code that eventually corresponds to an in-memory object? Can we move from managing shape files to managing an object?

Talk is cheap. Show me the code

So the first thing we need to do is figure out which class shape corresponds to, right? The first reaction is ShapeDrawable, as the name suggests. Then the brutal truth tells us that it’s GradientDrawable brothers. Browse through the method structure of the GradientDrawable class and you’ll also find target methods like setColor(), setCornerRadius(), and setStroke(). Well, anyway, we got the right guy.

The second step, continue to think about how to design this general control, mainly from the following aspects of consideration:

  • Shape can be used either as a text label or as a response button, so both text and button styles are required. The main difference between the two is that the button styles have shadows in both normal and pressed state.
  • In order to improve user experience, the universal control pressing effect is designed. For users above 5.0 open the press water ripple effect, for users below 5.0 open the press color effect. Combined with the above two points, the implementation of generic controls can extend AppCompatButton directly.
  • In specific business scenarios, the use of generic controls may also be accompanied by a drawable and require the drawable to be centered with text. This is not a separate problem, but Android has a pit where a drawable is set to the edge of a button control by default, so this pit needs to be filled in separately.
  • Custom control properties support Shape mode, Fill color, Pinch color, Stroke color, Stroke width, Corner radius, pinch effect on, Gradient start color, gradient end color, gradient direction, and Drawable direction.

The third step, the train of thought has been combed out clearly, then start masturbation.

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

The default style defStyleAttr passes 0, so the default representation of CommonShapeButton is a text style.

If you want to use the button style, you need to define a custom button style first. The reason is that the system button style comes with minWidth, minHeight and padding, which will affect the display of our button in specific business, so we reset these three attributes in the custom button style:

<! -- Custom button styles -->
<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>
Copy the code

With custom button styles, if you want to use CommonShapeButton style, use the following form:

<com.blue.view.CommonShapeButton
    style="@style/CommonShapeButtonStyle"
    android:layout_width="300dp"
    android:layout_height="50dp"/>
Copy the code

This is where you can switch between simple text styles and button styles. Next we’ll do the key shape rendering:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, HeightMeasureSpec) with(normalGradientDrawable) {// If (mStartColor! = Color.parseColor("#FFFFFF") && mEndColor ! = Color.parseColor("#FFFFFF")) { colors = intArrayOf(mStartColor, mEndColor) when (mOrientation) { 0 -> orientation = GradientDrawable.Orientation.TOP_BOTTOM 1 -> orientation = GradientDrawable. Orientation. LEFT_RIGHT}} / / fill color else {setColor (mFillColor)} the when (mShapeMode) {0 - > shape = GradientDrawable.RECTANGLE 1 -> shape = GradientDrawable.OVAL 2 -> shape = GradientDrawable.LINE 3 -> shape = GradientDrawable.RING } cornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, mCornerRadius.toFloat(), Resources.displaymetrics) // The default transparent border is not drawn, otherwise it will result in no shadow if (mStrokeColor! = Color.parseColor("#00000000")) { setStroke(mStrokeWidth, MStrokeColor)}} background = if (mActiveEnable) {// if (build.version.sdk_int > Build.VERSION_CODES.LOLLIPOP) { RippleDrawable(ColorStateList.valueOf(mPressedColor), normalGradientDrawable, Else {// Initialize the pressed state with(pressedGradientDrawable) {setColor(mPressedColor) when (mShapeMode) { 0 -> shape = GradientDrawable.RECTANGLE 1 -> shape = GradientDrawable.OVAL 2 -> shape = GradientDrawable.LINE 3 -> shape  = GradientDrawable.RING } cornerRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, McOrnerradio.tofloat (), resources.displayMetrics) setStroke(mStrokeWidth, mStrokeColor)} StateListDrawable. Apply {addState(intArrayOf(Android.r.attr.state_pressed), PressedGradientDrawable) // Set normal state addState(intArrayOf(), normalGradientDrawable) } } } else { normalGradientDrawable } }Copy the code

The code here is a bit long, so don’t worry, let’s take our time:

  • The first option is to do shape rendering in onMeasure method
  • Next, set the normarlGradientDrawable to whether it is currently gradient rendering or fill rendering. Gradient rendering also needs to control the direction of rendering separately
  • Then set normarlGradientDrawable to shape mode, rounded corners, and stroke
  • Finally, set background to CommonShapeButton. If click effects are not enabled, normarlGradientDrawable is returned directly. If click effect is enabled, water ripple effect is enabled above 5.0 and color change effect is enabled below 5.0. PressedGradientDrawable shape is also initialized in the color effect Settings, and the stateListDrawable is added in turn for the background display

Use shape to render the CommonShapeButton background with custom attributes.

<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>
Copy the code

Next we need to do some final work to solve the problem of adding an off-center drawable display to a button

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { super.onLayout(changed, left, top, right, If (mDrawablePosition > -1) {if (mDrawablePosition > -1) {if (mDrawablePosition > -1) { compoundDrawables? .let { val drawable: Drawable? = compoundDrawables[mDrawablePosition] drawable? Val drawablePadding = compoundDrawablePosition when (mDrawablePosition) {// drawable 0, Int int int int int int int int int int int int int int int int int int int int int int int int int int int int int int ContentWidth = textWidth + drawableWidth + drawablePadding Val rightPadding = (width-ContentWidth).toint () // SetPadding (0, 0, rightPadding, 0)} drawable 1, 3 -> {// image height val drawableHeight = it. IntrinsicHeight // Get text height val FM = paint. FontMetrics Math.ceil(fm.descent.todouble () -fm.ascent.todouble ()).tofloat () // Total line spacing val totalLineSpaceHeight = (linecount-1) * lineSpacingExtra val textHeight = singeLineHeight * lineCount + totalLineSpaceHeight // total contentHeight contentHeight = Var bottomPadding = (height-contentheight).toint () SetPadding (0, 0, 0, bottomPadding)}}}}} // Content centered gravity = gravity.Copy the code

Let’s continue analyzing the code here:

  • To start with rendering efficiency, we chose to calculate some values in the onLayout method
  • Second, since we support up, down, left, and right drawables, we need to specify the attribute drawablePosition in the XML
  • Then check if drawable is set and drawable fetch is not null
  • Then determine the left and right directions of drawable, calculate the width of the picture and the width of the text, and then paste all the content of button on the left edge according to the total width of the content
  • Finally, determine the upper and lower orientation of drawable, calculate the height of the picture and the height of the text, and then paste all the content of button on the edge according to the total height of the content

Now that we’re ready to center drawable, let’s move on:

override fun onDraw(canvas: Canvas) {/ / let the pictures and words centered 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) }Copy the code

The next step is to use the onDraw method to center the drawable and the text in the button by translating the value calculated in the onLayout method.

Here we have completed the design and implementation of CommonShapeButton, the following is the effect diagram:

Github address portal