Android自定义控件 | 源码里有宝藏之自动换行控件

简介: 回想一下在作文本上写作的场景,当从左到右写满一行后,会切换到下一行的开头继续写。如果把“作文本”比作容器控件,把“字”比作子控件。Android 原生控件中没有能“自动换行”的容器控件。

回想一下在作文本上写作的场景,当从左到右写满一行后,会切换到下一行的开头继续写。如果把“作文本”比作容器控件,把“字”比作子控件。Android 原生控件中没有能“自动换行”的容器控件,若不断向LinearLayout中添加View,它们会沿着一个方向不断堆叠,即使实际绘制位置已经超出屏幕。

在 GitHub 上找到了一些实现自动换行功能的容器控件,但使用过程中发现略“重”,而且大部分都把“子控件选中状态”和“自动换行”耦合在一起。使得切换选中方式变得困难。“多控件间的选中模式”和“自动换行的容器控件”是两个相互独立的概念,分开实现这两个功能会使得代码可以更加灵活地组合。“多控件间的选中模式”在之前的再也不要和产品经理吵架了——Android自定义控件之单选按钮中有详细的介绍。

本文试着从零开始手写一个带自动换行功能的容器控件。

这是 Android 自定义控件系列文章的第四篇,系列文章目录如下:

  1. Android自定义控件 | View绘制原理(画多大?)
  2. Android自定义控件 | View绘制原理(画在哪?)
  3. Android自定义控件 | View绘制原理(画什么?)
  4. Android自定义控件 | 源码里有宝藏之自动换行控件
  5. Android自定义控件 | 小红点的三种实现(上)
  6. Android自定义控件 | 小红点的三种实现(下)
  7. Android自定义控件 | 小红点的三种实现(终结)

业务场景

自动换行容器控件的典型应用场景是:“动态多选按钮”,即多选按钮的个数和内容是动态变化的,这样就不能把它们写死在布局文件中,而需要动态地调用addView()添加到容器控件中。
效果如下:

自动换行容器控件

点击一下 button 就会调用addView()向容器控件中添加一个 TextView 。

背景知识

如果了解“View绘制原理”中的测量和布局的过程,就能轻而易举地自定义自动换行容器控件。这两个过程的详细介绍可以分别点击View绘制原理——画多大?View绘制原理——画在哪?

简单回顾一下这一系列文章的结论:

  • View 绘制包含三个步骤,依次是测量、布局、绘制。它们分别解决了三个问题:画多大?,画在哪?,画什么?
  • “画多大?”是为了计算出控件本身的宽高占用多少像素。对于容器控件来说就是“以不同方式排列的子控件的总宽高是多少像素。”
  • “画在哪?”是为了计算出控件相对于屏幕左上角的相对位置。对于容器控件来说就是“如何安排每个子控件相对于自己左上角的相对位置”。(当每个控件相对于父控件都有确定的位置时,只要遍历完 View 树,屏幕上所有控件的具体位置都得以确定)
  • 容器控件用于组织若干子控件,所以它的主要工作是敦促所有子控件测量并布局自己,它自己并不需要绘制图案,所以自定义容器控件时不需要关心“画什么?”

重写 onMeasure()

经过上面的分析,自定义自动换行容器控件只需要继承ViewGroup并重写onMeasure()onLayout()

class LineFeedLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {}

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
}

本文将使用 Kotlin 作为开发语音,Kotlin 可读性超高,相信即使没有学习过它也能看懂。。

对于容器控件来说,onMeasure()需要做两件事情:

  1. 敦促所有子控件自己测量自己以确定自身尺寸 。
  2. 计算出容器控件的尺寸。

好在ViewGroup中提供了一个方法来帮助我们完成所有子控件的测量:

public abstract class ViewGroup extends View {

    protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
        final int size = mChildrenCount;
        final View[] children = mChildren;
        //'遍历所有子控件并触发它们自己测量自己'
        for (int i = 0; i < size; ++i) {
            final View child = children[i];
            if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
            }
        }
    }
        
    protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
        final LayoutParams lp = child.getLayoutParams();
        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom, lp.height);
        //'将父控件的约束和子控件的诉求相结合形成宽高两个MeasureSpec,并传递给孩子以指导它测量自己'
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }
}

只有当所有子控件尺寸都确定了,才能知道父控件的尺寸,就好比只有知道了全班每个人的体重,才能知道全班的总体重。所以onMeasure()中应该首先调用measureChildren()

class LineFeedLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        measureChildren(widthMeasureSpec, heightMeasureSpec)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
}

自动换行的容器控件的宽度应该是手动指定的(没有固定宽度何来换行?)。而高度应该将所有控件高度相累加。所以在onMeasure()中需遍历所有的孩子并累加他们的高度:

class LineFeedLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {
    //'横向间距'
    var horizontalGap: Int = 0
    //'纵向间距'
    var verticalGap: Int = 0
    
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        //'获取容器控件高度模式'
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        //'获取容器控件的宽度(即布局文件中指定的宽度)'
        val width = MeasureSpec.getSize(widthMeasureSpec)
        //'定义容器控件的初始高度为0'
        var height = 0
        //'当容器控件的高度被指定为精确的数值'
        if (heightMode == MeasureSpec.EXACTLY) {
            height = MeasureSpec.getSize(heightMeasureSpec)
        } 
        //'手动计算容器控件高度'
        else {
            //'容器控件当前行剩下的空间'
            var remainWidth = width
            //'遍历所有子控件并用自动换行的方式累加其高度'
            (0 until childCount).map { getChildAt(it) }.forEach { child ->
                val lp = child.layoutParams as MarginLayoutParams
                //'当前行已满,在新的一行放置子控件'
                if (isNewLine(lp, child, remainWidth, horizontalGap)) {
                    remainWidth = width - child.measuredWidth
                    //'容器控件新增一行的高度'
                    height += (lp.topMargin + lp.bottomMargin + child.measuredHeight + verticalGap)
                } 
                //'当前行未满,在当前行右侧放置子控件'
                else {
                    //'消耗当前行剩余宽度'
                    remainWidth -= child.measuredWidth
                    if (height == 0) height =
                        (lp.topMargin + lp.bottomMargin + child.measuredHeight + verticalGap)
                }
                //将子控件的左右边距和间隙也考虑在内
                remainWidth -= (lp.leftMargin + lp.rightMargin + horizontalGap)
            }
        }
        //'控件测量的终点,即容器控件的宽高已确定'
        setMeasuredDimension(width, height)
    }

    //'判断是否需要新起一行'
    private fun isNewLine(
        lp: MarginLayoutParams,
        child: View,
        remainWidth: Int,
        horizontalGap: Int
    ): Boolean {
        //'子控件所占宽度'
        val childOccupation = lp.leftMargin + child.measuredWidth + lp.rightMargin
        //'当子控件加上横向间距 > 剩余行空间时则新起一行'
        //'特别地,每一行的最后一个子控件不需要考虑其右边的横向间距,所以附加了第二个条件,当不考虑间距时能让得下就不换行'
        return (childOccupation + horizontalGap > remainWidth) && (childOccupation > remainWidth)
    
}

整个测量算法的目的是确定容器控件的宽度和高度,关键是要维护好当前行剩余空间remainWidth的值。测量过程的终点是View.setMeasuredDimension()的调用,它表示着容器控件尺寸已经有确定值。

重写 onLayout()

在确定了容器控件及其所有子控件的尺寸后,下一步就是确定所有子控件的位置:

class LineFeedLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : ViewGroup(context, attrs, defStyleAttr) {
    //'横向间距'
    var horizontalGap: Int = 0
    //'纵向间距'
    var verticalGap: Int = 0
    
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        //'当前横坐标(相对于容器控件左边界的距离)'
        var left = 0
        //'当前纵坐标(相对于容器控件上边界的距离)'
        var top = 0
        //'上一行底部的纵坐标(相对于容器控件上边界的距离)'
        var lastBottom = 0
        //'遍历所有子控件以确定它们相对于容器控件的位置'
        (0 until childCount).map { getChildAt(it) }.forEach { child ->
            val lp = child.layoutParams as MarginLayoutParams
            //'新起一行'
            if (isNewLine(lp, child, r - l - left, horizontalGap)) {
                left = -lp.leftMargin
                //'更新当前纵坐标'
                top = lastBottom
                //'上一行底部纵坐标置0,表示需要重新被赋值'
                lastBottom = 0
            }
            //'子控件左边界'
            val childLeft = left + lp.leftMargin
            //'子控件上边界'
            val childTop = top + lp.topMargin
            //'确定子控件上下左右边界相对于父控件左上角的距离'
            child.layout(
                childLeft,
                childTop,
                childLeft + child.measuredWidth,
                childTop + child.measuredHeight
            )
            //'更新上一行底部纵坐标'
            if (lastBottom == 0) lastBottom = child.bottom + lp.bottomMargin + verticalGap
            left += child.measuredWidth + lp.leftMargin + lp.rightMargin + horizontalGap
        }
    }
}

子控件的位置使用它上下左右四个点相对于父控件左上角的距离来描述。所以确定所有子控件位置的算法关键是维护好当前插入位置的横纵坐标,每个子控件的位置都是在当前插入位置上加上自己的宽高来确定的。

容器控件调用child.layout()触发子控件定位自己,子控件最终会调用setFrame()以最终确定自己相对于父控件的位置。

public class View {
    public void layout(int l, int t, int r, int b) {
        ...
        //'调用setFrame()'
        boolean changed = isLayoutModeOptical(mParent) ?setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
        ...
    }
    
    protected boolean setFrame(int left, int top, int right, int bottom) {
            ...
            //'为上下左右赋值'
            mLeft = left;
            mTop = top;
            mRight = right;
            mBottom = bottom;
            ...
    }
}

现在就可以像这样来使用LineFeedLayout了:

class MainActivity : AppCompatActivity() {
    private var index = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        //'当点击按钮是动态添加TextView到自动换行容器控件中'
        btnAdd.setOnClickListener {
            //'构建TextView'
            TextView(this).apply {
                text = ”Tag ${index}“
                textSize = 20f
                setBackgroundColor(Color.parseColor(”#888888“))
                gravity = Gravity.CENTER
                setPadding(8, 3, 8, 3)
                setTextColor(Color.parseColor(”#FFFFFF“))
                layoutParams = LinearLayout.LayoutParams(
                    LinearLayout.LayoutParams.WRAP_CONTENT,
                    LinearLayout.LayoutParams.WRAP_CONTENT
                ).apply {
                    rightMargin = 15
                    bottomMargin = 40
                }
            //'将TextView动态添加到容器控件container中'
            }.also { container?.addView(it) }
            index++
        }
    }
}

如果希望子控件之间存在多选、单选、菜单选,这类互斥选中关系,可以将 demo 中的 TextView 替换成 自定义控件Selector,关于该控件的介绍详见再也不要和产品经理吵架了——Android自定义控件之单选按钮

Talk is cheap, show me the code

推荐阅读

这也是读源码长知识系列的第二篇,该系列的特点是将源码中的设计思想运用到真实项目之中,系列文章目录如下:

  1. 读源码长知识 | 更好的RecyclerView点击监听器
  2. Android自定义控件 | 源码里有宝藏之自动换行控件
  3. Android自定义控件 | 小红点的三种实现(下)
  4. 读源码长知识 | 动态扩展类并绑定生命周期的新方式
  5. 读源码长知识 | Android卡顿真的是因为”掉帧“?
目录
相关文章
|
1月前
|
缓存 搜索推荐 Android开发
安卓开发中的自定义控件实践
【10月更文挑战第4天】在安卓开发的海洋中,自定义控件是那片璀璨的星辰。它不仅让应用界面设计变得丰富多彩,还提升了用户体验。本文将带你探索自定义控件的核心概念、实现过程以及优化技巧,让你的应用在众多竞争者中脱颖而出。
|
13天前
|
前端开发 Android开发 UED
安卓应用开发中的自定义控件实践
【10月更文挑战第35天】在移动应用开发中,自定义控件是提升用户体验、增强界面表现力的重要手段。本文将通过一个安卓自定义控件的创建过程,展示如何从零开始构建一个具有交互功能的自定义视图。我们将探索关键概念和步骤,包括继承View类、处理测量与布局、绘制以及事件处理。最终,我们将实现一个简单的圆形进度条,并分析其性能优化。
|
1月前
|
缓存 搜索推荐 Android开发
安卓开发中的自定义控件基础与进阶
【10月更文挑战第5天】在Android应用开发中,自定义控件是提升用户体验和界面个性化的重要手段。本文将通过浅显易懂的语言和实例,引导你了解自定义控件的基本概念、创建流程以及高级应用技巧,帮助你在开发过程中更好地掌握自定义控件的使用和优化。
40 10
|
1月前
|
前端开发 搜索推荐 Android开发
安卓开发中的自定义控件实践
【10月更文挑战第4天】在安卓开发的世界里,自定义控件如同画家的画笔,能够绘制出独一无二的界面。通过掌握自定义控件的绘制技巧,开发者可以突破系统提供的界面元素限制,创造出既符合品牌形象又提供卓越用户体验的应用。本文将引导你了解自定义控件的核心概念,并通过一个简单的例子展示如何实现一个基本的自定义控件,让你的安卓应用在视觉和交互上与众不同。
|
2月前
|
缓存 前端开发 Android开发
安卓应用开发中的自定义控件
【9月更文挑战第28天】在安卓应用开发中,自定义控件是提升用户界面和交互体验的关键。本文通过介绍如何从零开始构建一个自定义控件,旨在帮助开发者理解并掌握自定义控件的创建过程。内容将涵盖设计思路、实现方法以及性能优化,确保开发者能够有效地集成或扩展现有控件功能,打造独特且高效的用户界面。
|
1月前
|
XML 存储 Java
浅谈Android的TextView控件
浅谈Android的TextView控件
32 0
|
2月前
|
XML 编解码 Android开发
安卓开发中的自定义视图控件
【9月更文挑战第14天】在安卓开发中,自定义视图控件是一种高级技巧,它可以让开发者根据项目需求创建出独特的用户界面元素。本文将通过一个简单示例,引导你了解如何在安卓项目中实现自定义视图控件,包括创建自定义控件类、处理绘制逻辑以及响应用户交互。无论你是初学者还是有经验的开发者,这篇文章都会为你提供有价值的见解和技巧。
46 3
|
2月前
|
搜索推荐 Android开发 开发者
探索安卓开发中的自定义控件
【9月更文挑战第5天】在安卓开发的海洋中,自定义控件如同一艘精致的小船,让开发者能够乘风破浪,创造出既独特又高效的用户界面。本文将带你领略自定义控件的魅力,从基础概念到实战应用,一步步深入理解并掌握这一技术。
|
3月前
|
Android开发 UED 开发者
安卓开发中的自定义控件基础
【8月更文挑战第31天】在安卓应用开发过程中,自定义控件是提升用户界面和用户体验的重要手段。本文将通过一个简易的自定义按钮控件示例,介绍如何在安卓中创建和使用自定义控件,包括控件的绘制、事件处理以及与布局的集成。文章旨在帮助初学者理解自定义控件的基本概念,并能够动手实践,为进一步探索安卓UI开发打下坚实的基础。
|
3月前
|
存储 缓存 前端开发
安卓开发中的自定义控件实现及优化策略
【8月更文挑战第31天】在安卓应用的界面设计中,自定义控件是提升用户体验和实现特定功能的关键。本文将引导你理解自定义控件的核心概念,并逐步展示如何创建一个简单的自定义控件,同时分享一些性能优化的技巧。无论你是初学者还是有一定经验的开发者,这篇文章都会让你对自定义控件有更深的认识和应用。