随笔博文

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

2022-12-14 12:38:35 michael007js 265

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

    业务场景

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

    自动换行容器控件

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

    重写 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 可读性超高,相信即使没有学习过它也能看懂。关于 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++
         }
     }
    }



    首页
    关于博主
    我的博客
    搜索