Kotlin writes custom ViewGroup

Recommended by Haowen:
Author: IAn2018

With the blessing of Kotlin, the composition recently implemented by Android makes it easier and faster to write UI. There is no need to worry about layout nesting or declarative UI. So there are so many advantages of composition. Is there a "way out" for native writing?

Today, I'd like to share a non-traditional way to write a custom ViewGroup, so that you can no longer "fear" of a custom ViewGroup. With the help of Kotlin, we can also quickly write a non nested layout using the native writing method.

Why use a custom ViewGroup

When we write UI, we directly use xml to write the Layout. We pay more or less attention to avoid Layout nesting. Since there is ConstraintLayout, nesting has been reduced a lot, but can we achieve the ultimate performance with ConstraintLayout? Overall, the performance is better than other layouts Improved . However, for specific pages in the product, the ultimate performance may not be achieved, because there are too many scenarios to consider in the ConstraintLayout, resulting in its logic is very complex. For certain pages, a "targeted" custom ViewGroup can surpass the ConstraintLayout, because you only need to be responsible for one page, not so comprehensive.

The custom ViewGroup I'm talking about here is to use code to write the layout, not to write a public control for others to use. Telegram The layout is written in code.

So why don't we write layout in code?

  • Customizing the ViewGroup is too complicated. There are a lot of MeasureSpec situations.
  • Every time I write, I forget. I have to check. I forget when I learn
  • The efficiency is too low. I don't need to improve the performance

To sum up, it is because it is difficult and troublesome to customize ViewGroup. In the past, writing code in Java was really troublesome, but now with Kotlin, you can write layout in code gracefully. Next, I'll take you through the process of customizing ViewGroup, and then implement it with Kotlin.

What do I do to customize the ViewGroup

I think we all know the steps of a ViewGroup, which are nothing more than measurement, layout and drawing. In these three steps, measurement is to measure the size of the sub View and then calculate its own size; Layout is to set the position of the View; Drawing is generally unnecessary for the ViewGroup. It is nothing more than drawing something here. It's not very difficult to see that. What's so difficult? I think it's because of the following table:

It refers to various modes during measurement. Many books give this table when talking about custom View. In fact, this is summarized by the author. These things are not available on the Android official website.

Now let's forget the above table and just look at several measurement modes: actual and at_ The three English meanings of most and UNSPECIFIED have been very clear.

  • Exctly is accurate, that is, you can set as many as you want
  • AT_MOST is the maximum number that can be used, that is, how large the sub View is
  • UNSPECIFIED is uncertain, which generally needs to be measured again. For example, linear layout uses weight

UNSPECIFIED is rarely used when we use code to write layout. This can be ignored, so there are only two modes left. To sum up, it is "the actual number of views is how much" and "the maximum number of views can be used". Is it not so complicated. It's estimated that there is no specific concept. Let's start with the code.

How to improve write efficiency with Kotlin

Next, let's use Kotlin's extension method to complete what is needed to customize the ViewGroup step by step.

During measurement, a MeasureSpec object is passed. This object is determined according to the width height Int value and the measurement mode. With Kotlin, can we directly define an extension method for Int to obtain the MeasureSpec of this Int value? Let's see the code:

// Actual measurement mode
fun Int.toExactlyMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)
// AT_MOST measurement mode
fun Int.toAtMostMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)

When we set the width and height of a control, we usually give a specific value, or MATCH_PARENT or WRAP_CONTENT, do we also draw a method for this common situation? With Kotlin, we can directly find an extension method on this View to obtain its default width and height:

// Gets the default measurement of the View width
fun View.defaultWidthMeasureSpec(parent: ViewGroup): Int {
    return when (layoutParams.width) {
        // If MATCH_PARENT means that it needs to fill the parent layout, so give it an accurate value of the width of the parent layout
        MATCH_PARENT -> parent.measuredWidth.toExactlyMeasureSpec()
        // If WRAP_CONTENT means that it meets its own size, so just give it the maximum size it can use
        WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
        // 0 is uncertain. We have a UI draft here, so there is no uncertainty, so we don't need to consider it here
        0 -> throw IllegalAccessException("I don't consider this situation $this")
        // The last is the specific value, so I'll give you the specific value
        else -> layoutParams.width.toExactlyMeasureSpec()
    }
}
// Obtain the default measurement value of View height, which is the same as the principle of obtaining width above
fun View.defaultHeightMeasureSpec(parent: ViewGroup): Int {
    return when (layoutParams.height) {
        MATCH_PARENT -> parent.measuredHeight.toExactlyMeasureSpec()
        WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
        0 -> throw IllegalAccessException("I don't consider this situation $this")
        else -> layoutParams.height.toExactlyMeasureSpec()
    }
}

Well, with these, is it much easier for us to write a custom ViewGroup? We can measure a control and write these directly:

textView.measure(textView.defaultWidthMeasureSpec(this), textView.defaultHeightMeasureSpec(this))

Wait, it's still a little complicated. Why don't we just define an extension method and let the View measure directly according to the default:

fun View.autoMeasure(parent: ViewGroup) {
    measure(
        this.defaultWidthMeasureSpec(parent),
        this.defaultHeightMeasureSpec(parent)
    )
}

In this way, it can be written like this next time:

textView.autoMeasure(this)

Is it simpler? Here, the basic code of measurement is almost finished. By the way, write the basic method of layout. The layout is relatively simple, that is, just tell the location of the sub View.

// Set the location of the view
fun View.autoLayout(parent: ViewGroup, x: Int = 0, y: Int = 0, fromRight: Boolean = false) {
    // Judge whether the layout starts from the right
    if (!fromRight) {
        // Notice why measuredWidth is used instead of width
        // Because the width is calculated by mRight - mLeft, and neither of them is assigned, they are both 0
        layout(x, y, x + measuredWidth, y + measuredHeight)
    } else {
        autoLayout(parent.measuredWidth - x - measuredWidth, y)
    }
}

In fact, we can write these methods into a class, write a custom ViewGroup in the future, and directly inherit it, as follows:

 // To facilitate setting dp sp, the extension attribute is declared here directly
val Int.dp
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP, this.toFloat(), 
        Resources.getSystem().displayMetrics
    ).toInt()
val Float.sp
    get() = TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_SP, this,
        Resources.getSystem().displayMetrics
    )

abstract class CustomViewGroup(context: Context) : ViewGroup(context) {

    // Easy access to width and height with Margin
    protected val View.measuredWidthWithMargins get() = measuredWidth + marginStart + marginEnd
    protected val View.measuredHeightWithMargins get() = measuredHeight + marginTop + marginBottom

    protected fun Int.toExactlyMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)

    protected fun Int.toAtMostMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)

    protected fun View.defaultWidthMeasureSpec(parent: ViewGroup): Int {
        return when (layoutParams.width) {
            MATCH_PARENT -> parent.measuredWidth.toExactlyMeasureSpec()
            WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
            0 -> throw IllegalAccessException("I don't consider this situation $this")
            else -> layoutParams.width.toExactlyMeasureSpec()
        }
    }

    protected fun View.defaultHeightMeasureSpec(parent: ViewGroup): Int {
        return when (layoutParams.height) {
            MATCH_PARENT -> parent.measuredHeight.toExactlyMeasureSpec()
            WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
            0 -> throw IllegalAccessException("I don't consider this situation $this")
            else -> layoutParams.height.toExactlyMeasureSpec()
        }
    }

    protected fun View.autoMeasure() {
        measure(
            this.defaultWidthMeasureSpec(this@CustomViewGroup),
            this.defaultHeightMeasureSpec(this@CustomViewGroup)
        )
    }

    protected fun View.autoLayout(x: Int = 0, y: Int = 0, fromRight: Boolean = false) {
        if (!fromRight) {
            layout(x, y, x + measuredWidth, y + measuredHeight)
        } else {
            autoLayout(this@CustomViewGroup.measuredWidth - x - measuredWidth, y)
        }
    }
}

Try writing a custom ViewGroup

Take the calculator interface as an example. above 👆 It is implemented through ConstraintLayout. See which controls there are, 1 EditText and 17 buttons. Let's try the custom ViewGroup to reproduce it briefly. Go directly to the code:

class CalculatorLayout(context: Context) : CustomViewGroup(context) {
    // We can directly new the control and set some properties, so that we don't need to worry about null pointers and findViewById
    val etResult = AppCompatEditText(context).apply {
        typeface = ResourcesCompat.getFont(context, R.font.comfortaa_regular)
        setTextColor(ResourcesCompat.getColor(resources, R.color.white, null))
        background = null
        textSize = 65f
        gravity = Gravity.BOTTOM or Gravity.END
        maxLines = 1
        isFocusable = false
        isCursorVisible = false
        setPadding(16.dp, paddingTop, 16.dp, paddingBottom)
        layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
        // Note that adding directly here will not trigger onMeasure processes. You can rest assured that adding
        addView(this)
    }
    // Background behind the numeric keypad
    val keyboardBackgroundView = View(context).apply {...}

    // Pull out a button of the same style
    class NumButton(context: Context, text: String, parent: ViewGroup) : AppCompatTextView(context) {
        init {
            setText(text)
            gravity = Gravity.CENTER
            background =
                ResourcesCompat.getDrawable(resources, R.drawable.ripple_cal_btn_num, null)
            layoutParams =
                MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
                    leftMargin = 2.dp
                    rightMargin = 2.dp
                    topMargin = 6.dp
                    bottomMargin = 6.dp
                }
            isClickable = true
            setTextAppearance(context, R.style.StyleCalBtn)
            parent.addView(this)
        }
    }

    // Specific digital buttons
    val btn0 = NumButton(context, "0", this)
    ...

    init {
        // Set yourself a background
        background = ResourcesCompat.getDrawable(resources, R.drawable.shape_cal_bg, null)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // First calculate the size of the number button
        val allSize =
            measuredWidth - keyboardBackgroundView.paddingLeft - keyboardBackgroundView.paddingRight -
                    btn0.marginLeft * 8
        val numBtSize = (allSize * (1 / 3.8)).toInt()
        // Calculate the size of the action button
        val operatorBtWidth = (allSize * (0.8 / 3.8)).toInt()
        val operatorBtHeight = (numBtSize * 4 + btn0.marginTop * 6 - btnDel.marginTop * 8) / 5

        // Then calculate the height of the digital disk
        val keyboardHeight =
            keyboardBackgroundView.paddingTop + keyboardBackgroundView.paddingBottom +
                    numBtSize * 4 + btn0.marginTop * 8

        // Finally, give the remaining space of the height to EditText
        val editTextHeight = measuredHeight - keyboardHeight

        // Measurement background
        keyboardBackgroundView.measure(
            measuredWidth.toExactlyMeasureSpec(),
            keyboardHeight.toExactlyMeasureSpec()
        )

        // Measurement button
        btn0.measure(numBtSize.toExactlyMeasureSpec(), numBtSize.toExactlyMeasureSpec())
        ...
        btnDiv.measure(operatorBtWidth.toExactlyMeasureSpec(), operatorBtHeight.toExactlyMeasureSpec())
        ...

        // Measure EditText
        etResult.measure(
            measuredWidth.toExactlyMeasureSpec(),
            editTextHeight.toExactlyMeasureSpec()
        )

        // Finally, set your own width and height
        setMeasuredDimension(measuredWidth, measuredHeight)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // OK, the measurement is over. Let's put them one by one
        // Put EditText first
        etResult.autoLayout()

        // Put the background on
        keyboardBackgroundView.autoLayout(0, etResult.bottom)

        // Start putting the button
        btn7.let {
            it.autoLayout(
                keyboardBackgroundView.paddingLeft + it.marginLeft,
                keyboardBackgroundView.top + keyboardBackgroundView.paddingTop + it.marginTop
            )
        }
        btn8.let {
            it.autoLayout(
                btn7.right + btn7.marginRight + it.marginLeft,
                btn7.top
            )
        }
        btn9.let {
            it.autoLayout(
                btn8.right + btn8.marginRight + it.marginLeft,
                btn7.top
            )
        }
        ...
    }
}

Ok, the above completes the custom ViewGroup. We can use it directly as follows:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val contentView = CalculatorLayout(this)
    setContentView(contentView)

    // Instead of findViewById, ktx plug-in and ViewBinding, you can use it directly
    contentView.btnDel.setOnClickListener {
        contentView.etResult.setText("")
    }
}

See the final contrast effect:

It looks ok. How about writing the layout in Kotlin code? It's not very complex. The disadvantage is that it can't be previewed on Android Studio.

It's over

Although it is a little troublesome compared with xml writing, I feel almost the same after proficiency. It can also help us become more familiar with custom View. Interested partners can try this writing method first when the project is not busy. Of course, you can also try Compose. In fact, Compose is also a ViewGroup. Take a look at AndroidComposeView, which will eventually be added to DecorView.

Finally, Xiaobian shared one. When I was learning and improving, I collected and sorted out some learning documents, interview questions, Android core notes and other documents related to Android development from the Internet, hoping to help you learn and improve. If you need reference, you can go to me directly CodeChina address: https://codechina.csdn.net/u012165769/Android-T3 Access.

Tags: Android Design Pattern kotlin

Posted on Tue, 23 Nov 2021 16:59:10 -0500 by dotMoe