Kotlin 写自定义 ViewGroup

Android 最近推行的 Compose ,有着 Kotlin 的加持,使写 UI 更加方便快速,不用担心布局嵌套,还是声明式 UI,那么 Compose 有这么多好处,原生写法还有 “出路” 吗?

今天给大家分享一种非传统的自定义 ViewGroup 写法,让你对自定义 ViewGroup 不再 “恐惧”,再借助 Kotlin ,我们用原生写法,也可以快速写出无嵌套的布局。

为什么要用自定义 ViewGroup

平时大家写 UI,都直接用 xml 去写布局,或多或少都会注意去避免布局嵌套,自从有了 ConstraintLayout,嵌套的情况就减少了许多,但用 ConstraintLayout 就能达到极致性能吗?整体上相较于其他 Layout ,性能确实有所提升。但对于产品中具体的页面,可能就不会达到极致性能,因为 ConstraintLayout 要考虑的场景太多了,导致其逻辑很复杂,对于确定的页面来说,一个有 “针对性” 的自定义 ViewGroup ,是能够超越 ConstraintLayout 的,因为你只需要对一个页面负责即可,不用考虑那么全。

这里我所说的自定义 ViewGroup 就是用代码去写布局,不是写一个公共的控件让别人去使的那种。Telegram 的布局就全部用代码去写的。

那么我们平时为什么不去用代码写布局呢?

  • 自定义 ViewGroup 太复杂了,什么 MeasureSpec 情况有一堆。
  • 每次写都忘,还得去查,学了就忘
  • 效率太低,我为了那点性能提升,没必要

总结一下,就是因为自定义 ViewGroup 比较难,还费事。以前用 Java 写代码确实会比较麻烦,但现在有了 Kotlin,也可以很优雅的去用代码写布局了。接下来,我就带大家来捋一下自定义 ViewGroup 的流程,然后用 Kotlin 去实现。

自定义 ViewGroup 要做什么

一个 ViewGroup 有哪几步,我想大家都知道,无非就是测量、布局、绘制,就这三步,测量就是把子 View 的大小测量一下,再算一下自己的大小;布局就是设置一下子 View 的位置;绘制对于 ViewGroup 来说一般不需要,无非就是在自己这画点什么东西。这么看也不是很难嘛,那么难的是哪里呢?我想就是因为下面这张表:


就是测量的时候的各种模式,很多书上讲自定义 View 的时候都会给出这张表,其实这是作者自己总结出来的,Android 官网上是没有这些东西的。

现在让我们忘记上面这张表,就只看一下有几种测量模式:EXACTLY、AT_MOST、UNSPECIFIED,这三个英文意思已经很明确了。

  • EXACTLY 就是精确的,就是你设置多少就是多少
  • AT_MOST 就是最多能用多少,就是尽可能满足子 View
  • UNSPECIFIED 就是不确定的,这种一般都是需要再次测量的,就比如 LinearLayout 使用 weight

其中 UNSPECIFIED 对于我们用代码写布局这种情况,几乎就不会用到,这种就可以不用考虑,那么就只剩下两种模式了,总结一下就是 “View 实际多少就是多少” 和 "View 最多能用多少",这么一想,是不是就没那么复杂了。光说大家估计也没有具体的概念,接下来上代码。

如何借助 Kotlin 提升写 ViewGroup 效率

接下来,让我们用 Kotlin 的扩展方法,来一步一步去完成自定义 ViewGroup 需要的东西。

测量的时候要传一个 MeasureSpec 对象,这个对象是根据宽高 Int 值和测量模式确定的,有了 Kotlin ,我们是不是可以直接给 Int 定义一个扩展方法,来获取这个 Int 值的 MeasureSpec 不就行了,来,看代码:

// EXACTLY 的测量模式
fun Int.toExactlyMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)
// AT_MOST 的测量模式
fun Int.toAtMostMeasureSpec() = MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)

我们给一个控件设置宽高,一般都是给个具体的值,要么就是 MATCH_PARENT 或者 WRAP_CONTENT,那么我们是不是也这种常见的情况抽成一个方法呢?有了 Kotlin 我们可以直接在这个 View 上弄个扩展方法,来获取它的默认宽高:

// 获取 View 宽度的默认测量值
fun View.defaultWidthMeasureSpec(parent: ViewGroup): Int {
    return when (layoutParams.width) {
        // 如果是 MATCH_PARENT,就说明它要填满父布局,那就给它一个父布局宽度的精确值呗
        MATCH_PARENT -> parent.measuredWidth.toExactlyMeasureSpec()
        // 如果是 WRAP_CONTENT,就说明它满足自己的大小就行,那就给它最多能用的大小就行了
        WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
        // 0 就是不确定的,这里我们有 UI 稿,就没有不确定的情况了,所以这里就不用考虑了
        0 -> throw IllegalAccessException("我不考虑这种情况 $this")
        // 最后就是具体的值了,那就给你具体的呗
        else -> layoutParams.width.toExactlyMeasureSpec()
    }
}
// 获取 View 高度的默认测量值,和上面获取宽度的原理一样
fun View.defaultHeightMeasureSpec(parent: ViewGroup): Int {
    return when (layoutParams.height) {
        MATCH_PARENT -> parent.measuredHeight.toExactlyMeasureSpec()
        WRAP_CONTENT -> WRAP_CONTENT.toAtMostMeasureSpec()
        0 -> throw IllegalAccessException("我不考虑这种情况 $this")
        else -> layoutParams.height.toExactlyMeasureSpec()
    }
}

好了,有了这些,我们再写自定义 ViewGroup 是不是就简单多了,我们测量一个控件,直接这些写就可以了:

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

等等,这样写还是有点复杂,我们为什么不干脆再定义一个扩展方法,让 View 直接按默认的测量好了:

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

这样下次使用就可以这样写了:

textView.autoMeasure(this)

是不是更简单了,到这,测量的基本代码差不多就写完了,顺便把布局的基础方法也写一下吧,布局就比较简单了,就是告诉子 View 的位置就好了。

// 设置 view 的位置
fun View.autoLayout(parent: ViewGroup, x: Int = 0, y: Int = 0, fromRight: Boolean = false) {
    // 判断布局是不是从右边开始
    if (!fromRight) {
        // 注意这里为什么用 measuredWidth 而不是用 width
        // 因为 width 是通过 mRight - mLeft 计算的,而这时它俩都没有被赋值,所以都是 0
        layout(x, y, x + measuredWidth, y + measuredHeight)
    } else {
        autoLayout(parent.measuredWidth - x - measuredWidth, y)
    }
}

我们其实可以把这些方法都写到一个类,以后写自定义 ViewGroup,直接继承它就可以了,就像下面这样:

 // 为了方便设置 dp sp,直接在这里声明了扩展属性
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) {

    // 方便获取带 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("我不考虑这种情况 $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("我不考虑这种情况 $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)
        }
    }
}

写个自定义 ViewGroup 试试

就以计算器界面为例吧。上面👆是通过 ConstraintLayout 实现的,看看有哪些控件,1 个 EditText,17 个 Button。我们来试试用自定义 ViewGroup 来简单复刻一下,直接上代码吧:

class CalculatorLayout(context: Context) : CustomViewGroup(context) {
    // 我们可以直接这样在把控件 new 出来,设置一些属性,这样我们还省去了 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)
        // 注意,这里直接 add 不会触发 onMeasure 这些流程,可以放心 add
        addView(this)
    }
    // 数字键盘后面的背景
    val keyboardBackgroundView = View(context).apply {...}

    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)
        }
    }
  
    val btn0 = NumButton(context, "0", this)
    ...

    init {
        // 给自己设置个背景
        background = ResourcesCompat.getDrawable(resources, R.drawable.shape_cal_bg, null)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // 先算一下数字按钮的大小
        val allSize =
            measuredWidth - keyboardBackgroundView.paddingLeft - keyboardBackgroundView.paddingRight -
                    btn0.marginLeft * 8
        val numBtSize = (allSize * (1 / 3.8)).toInt()
        // 计算操作按钮的大小
        val operatorBtWidth = (allSize * (0.8 / 3.8)).toInt()
        val operatorBtHeight = (numBtSize * 4 + btn0.marginTop * 6 - btnDel.marginTop * 8) / 5

        // 再计算数字盘的高度
        val keyboardHeight =
            keyboardBackgroundView.paddingTop + keyboardBackgroundView.paddingBottom +
                    numBtSize * 4 + btn0.marginTop * 8

        // 最后把高度剩余空间都给 EditText
        val editTextHeight = measuredHeight - keyboardHeight

        // 测量背景
        keyboardBackgroundView.measure(
            measuredWidth.toExactlyMeasureSpec(),
            keyboardHeight.toExactlyMeasureSpec()
        )

        // 测量按钮
        btn0.measure(numBtSize.toExactlyMeasureSpec(), numBtSize.toExactlyMeasureSpec())
        ...
        btnDiv.measure(operatorBtWidth.toExactlyMeasureSpec(), operatorBtHeight.toExactlyMeasureSpec())
        ...

        // 测量 EditText
        etResult.measure(
            measuredWidth.toExactlyMeasureSpec(),
            editTextHeight.toExactlyMeasureSpec()
        )

        // 最后设置自己的宽高
        setMeasuredDimension(measuredWidth, measuredHeight)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        // 好了,测量都完了,就一个个放吧
        // 先放 EditText
        etResult.autoLayout()

        // 把背景放上
        keyboardBackgroundView.autoLayout(0, etResult.bottom)

        // 开始放按钮吧
        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,以上就完成了自定义 ViewGroup,我们可以直接这样用了:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val contentView = CalculatorLayout(this)
    setContentView(contentView)
    
    // 不用 findViewById 和 ktx插件、ViewBinding 这些东西,直接用就可以
    contentView.btnDel.setOnClickListener {
        contentView.etResult.setText("")
    }
}

看看最终的对比效果:

看上去还算可以,怎么样,是不是用 Kotlin 代码写布局也不是很复杂,缺点就是不能在 Android Studio 上预览(其实是可以显示预览框的,只要在自定义 View 的构造方法加上 AttributeSet 参数就可以了,不过不一定能显示成功,反正我这预览的还是灰色的)。

结束了

虽然与 xml 的书写相比,确实有些麻烦,但熟练之后,我感觉都差不多,还能帮助我们对自定义 View 这块更加熟悉,感兴趣的小伙伴可以在项目不忙的时候先试试这种写法。当然,也可以试试 Compose,其实 Compose 最终也是一个 ViewGroup ,可以看看 AndroidComposeView ,它最终也是会添加到 DecorView 上。

最后感谢纯纯写作的作者 drakeet 大神的分享。

Demo 地址:github.com/IAn2018cs/C…

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容