Android自定义View系列:布局自定义

View 的整体绘制流程

View 的绘制都是由 ViewRootImpl 完成,Window 和 DecorView 通过 ViewRootImpl 关联。具体绘制流程可以详细参考文章:View绘制流程源码解析

View绘制的工作流程

MeasureSpec

获取测量大小和测量模式

在布局过程中,经常会使用到 MeasureSpec 根据 onMeasure(int widthMeasureSpec, int heightMeasureSpec) 提供的两个参数 widthMeasureSpec 和 heightMeasureSpec 获取测量的宽高和测量模式,或者生成 widthMeasureSpec 和 heightMeasureSpec。

其中,widthMeasureSpec 和 heightMeasureSpec 是一个 32 位的 int 值,高 2 位是测量模式 specMode,低 30 位是测量大小 specSize。

宽高和测量模式使用如下方法获取和设置:

  • MeasureSpec.getMode(measureSpec):获取测量模式 specMode

  • MeasureSpec.getSize(measureSpec):获取测量大小 specSize

  • MeasureSpec.makeMeasureSpec(int size, int mode):根据自己设置的测量大小和测量模式生成一个 widthMeasureSpec 或 heightMeasureSpec

三种测量模式

  • EXACTLY:精确值模式,对应将控件的 layout_width 属性或 layout_height 属性指定为具体数值时,比如 android:layout_width=”100dp”,或者指定为 match_parent(View 类的 onMeasure() 默认的模式,所以需要指定 wrap_content 就要重写 onMeasure()

  • AT_MOST:最大值模式,对应将控件的 layout_width 属性或 layout_height 属性指定为 wrap_content 时,此时控件的最大尺寸只要不超过父控件允许的最大尺寸即可(即能有多大就给多大)

  • UNSPECIFIED:未指定模式,它主要用于在需要多次测量的场景中作为标记的一种测量模式。平常很少用到,甚至你可以几乎当它不存在

布局过程

布局过程的含义

布局过程,就是程序在运行时利用布局文件的代码来计算出实际尺寸的过程。

布局过程的工作内容

两个阶段:测量阶段和布局阶段。

  • 测量阶段:从上到下递归地调用每个 View 或者 ViewGroup 的 measure(),测量它们的尺寸并计算它们的位置

  • 布局阶段:从上到下调用每个 View 或者 ViewGroup 的 layout(),把测得的它们的尺寸和位置赋值给它们

View 或 ViewGroup 的布局过程

  • 测量阶段:measure() 被父 View 调用,在 measure() 中做一些准备和优化工作后,调用 onMeasure() 来进行实际的自我测量。onMeasure() 做的事,View 和 ViewGroup 不一样:

    • View:View 在 onMeasure() 中会计算出自己的尺寸然后保存

    • ViewGroup:ViewGroup 在 onMeasure() 中会调用所有子 View 的 measure() 让它们进行自我测量,并根据子 View 计算出期望尺寸来计算出它们的实际尺寸和位置(实际上99%的父 View 都会使用子 View 绘制出的期望尺寸来作为实际尺寸)然后保存。同时,它也会根据子 View 的尺寸和位置来计算出自己的尺寸然后保存

在这里插入图片描述

  • 布局阶段:layout() 被父 View 调用,在 layout() 中它会保存父 View 传进来的自己的位置和尺寸,并且调用 onLayout() 来进行实际的内部布局。onLayout() 做的事,View 和 ViewGroup 也不一样:

    • View:由于没有子 View,所以 View 的 onLayout() 什么也不做

    • ViewGroup:ViewGroup 在 onLayout() 中会调用自己的所有子 View 的 layout(),把它们的尺寸和位置传给它们,让它们完成自我的内部布局

在这里插入图片描述
所以测量和布局阶段的整体流程如下图:
在这里插入图片描述

布局过程自定义的方式

有三种自定义方式:

  • 重写 onMeasure() 来修改已有的 View 的尺寸

  • 重写 onMeasure() 来全新定制自定义 View 的尺寸

  • 重写 onMeasure()onLayout() 来全新定制自定义 ViewGroup 的内部布局

重写 onMeasure 修改已有的 View 的尺寸

  • 重写 onMeasure(),并在里面调用 super.onMeasure(),触发原有的自我测量

  • super.onMeasure() 的下面用 getMeasureWidth()getMeasureHeight() 来获取之前的测量结果,并使用自己的算法,根据测量结果计算出新的结果

  • 调用 setMeasureDimension() 来保存新的结果

public class SquareImageView extends ImageView {
	...

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// 先执行原测量算法
		// super.onMeasure() 也是调用了 setMeasureDimension()
		super.onMeasure(widthMeasureSpec, heightMeasureSpec); 

		// 获取原先的测量结果
		int measureWidth = getMeasureWidth();
		int measureHeight = getMeasureHeight();

		// 利用原先的测量结果计算出尺寸
		int size = Math.min(measureWidth, measureHeight);

		// 保存计算后的结果
		setMeasureDimension(size, size);
	}
}

如果重写 layout(),你会发现也是可以实现相同的效果:

public class SquareImageView extends ImageView {
	...
	
	@Override
	public void layout(int l, int t, int r, int b) {
		int width = r - l;
		int height = b - t;
		int size = Math.min(width, height);
		super.layout(l, t, l + size, t + size);
	}
}

但是我们为什么不在这里写,要在 onMeasure() 呢?

因为在 onMeasure() 的时候保存的测量尺寸在父 View 是可知的,父 View 会根据你测量的尺寸来布局你的位置;但是在 layout() 的时候修改大小父 View 就是不可知的了,这会导致当父 View 内有多个子 View,你在 layout() 又重写了自己的大小,而父 View 仍旧按照你在 onMeasure() 时测量的尺寸来摆放你的位置,就会出现布局错误。

重写 onMeasure() 来全新定制自定义 View 的尺寸

  • 重写 onMeasure() 把尺寸计算出来

  • 计算出自己的尺寸

  • resolveSize() 或者 resolveSizeAndState() 修正结果

  • 使用 setMeasuredDimension() 保存结果

public class CircleView extends View {
	private static final float RADIUS = Utils.dpToPx(80);
	private static final float PADDING = Utils.dpToPx(30);

	Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);

	@Override
	protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
		// 自己定义的 View 宽高
		int size = (int) (PADDING + RADIUS) * 2);
		// 将子 View 提供的宽高大小修正,让宽高符合父 View 的要求
		// 父 View 的宽高要求就在 widthMeasureSpec 和 heightMeasureSpec
		int width = resolveSize(size, widthMeasureSpec);
		int height = resolveSize(size, heightMeasureSpec);

		setMeasureDimension(width , height);
	}

	@Override
	protected void onDraw(Canvas canvas) {
		super.onDraw(canvas);

		canvas.drawColor(Color.RED);
		canvas.drawCircle(PADDING + RADIUS, PADDING + RADIUS, RADIUS, paint);
	}
}

onMeasure() 的 widthMeasureSpec 和 heightMeasureSpec 有特殊的含义:

从上面我们知道,子 View 的测量是父 View 发起 measure() 然后让子 View 从上到下递归自测量,而 widthMeasureSpec 和 heightMeasureSpec 就是父 View 提供给子 View 的一个测量宽高最大限度

widthMeasureSpec 和 heightMeasureSpec 对于子 View 来说,就是处理自己在xml布局设置的 layout_width 和 layout_height。

自定义子 View 最主要的就是处理 wrap_content,也就是 MeasureSpec.AT_MOST 的情况。在 MeasureSpec.AT_MOST 情况将自己想要的大小和父 View 限定给我们的大小对比(父 View 的大小通过 onMeasure() 提供的 widthMeasureSpec 或 heightMeasureSpec 解析出来 int specSize = MeasureSpec.getSize(measureSpec)),如果想要的大小超过父 view 的大小就使用限定大小 specSize,否则用我们自己的大小。

其实上面的说明就是 resolveSize() 做的事情,可能看了上面描述比较难理解,再对比下面的伪代码相信就能看懂:

public static int resolveSize(int size, int measureSpec) {
	final int specMode = MeasureSpec.getMode(measureSpec);
	// 父 View 限定的最大可用大小
	final int specSize = MeasureSpec.getSize(measureSpec);

	switch(specMode) {
		// 父 View 提供的 specMode 为 MeasureSpec.AT_MOST,需要特殊处理
		case MeasureSpec.AT_MOST:
			// 如果子 View 想要的大小比父 View 提供最大可用大小要小
			// 父 View 能满足,就提供给子 View 想要的大小
			if (size <= specSize) {
				return size;
			} 
			// 否则使用父 View 提供的最大可用大小
			else {
				return specSize;
			}
		// 父 View 提供的 specMode 为精确值,直接返回父 View 提供的大小
		case MeasureSpec.EXACTLY:
			return specSize;
		default:
			return size;
	}
}
}

问题:getWidth()getHeight()getMeasureWidth()getMeasureHeight() 的区别?

getWidth()getHeight() 是确定了 View 的最终宽高,形成于 layout 过程;getMeasureWidth()getMeasureHeight() 是确定了 View 的测量宽高,形成于 measure 过程。

一般情况下最终宽高和测量宽高几乎相同。除非重写了 layout() 导致最终宽高与测量宽高不同。

定制 Layout 的内部布局

步骤:

  • 重写 onMeasure()

    • 遍历每个子 View,用 measureChildWidthMargins() 测量子 View

      • 重写 generateLayoutParams() 提供 MarginLayoutParams 对象

      • 有些子 View 可能需要重新测量

      • 测量完毕后,得出子 View 的实际位置和尺寸,并暂时保存

    • 测量出所有子 View 的位置和尺寸后,计算出自己的尺寸,并用 setMeasureDimension() 保存

  • 重写 onLayout()

    • 遍历每个子 View,调用它们的 layout() 来将位置和尺寸传给它们
public class TagLayout extends ViewGroup {
    private List<Rect> childRectBounds = new ArrayList<>();

    public TagLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthUsed = 0; // 已使用父 View 的宽度,按最大的子 View 宽度来就行了
        int heightUsed = 0; // 已使用父 View 的高度,每一行最大子 View 高度的叠加和
        int lineWidthUsed = 0; // 当前行的已用宽度
        int lineHeight = 0; // 当前行最大子 View 的高度
        
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);

            // 根据父 View 的 MeasureSpec 限定大小以及子 View 的 LayoutParams 共同决定去测量子 View 的大小
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);

            // 父 View 的 MeasureSpec 不能是 UNSPECIFIED 无限大,当前已用宽度+下一个测量子View的宽度 > 父View的限定宽度,换行
            if (widthMode != MeasureSpec.UNSPECIFIED && ((lineWidthUsed + child.getMeasuredWidth()) > widthSize)) {
                // 当前行的已用宽度置0
                lineWidthUsed = 0;
                // 已使用父 View 的高度增加为当前行的最大子 View 的高度
                heightUsed += lineHeight;
                // 重新计算
                measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);
            }

            // 测量后保存子 View 的摆放位置,每一行因子 View 标签文本不同而摆放数量不同
            Rect childRect;
            if (childRectBounds.size() <= i) {
                childRect = new Rect();
                childRectBounds.add(childRect);
            } else {
                childRect = childRectBounds.get(i);
            }
            // left:添加一个子 View 后,上一个子 View 的宽度就是下一个子 View 的起始位置
            // top:其实就是每一行最大的 View 高度作为下一行的起始高度
            // right:left + 当前 View 的宽度
            // bottom:top + 当前 View 的高度
            childRect.set(lineWidthUsed, heightUsed, lineWidthUsed + child.getMeasuredWidth(),
                    heightUsed + child.getMeasuredHeight());

			// 该行添加子 View 的宽度方便下一个子 View 的位置摆放计算
            lineWidthUsed += child.getMeasuredWidth(); 
            
            widthUsed = Math.max(lineWidthUsed, widthUsed);
            
			// 该行最大子 View 高度,换行时使用
            lineHeight = Math.max(lineHeight, child.getMeasuredHeight()); 
        }

        int measureWidth = widthUsed;
        heightUsed += lineHeight;
        int measureHeight = heightUsed;
        setMeasuredDimension(measureWidth, measureHeight);
    }

    // 使用使用 measureChildWithMargins() 时,内部会获取到 childView 的 LayoutParams 并将它强转为 MarginLayoutParams
    // 需要使用该方法转换,否则抛出 ClassCastException
    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new MarginLayoutParams(getContext(), attrs);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        // 只需要根据测量的结果循环摆放子 View 的位置即可
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            Rect rect = childRectBounds.get(i);
            child.layout(rect.left, rect.top, rect.right, rect.bottom);
        }
    }
}

关于保存子 View 位置的两点说明:

  • 不是所有的 Layout 都需要保存子 View 的位置(因为有的 Layout 可以在布局阶段实时推导出子 View 的位置,例如 LinearLayout)

  • 有时候对某些子 View 需要重复测量两次或多次才能得到正确的尺寸和位置

上面的示例代码有一个方法需要特别留意:measureChildWithMargins(),该方法是根据父 View 的 MeasureSpec 限定大小以及子 View 的 LayoutParams 共同决定测出子 View 的大小。

那具体是怎样决定的呢?(为了看起来更简洁,代码将 padding、margin 和 MeasureSpec.UNSPECIFIED 去除了,需要具体了解怎么处理的可以查看源码)

protected void measureChildWithMargins(
		View child, 
		int parentWidthMeasureSpec, 
		int parentHeightMeasureSpec) {
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, lp.height);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

// childDimension 就是子 View 提供的 layout_width 或 layout_height
public static int getChildMeasureSpec(int spec, int childDimension) {
    int specMode = MeasureSpec.getMode(spec);
    int specSize = MeasureSpec.getSize(spec);

    int size = Math.max(0, specSize);

    int resultSize = 0;
    int resultMode = 0;

    switch (specMode) {
	    // Parent has imposed an exact size on us
	    case MeasureSpec.EXACTLY:
	        if (childDimension >= 0) {
	            resultSize = childDimension;
	            resultMode = MeasureSpec.EXACTLY;
	        } else if (childDimension == LayoutParams.MATCH_PARENT) {
	            // Child wants to be our size. So be it.
	            resultSize = size;
	            resultMode = MeasureSpec.EXACTLY;
	        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
	            // Child wants to determine its own size. It can't be
	            // bigger than us.
	            resultSize = size;
	            resultMode = MeasureSpec.AT_MOST;
	        }
	        break;
	
	    // Parent has imposed a maximum size on us
	    case MeasureSpec.AT_MOST:
	        if (childDimension >= 0) {
	            // Child wants a specific size... so be it
	            resultSize = childDimension;
	            resultMode = MeasureSpec.EXACTLY;
	        } else if (childDimension == LayoutParams.MATCH_PARENT) {
	            // Child wants to be our size, but our size is not fixed.
	            // Constrain child to not be bigger than us.
	            resultSize = size;
	            resultMode = MeasureSpec.AT_MOST;
	        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
	            // Child wants to determine its own size. It can't be
	            // bigger than us.
	            resultSize = size;
	            resultMode = MeasureSpec.AT_MOST;
	        }
	        break;
    }
    //noinspection ResourceType
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

measureChildWithMargins() 做的事情可以如下理解(specSize 为限定子 View 可用的最大大小):

  • 父 View 的 specMode 是 MeasureSpec.EXACTLY:

    • 子 View 是具体数值,返回具体数值,测量模式 MeasureSpec.EXACTLY

    • 子 View 是 match_parent,返回 specSize,测量模式 MeasureSpec.EXACTLY(父 View 多大我就多大

    • 子 View 是 wrap_content,返回 specSize,测量模式 MeasureSpec.AT_MOST(只要不超过父 View 的限定大小即可

  • 父 View 的 specMode 是 MeasureSpec.AT_MOST:

    • 子 View 是具体数值,返回具体数值,测量模式 MeasureSpec.EXACTLY

    • 子 View 是 match_parent,返回 specSize,测量模式 MeasureSpec.AT_MOST(此时父 View 的大小也没固定,只要不超过父 View 的限定大小即可

    • 子 View 是 wrap_content,返回 specSize,测量模式 MeasureSpec.AT_MOST(只要不超过父 View 的限定大小即可

上面虽然罗列了具体的处理,能否再简化更好理解呢?接着看:

class MyLayout(context: Context) : CustomLayout(context) {
	val header = AppCompatImageView(context).apply {
		...
	}
	
	override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
		super.onMeasure(widthMeasureSpec, heightMeasureSpec)
		header.autoMeasure()
	}
}

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

	fun View.autoMeasure() {
		measure(
			this.defaultWidthMeasureSpec(parentView = this@CustomLayout),
			this.defaultHeightMeasureSpec(parentView = this@CustomLayout)
		)
	}

	fun View.defaultWidthMeasureSpec(parentView: ViewGroup) : Int {
		return when (layoutParams.width) {
			// 父 View 多大我就多大,那子 View 的大小就是父 View 的测量大小
			// 测量模式是 MeasureSpec.EXACTLY
			ViewGroup.LayoutParams.MATCH_PARENT -> parentView.measuredWidth.toExactlyMeasureSpec()
			// 要自适应大小,可以提供可用的最大大小
			// 测量模式是 MeasureSpec.AT_MOST
			ViewGroup.LayoutParams.WRAP_CONTENT -> parentView.measuredWidth.toAtMostMeasureSpec()
			// 具体数值,直接返回
			else -> layoutParams.width.toExactlyMeasureSpec()
		}
	}

	fun View.defaultHeightMeasureSpec(parentView: ViewGroup) : Int {
		return when (layoutParams.height) {
			ViewGroup.LayoutParams.MATCH_PARENT -> parentView.measuredHeight.toExactlyMeasureSpec()
			ViewGroup.LayoutParams.WRAP_CONTENT -> parentView.measuredHeight.toAtMostMeasureSpec()
			else -> layoutParams.height.toExactlyMeasureSpec()
		}
	}

	fun Int.toExactlyMeasureSpec() : Int {
		return MeasureSpec.makeMeasureSpec(this, MeasureSpec.EXACTLY)
	}
	
	fun Int.toAtMostMeasureSpec() : Int {
		return MeasureSpec.makeMeasureSpec(this, MeasureSpec.AT_MOST)
	}
}

站在子 View 的角度理解测量会比较好理解一些了:

  • 子 View 是 match_parent,返回的就是父 View 的大小,测量模式是 MeasureSpec.EXACTLY(父 View 多大我就多大

  • 子 View 是 wrap_content,测量模式是 MeasureSpec.AT_MOST

  • 子 View 是具体数值,返回具体数值

或许你会有疑惑?为什么子 View 是 wrap_content 时没有说测量的大小?因为当子 View 的 LayoutParams 是 wrap_content 且测量模式是 MeasureSpec.AT_MOST 时,子 View 大多会完全忽略 MeasureSpec 提供的 size,也就是说,这个 size 数值是多少都不影响测量。

fun View.defaultWidthMeasureSpec(parentView: ViewGroup) : Int {
	return when (layoutParams.width) {
		// 将测量的 size 替换为 ViewGroup.LayoutParams.WRAP_CONTENT 也不影响子 View 的测量
		ViewGroup.LayoutParams.WRAP_CONTENT -> ViewGroup.LayoutParams.WRAP_CONTENT.toAtMostMeasureSpec()
	}
}

自定义 View 注意事项

  • 让 View 支持 wrap_content:如果是直接继承 View 或 ViewGroup,且自定义 View 的宽高有 wrap_content,需要重写 onMeasure() 进行处理

  • 如果有必要,让自定义 View 支持 padding:如果是直接继承 View,要在 onDraw() 中处理padding(margin 在父容器会处理),否则无法起作用;如果是直接继承 ViewGroup,要在 onMeasure()onLayout() 中处理 padding 和 margin,否则无法起作用;getPaddingLeft()getPaddingTop()getPaddingRight()getPaddingBottom() 获取 padding

  • 尽量不要在自定义 View 中使用 Handler,没必要:View 内部提供了 post() 可以替代 Handler 的作用,除非明确需要 Handler 发送消息

  • 自定义 View 中如果有线程或动画,要及时停止:如果自定义 View 中有线程或动画,在 onDetachedFromWindow() 停止线程或动画(Activity 退出或当前 View 被 remove 时调用,onAttachedToWindow() 相反);不及时处理容易造成内存泄漏

  • 自定义 View 带有滑动嵌套情形时,要处理滑动冲突

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值