自定义view⑧布局流程和自定义布局

1,234 阅读6分钟

布局流程

  • 测量流程:从根 View 递归调用每一级子 View 的 measure() 方法,对它们进行 测量
  • 布局流程:从根 View 递归调用每一级子 View 的 layout() 方法,把测量过程得 出的子 View 的位置和尺寸传给子 View,子 View 保存
  • 为什么要分两个流程?
    • 因为可能会重复测量

对于每个View本身

  • 运行前,开发者在 xml 文件里写入对 View 的布局要求 layout_xxx
  • 父 View 在自己的 onMeasure() 中,根据开发者在 xml 中写的对子 View 的要 求,和自己的可用空间,得出对子 View 的具体尺寸要求
  • 子 View 在自己的 onMeasure() 中,根据父 View 的要求和自己的特性算出自己 的期望尺寸
  • 如果是 ViewGroup,还会在这里调用每个子 View 的 measure() 进行测量 父 View - 在子 View 计算出期望尺寸后,得出子 View 的实际尺寸和位置
  • 子 View 在自己的 layout() 方法中,将父 View 传进来的自己的实际尺寸和位置 保存
    • 如果是 ViewGroup,还会在 onLayout() 里调用每个子 View 的 layout() 把 它们的尺寸位置传给它们

对于父布局wrap-content子布局某个是match-parent

下面的代码要进行两次测量

  • 测量两遍,第一遍将match-parent的宽度设置为0
  • 第二遍将宽度设置为其他子布局的最大宽度
37

对于父布局wrap-content子布局都是match-parent

对于下面的代码要测量三遍

  • 第一遍测量宽度都置为0
  • 第二遍根据LinearLayout内部规则逐一设置宽度
  • 第三遍重置宽度
38

xml与onMeasure的冲突问题

如下:

  • 自定义myView重写布局宽高都为100dp
  • xml中我们重写宽高都为match_parent
class MyView extends View{
    onMeasure{
        setMeasureDimension(100,100)
    }
}

LinearLayout中Myview会正常展示100dp宽高

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Myview
        android:layout_width="match_parent"
        android:layout_height="match_parent">

</LinearLayout>

而在Constraint中Myview则填满屏幕

<ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <Myview
        android:layout_width="match_parent"
        android:layout_height="match_parent">

</ConstraintLayout>

所以在开发中我们要尽量避免这样的冲突

自定义布局

  • 继承已有的View,简单改写他们的尺寸:重写onMeasure
    • 如SquareImageView
  • 对自定义view完全进行自定义尺寸计算:重写onMeasure
    • 如CircleView
  • 自定义Layout:重写onMeasure()和onLayout()
    • 如TagLayout
    • 39

onMeasure 改写已有 View 的尺寸

重写layout方法及其问题

如自定义SquareImageView

  1. 取短边作为边长
class SuqareImageView(context: Context?, attrs: AttributeSet?) : AppCompatImageView(context, attrs) {
  override fun layout(l: Int, t: Int, r: Int, b: Int) {
    //取短边为方形边长
    val width = r-l
    val height = b-t;
    val size = min(width,height);
    super.layout(l, t, l+size, t+size)
  }
}
  1. 在布局中使用它,可以正常显示200dp的方形ImageView
    <com.dsh.txlessons.viewcustomize.SuqareImageView
        android:layout_width="300dp"
        android:layout_height="200dp"
        android:src="@mipmap/slmh"/>
        
    <View
        android:layout_width="300dp"
        android:background="@color/colorPrimary"
        android:layout_height="200dp"/>
  1. 但是当我们增加一个布局之后,就变成了如下这样,同级其他布局并没有紧贴SuqareImageView,这是因为重写layout方法之后父布局并不知道子布局尺寸发生了变化,在父布局逐级调用子View的measure方法时,依然认为SuqareImageView的宽度为300dp,所以我们需要重写onMeasure方法
    40
  2. 所以不能重写layout而是要重写onMeasure

重写onMeasure

  • 重写 onMeasure()
  • 用 getMeasuredWidth() 和 getMeasuredHeight() 获取到测量出的尺寸
  • 计算出最终要的尺寸
  • 用 setMeasuredDimension(width, height) 把结果保存
  • 为什么不重写 layout()?
    • 上面有讲,父布局不知道子布局尺寸发生变化
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    val size = min(measuredWidth,measuredHeight);
    setMeasuredDimension(size,size)
  }

效果正常了
41

getMeasureWidth与getWidth
  • 测量过程中使用getMeasureWidth和getMeasureHeight
  • getWidth与getHeight是在布局时才会获取到结果
  • getMeasureWidth可以更早拿到尺寸

onMeasure完全自定义View的尺寸

  • 重写 onMeasure()
  • 计算出自己的尺寸
  • 用 resolveSize() 或者 resolveSizeAndState() 修正结果 使用
  • setMeasuredDimension(width, height) 保存结果
  1. 自定义一个带背景的圆形View

    private val RADIUS = 100.dp;
    private val PADDING = 100.dp;
    class CircleView(context: Context?, attrs: AttributeSet?) : View(context, attrs) {
    
      private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
    
      override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawCircle(PADDING+RADIUS,PADDING+RADIUS,RADIUS,paint)
      }
    
      override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val size = ((PADDING+ RADIUS)*2).toInt();
        setMeasuredDimension(size,size)
      }
    
    }
    

    用wrap-content指定宽高,实际宽高应该是400dp

        <com.dsh.txlessons.viewcustomize.CircleView
            android:layout_width="wrap_content"
            android:background="@color/colorPrimary"
            android:layout_height="wrap_content"/>
    
    42
  2. 使用固定宽高 宽300dp高200dp,没有效果,布局依然是400dp的宽高,这不是我们期待的结果

  3. 所以要继续修改onMeasure代码,通过resolveSize获取新的宽高,调用setMeasuredDimension保存结果

      override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val size = ((PADDING+ RADIUS)*2).toInt();
        //以下被注释的代码,官方为我们提供了API->resolveSize()
    //    val specWidthMode = MeasureSpec.getMode(widthMeasureSpec);
    //    val specWidthSize = MeasureSpec.getSize(widthMeasureSpec);
    //    val width = when(specWidthMode){
    //      MeasureSpec.EXACTLY -> specWidthSize
    //      MeasureSpec.AT_MOST -> if (size>specWidthSize) specWidthSize else size
    //      else -> size
    //    }
        val width = View.resolveSize(size,widthMeasureSpec);
        val height = View.resolveSize(size,heightMeasureSpec);
        setMeasuredDimension(width,height)
    
      }
    

    效果如下,是我们期待的结果了
    43

onMeasure、onLayout自定义Layout

  • 重写onMeasure()
    • 遍历每个子View,测量子View
      • 测量完成后,得出子view的实际位置和尺寸,并暂时保存
      • 有些子View可能需要重新测量
    • 测量出所有子View的位置和尺寸后,计算出自己的尺寸,并用setMeasuredDimension(width,height)保存
  • 重写onLayout()
    • 遍历每个子view,调用他们的layout()方法将位置和尺寸传给他们
  1. 首先写一个随机字体大小和颜色的TextView:ColoredTextView
    private val COLORS = intArrayOf(
      Color.parseColor("#E91E63"),
      Color.parseColor("#673AB7"),
      Color.parseColor("#3F51B5"),
      Color.parseColor("#2196F3"),
      Color.parseColor("#009688"),
      Color.parseColor("#FF9800"),
      Color.parseColor("#FF5722"),
      Color.parseColor("#795548")
    )
    private val TEXT_SIZES = intArrayOf(16, 22, 28)
    private val CORNER_RADIUS = 4.dp
    private val X_PADDING = 16.dp.toInt()
    private val Y_PADDING = 8.dp.toInt()
    
    /**
     * 随机字体大小和颜色的TextView
     */
    class ColoredTextView(context: Context?, attrs: AttributeSet?) : AppCompatTextView(context, attrs) {
      private var paint = Paint(Paint.ANTI_ALIAS_FLAG)
      private val random = Random()
    
      init {
        setTextColor(Color.WHITE)
        textSize = TEXT_SIZES[random.nextInt(3)].toFloat()
        paint.color = COLORS[random.nextInt(COLORS.size)]
        setPadding(X_PADDING, Y_PADDING, X_PADDING, Y_PADDING)
      }
    
      @RequiresApi(VERSION_CODES.LOLLIPOP)
      override fun onDraw(canvas: Canvas) {
        canvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), CORNER_RADIUS, CORNER_RADIUS, paint)
        super.onDraw(canvas)
      }
    }
    
  2. 然后写tagLayout
    • 重写onlayout:遍历每个子view,调用他们的layout()方法将位置和尺寸传给他们
    • 核心是onMeasure
      • 要对每一个子view进行测量,并进行折行处理
      • 最后算出布局需要占用的宽度和高度
      • 将总的宽度和高度通过setMeasuredDimension(width,height)的方式保存
    class TagLayout(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {
    
      private val childrenBounds = mutableListOf<Rect>()
    
      override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        //先遍历所有子view 计算尺寸
        val count: Int = getChildCount()//子view数量
        var widthUsed = 0 //
        var lineWidthUsed = 0 //每行已用的宽度
        var heightUsed = 0
        var lineMaxHeight = 0;
        val specWidthSize = MeasureSpec.getSize(widthMeasureSpec)
        val specWidthMode = MeasureSpec.getMode(widthMeasureSpec)
        for (index in 0 until count) {
          val child: View = getChildAt(index)
    
          //第一次测量(每一个子view)
          measureChildWithMargins(child,widthMeasureSpec,0,heightMeasureSpec,heightUsed)
          //折行处理, 重置下一行参数
          if (specWidthMode!=MeasureSpec.UNSPECIFIED &&
              lineWidthUsed+child.measuredWidth>specWidthSize){
            lineWidthUsed = 0
            heightUsed +=lineMaxHeight
            lineMaxHeight = 0
            measureChildWithMargins(child,widthMeasureSpec,0,heightMeasureSpec,heightUsed)
          }
          //childrenBounds 添加元素
          if (index >= childrenBounds.size){
            childrenBounds.add(Rect())
          }
          val childBounds = childrenBounds[index];
          childBounds.set(lineWidthUsed,heightUsed,lineWidthUsed+child.measuredWidth,heightUsed+child.measuredHeight)
          lineWidthUsed += child.measuredWidth
          //保证tagLayout宽度为最宽那一行的宽度
          widthUsed = Math.max(widthUsed,lineWidthUsed)
          //每一行的最大高度
          lineMaxHeight = Math.max(lineMaxHeight,child.measuredHeight)
        }
    
        //再算出自己的尺寸
        val selfWidth = widthUsed//最宽行的宽度
        val selfHeight = lineMaxHeight+heightUsed//之前所有行的高度加上当前行的最大高度
        setMeasuredDimension(selfWidth,selfHeight)
      }
    
      override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        val count: Int = getChildCount()
        for (index in 0 until count) {
          val view: View = getChildAt(index)
          val childBounds = childrenBounds[index]
          view.layout(childBounds.left,childBounds.top,childBounds.right,childBounds.bottom);
        }
      }
    
      override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context,attrs)
      }
    
    }
    
  3. 在xml中使用
<com.dsh.txlessons.viewcustomize.TagLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.dsh.txlessons.viewcustomize.ColoredTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="北京市" />

    <com.dsh.txlessons.viewcustomize.ColoredTextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="天津市" />
        
    ...
    
 </com.dsh.txlessons.viewcustomize.TagLayout>   
  1. 效果如下
    44