自定义 View 核心要点总结与代码注释
一、核心知识点总结
1. View 的工作流程
- measure:确定 View 的测量宽高(
onMeasure)
- layout:确定 View 的最终位置(
onLayout,对单一 View 无作用)
- draw:将内容绘制到屏幕上(
onDraw)
2. MeasureSpec 说明
EXACTLY:对应 match_parent 或具体数值
AT_MOST:对应 wrap_content
UNSPECIFIED:父容器不对子 View 限制大小(多用于系统内部)
3. 自定义 View 注意事项
- 必须处理
wrap_content:否则其行为等同于 match_parent
- 必须手动处理
padding:在 onDraw 中考虑 padding 值
- 背景影响最小尺寸:
getSuggestedMinimumWidth() 会结合 minWidth 与背景 drawable 的最小宽度
- 推荐使用
resolveSize():统一处理 MeasureSpec 逻辑
4. 自定义 View 分类
- 继承
View:需自行处理 wrap_content 和 padding
- 继承已有控件(如
TextView):无需处理测量和 padding
- 继承
ViewGroup:需实现子 View 的测量与布局
- 组合控件:继承
ViewGroup 封装多个子 View
二、代码文件(带完整注释)
CircleView.kt
package com.shakespace.artofandroid.chapter04
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import com.shakespace.artofandroid.R
/**
* 自定义 CircleView,演示如何正确处理:
* - 自定义属性(circle_color)
* - wrap_content 的默认尺寸
* - padding 的绘制偏移
*/
class CircleView @JvmOverloads constructor(
context: Context?,
private val attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// 默认颜色为红色
var color = Color.RED
// 开启抗锯齿的画笔
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
initView()
}
/**
* 初始化自定义属性
*/
private fun initView() {
// 从 XML 中读取自定义属性
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView)
color = typedArray.getColor(R.styleable.CircleView_circle_color, Color.RED)
// radius 属性被注释,实际使用 view 宽高决定
paint.color = color
typedArray.recycle() // 必须回收
}
/**
* 重写 onMeasure 以支持 wrap_content
* 若不重写,wrap_content 行为等同于 match_parent
*/
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 调用父类无实际作用,但保留良好习惯
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 使用 resolveSize 处理 AT_MOST (wrap_content) 情况
// 默认期望大小为 200px
val measuredWidth = Math.max(
this.suggestedMinimumWidth,
resolveSize(200, widthMeasureSpec)
)
val measuredHeight = Math.max(
this.suggestedMinimumHeight,
resolveSize(200, heightMeasureSpec)
)
// 设置最终测量尺寸
this.setMeasuredDimension(measuredWidth, measuredHeight)
}
/**
* 绘制圆形,正确处理 padding
*/
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
// 减去 padding 得到可绘制区域
val width = width - paddingLeft - paddingRight
val height = height - paddingTop - paddingBottom
// 取可绘制区域的内切圆半径
val radius = Math.min(width, height) / 2
// 圆心位置需加上 paddingLeft / paddingTop
val centerX = (paddingLeft + width / 2).toFloat()
val centerY = (paddingTop + height / 2).toFloat()
canvas?.drawCircle(centerX, centerY, radius.toFloat(), paint)
}
}
三、 .txt 内容
z_memo01_view.txt
1. ViewRoot -- ViewRootImpl
start with performTraversals
performMeasure -- measure -- onMeasure ----- (View) measure
performLayout -- layout --onLayout -----(View ) layout
performDraw -- draw -- onDraw ----- (View ) draw
2. when measure finished , can use getMeasurdWidth/getMeasureHeight to get width/height (usually it is equivalent to final width/height)
3. when layout finished , can use getTop/getBottom/getLeft/getRight to get top/bottom/left/right position
4. MeasureSpec
UNSPECIFIED usually use in system interior
EXACTLY final width/height == SpecSize , match to match_parent or exact value in xml.
AT_MOST width/height <= SpecSize , match to wrap_content
5. ViewRootImpl.java
private static int getRootMeasureSpec(int windowSize, int rootDimension) {
int measureSpec;
switch (rootDimension) {
case ViewGroup.LayoutParams.MATCH_PARENT:
// Window can't resize. Force root view to be windowSize.
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
break;
case ViewGroup.LayoutParams.WRAP_CONTENT:
// Window can resize. Set max size for root view.
measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
break;
default:
// Window wants to be an exact size. Force root view to be that size.
measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
break;
}
return measureSpec;
}
6. for View , it's MeasureSpec depends on Parental MeasureSpec and child's LayoutParams
exact value -- > EXACTLY
match_parent ---> parentSize , if parent is UNSPECIFIED child is 0
wrap_content --->
EXACTLY/AT_MOST --> AT_MOST and not more than parentSize
UNSPECIFIED ----> child is 0
7.
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;getChildMeasureSpec
break;
}
return result;
}
So , Both AT_MOST and EXACTLY , will return specSize in MeasureSpec.
When UNSPECIFIED , size would be getSuggestedMinimumWidth() or getSuggestdMinimumHeight()
protected int getSuggestedMinimumWidth() {
return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}
public int getMinimumWidth() {
final int intrinsicWidth = getIntrinsicWidth();
return intrinsicWidth > 0 ? intrinsicWidth : 0;
}
如果view没有背景, 那么宽度是mMinWidth , 对应属性 android:minWidth 的值,如果没有设置,则为0 。
如果设置了背景,宽度为max(mMinWidth,mBackground.getMinimumWidth())
getMinimumWidth 返回的是drawable的原始宽度,如果没有,返回0。 (ShapeDrawable没有原始宽度,BitmapDrawable有原始宽度)
note : 直接继承View, 需要重写onMeasure处理wrap_content时的自身大小(通常是一个默认size), 否则wrap_content和match_parent相同
solution: set a width/height when wrap_content
e.g:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int size = this.dp2px(150.0F);
this.setMeasuredDimension(
Math.max(this.getSuggestedMinimumWidth(),
resolveSize(size, widthMeasureSpec)),
Math.max(this.getSuggestedMinimumHeight(),
resolveSize(size, heightMeasureSpec)));
}
8.
z_memo02_viewgroup.txt
ViewGroup
1. ViewGroup is a abstract class , it will call measureChildren to measure child 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);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
create childWidthMeasureSpec by getLayoutParams and parentWidthMeasureSpec
getChildMeasureSpec
2. LinearLayout
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (mOrientation == VERTICAL) {
measureVertical(widthMeasureSpec, heightMeasureSpec);
} else {
measureHorizontal(widthMeasureSpec, heightMeasureSpec);
}
}
measureChildBeforeLayout(child, i, widthMeasureSpec, 0,
heightMeasureSpec, usedHeight);
will call child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
use mTotalLength to store the preliminary height
// Add in our padding
mTotalLength += mPaddingTop + mPaddingBottom;
int heightSize = mTotalLength;
// Check against our minimum height
heightSize = Math.max(heightSize, getSuggestedMinimumHeight());
// Reconcile our calculated size with the heightMeasureSpec
int heightSizeAndState = resolveSizeAndState(heightSize, heightMeasureSpec, 0);
heightSize = heightSizeAndState & MEASURED_SIZE_MASK;
LinearLayout will measure itself once children's measurement finished
在Vertical模式下, 如果是match_parent, 则高度为specSize,如果是wrap_conent,则高度是所有子元素高度总和,但不能超过父容器剩余空间
3. 通常在measure完成之后就可以通过getMeasuredWidth/Height来获取测量的宽高,但是某些情况下,系统可能需要多次measure,所以最好在onLayout中再去获取测量的宽高。
4. 在onCreate\onStart\onResume 都无法正确获得测量的宽高,可以使用以下四种方式
Activity/View -- onWindowFocusChanged (会被调用多次)
view.post(runnable)
ViewTreeObserver . onGlobalLayoutListener (调用多次)
view.measure(int widthMeasureSpec,int heightMeasureSpec)
需要根据LayoutParams判断,如果是match_parent 无法测量。
如果是具体值:View.MeasureSpec.makeMeasureSpec(100,View.MeasureSpec.EXACTLY) 得到spec
如果是wrap_content:通过 View.MeasureSpec.makeMeasureSpec((1 shl 30)-1,View.MeasureSpec.AT_MOST) 得到spec
* 1 shl 30 是 1 << 30 的Kotlin写法
5. two incorrect way
View.MeasureSpec.makeMeasureSpec(-1,View.MeasureSpec.UNSPECIFIED)
view.measure(WindowManager.LayoutParams.WRAP_CONTENT,indowManager.LayoutParams.WRAP_CONTEN)
6. Layout
通过setFrame 设定view的四个顶点,初始化mLeft、mRight、mTop、mBottom
再通过onLayout确定子View的位置
在LinearLayout中的VerticalLayout方法
childTop += lp.topMargin;
setChildFrame(child, childLeft, childTop + getLocationOffset(child),
childWidth, childHeight);
childTop会不断增加。
private void setChildFrame(View child, int left, int top, int width, int height) {
child.layout(left, top, left + width, top + height);
}
其中的width和height ,就是子元素的测量宽高。
7. getMeasuredWidth 和 getWidth 区别
1.在默认实现中, 两者是相同的
2.两者的赋值实际不同。
3. 如果重写layout方法
public void layout(int l , int t , int r, int b ){
super.layout(l,t,r+100,b+100)
}
没有实际意义,但是会导致最终宽高比测量宽高大100px
4. 此外,通常测量会进行多次,前几次测量的结果可能不准确。
8. onDraw()
1. draw background background.draw(canvas)
2. draw itself onDraw()
3. draw children dispatchDraw
4. draw decoration onDrawScrollBars
comments in ViewGroup.java
/*
* Draw traversal performs several drawing steps which must be executed
* in the appropriate order:
*
* 1. Draw the background
* 2. If necessary, save the canvas' layers to prepare for fading
* 3. Draw view's content
* 4. Draw children
* 5. If necessary, draw the fading edges and restore layers
* 6. Draw decorations (scrollbars for instance)
*/
9. View has a special method "setWillNotDraw" , if a view not need draw anything , set true.
in default , this flag in View is false , and is true in ViewGroup , so if need draw something in ViewGroup
need set this in false .
public void setWillNotDraw(boolean willNotDraw) {
setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK);
}
z_memo03_custom_view.txt
自定义view分类
1. 继承View重写onDraw()
通常用于实现一些不规则的效果,通过绘制的方式实现。
需要自己支持 wrap_content和padding
2. 继承ViewGroup 派生特殊的Layout
需要处理ViewGroup的测量和布局过程,并同时处理子元素的测量和布局过程。
3. 继承特定的View
用于扩展已有View的功能, 例如TextView, 不需要自己支持wrap_content和padding
4. 继承特定的ViewGroup
当需要将某几种View组合在一起时常用,可以将多个view放在一个ViewGroup中。,不需要处理ViewGroup的测量和布局过程。
自定义View须知
1、支持wrap_content
需要在onMeasure时对wrap_content做处理,给一个最小宽高。
2、如果有必要,需要支持padding
如果不在onDraw时计算padding,那么padding将不起作用。
ViewGroup的measure和layout中也需要考虑padding的影响。
3、 尽量不要在View中使用handler,使用post完全可以替代handler
4、 有动画和线程需要及时停止,
可以在onDetachedFromWindow中操作
此方法对应onAttachedToWindow
当View不可见时也需要及时处理线程和动画。
5、 带有滑动嵌套时,需要处理滑动冲突。