ViewGroup的职能是为childView计算出建议的宽高和测量模式。View的职能是根据父容器建议的宽高计算并绘制出自身形态。
每个ViewGroup都有独特的LayoutParams,用于确定childView支持哪些属性,如LinearLayout的layout_weight属性。在XML布局里,凡是以layout、margin开头的属性,都是针对容器的。如layout_width、layout_gravity等。
有关自定义属性可参考 写一个扩展性强的自定义控件
View的绘制需要经过measure-layout-draw三个过程才能将View绘制出来。measure负责测量view的宽高,layout负责确定View在父容器中位置,draw负责将view绘制在屏幕上。
onMeasure
MeasureSpec是一个32位的int值,里面包含测量模式SpecMode和测量值SpecSize。在onMeasure方法里,父容器为子元素指定了宽、高的MeasureSpec。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
}
View的测量模式
- EXACTLY:表示设置了精确值。如childView设置了宽高为精确值或match_parent。
- AT_MOST:表示childView被限制在一个最大值内(父容器的剩余空间)。如childView设置宽高为 wrap_content。
- UNSPECIFIED:表示childView大小不受限制,不常用。
ViewGroup在onMeasure测量过程中,会遍历子元素的measure方法并获得子元素自我测量值(调用onMeasure),然后根据子元素的测量值计算出自身测量值。即ViewGroup调用setMeasuredDimension()方法测量自身宽高。若widthMode是EXACTLY,则采用widthSize,若heightMode是AT_MOST,则需根据布局方式(横向/纵向)计算出子元素的测量值。
补充知识点:
- 在measureChild方法中,父容器获取子元素的LayoutParams,通过getChildMeasureSpec获得子元素的MeasureSpec。
- View的测量大小是在measure阶段确定的,View的最终大小是在layout阶段确定的。一般情况下(除主动设置layout顶点位置外),View的测量大小和最终大小是相等的。
- 在Activity/View#onWindowFocusChanged,View#post(runnable)方法中获取View的宽高是正确的时机。
onLayout
在View的layout方法中,通过setFrame设置四个顶点的位置。layout方法中会调用onLayout方法用于确定子元素的位置。具体调用稍后请看源码示例。
onDraw
在View的draw方法中,通过dispatchDraw遍历子元素的draw方法。
view的绘制由几步组成:
- 绘制背景 background.draw(canvas)
- 绘制自己(onDraw)
- 绘制children(dispatchDraw)
- 绘制装饰(onDrawScrollBars)
Canvas和Paint
Android中的图形绘制就是在一个view指定的画布Canvas上,绘制一些图片、形状或文本等。相关的类有Canvas(画布)、Paint(画笔)、RetcP(矩形)等。
- Canvas(画布):在被操作的对象(如Bitmap、View)上充当画板,支持绘制形状、位图、文本、图片等。
- Paint(画笔):负责绘制的风格,支持设置颜色、透明度、粗细、抗锯齿、填充效果、文字风格等。
案例1:绘制一个圆饼,标注圆心和文本。
//1,activity的onCreate方法载入自定义view
setContentView(new CustomerView(this));
//2,继承View,重写onDraw获取画布
class CustomerView extends View {
//3,声明圆饼、圆心、文本画笔
Paint paint1, paint2, paint3;
public CustomerView(Context context) {
super(context);
paint1 = new Paint();//圆饼画笔
paint1.setAntiAlias(true);//抗锯齿
paint1.setStrokeWidth(2);//画笔宽度
paint1.setColor(Color.RED);//画笔颜色
paint2 = new Paint();//圆心画笔
paint2.setAntiAlias(true);
paint2.setColor(Color.YELLOW);
paint3 = new Paint();//文本画笔
paint3.setAntiAlias(true);
paint3.setTextSize(30);//文本大小
paint3.setColor(Color.WHITE);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//绘制圆饼
canvas.drawCircle(300, 300, 200, paint1);
//绘制圆心
canvas.drawCircle(300, 300, 10, paint2);
//绘制文本
canvas.drawText("圆心", 320, 310, paint3);
}
}
案例中主要运用了Paint的抗锯齿、画笔颜色、画笔宽度、文本大小等属性。抗锯齿可使图形边缘模糊绘制体现平滑,减少锯齿效果。
Canvas的获取推荐采用重写onDraw方法获取该view指定的画布。案例中运用了绘制圆饼、文本的API。
案例解析
自定义view的注意事项
- 让你的view支持wrap_content;
- 如果有必要,让你的view支持padding,margin;
- 推荐使用post刷新页面,Handler侧重异步消息传递;
- 及时停止线程或动画等资源,避免内存泄漏,时机参考View#onAttachedToWindow,View#onDetachedFromWindow;
- View中带有滑动嵌套情形,要处理好滑动冲突,推荐采用外部拦截法。
自定义View的几种形式
- 继承View并重写onDraw方法;如案例1。
- 继承ViewGroup,重写onMeasure,onLayout方法;
- 继承现有ViewGroup,如LinearLayout,FrameLayout,在现有功能上扩展功能;
在一般的UI交互需求中,继承现有的ViewGroup即可实现效果,也降低了绘制成本。
案例2:支持横向滑动的View,要求有回弹效果。
需求分析:
- 横向滑动的实现,用LinearLayout即可,其已经内置了横向子元素的衡量和位置计算。
- 回弹效果的实现,推荐用OverScroller实现。此处用Scroller实现偏移回弹效果,需计算好回弹的触发时机。
- 滑动冲突?即横向滑动的ViewGroup和内部子元素对事件分别拦截处理。
关于Scroller动画的实现,可查看View属性知识手札
onTouchEvent()处理的任务:
- 绑定速度追踪器,根据速度方向和偏移量确定临近item索引;
- ACTION_MOVE滑动过程中,scrollBy()执行偏移动画,同步校准临近item索引;
- ACTION_UP滑动抬起时,根据索引和scrollX()计算出微调至临近item的偏移量。惯性滑动可参考Scroller.fling();
onInterceptTouchEvent()外部拦截法处理滑动冲突。当横向滑动距离大于纵向滑动距离时,横向ViewGroup拦截事件,记录屏幕操作的坐标并执行横向滑动操作。
完整源码如下:
public class MyView extends LinearLayout {
Scroller scroller;
int childWidth;
VelocityTracker velocityTracker;
int lastTouchX;
int nearlyChildIndex;//偏移对应的最近item索引
int lastInterceptX, lastInterceptY;
int touchSlop;
public MyView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context, attrs, 0);
}
private void init(Context context, AttributeSet attrs, int defStyleAttr) {
scroller = new Scroller(context);
velocityTracker = VelocityTracker.obtain();
touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
if (getChildCount() > 0) {
childWidth = getChildAt(0).getMeasuredWidth();
}
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean isIntercepted = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
isIntercepted = false;
if (!scroller.isFinished()) {
scroller.abortAnimation();
isIntercepted = true;
}
break;
case MotionEvent.ACTION_MOVE:
int offsetX = x - lastInterceptX;
int offsetY = y - lastInterceptY;
//横向滑动大于纵向滑动时 拦截事件
if (Math.abs(offsetX) > Math.abs(offsetY)
&& Math.abs(offsetX) > touchSlop) {
isIntercepted = true;
//记录事件拦截时坐标
lastTouchX = x;
} else {
isIntercepted = false;
}
break;
case MotionEvent.ACTION_UP:
isIntercepted = false;
break;
}
lastInterceptX = x;
lastInterceptY = y;
return isIntercepted;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
velocityTracker.addMovement(event);
int touchX = (int) event.getX();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
//ViewGroup的ACTION_DOWN事件默认不拦截,不在此捕获事件坐标,
// 正确获取时机在ViewGroup开始拦截事件时。
if (!scroller.isFinished()) {
scroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int offsetX = touchX - lastTouchX;
scrollBy(-offsetX, 0); //滑动时偏移
//滑动时同步校准临近child索引
nearlyChildIndex = getScrollX() / childWidth;
break;
case MotionEvent.ACTION_UP:
velocityTracker.computeCurrentVelocity(1000);
int velocityX = (int) velocityTracker.getXVelocity(); //左负右正
//粗调:滑动抬起时,找到最近的item的索引
if (Math.abs(velocityX) >= childWidth / 2) {
nearlyChildIndex = velocityX > 0 ? nearlyChildIndex - 1 : nearlyChildIndex + 1;
} else {
//计算出累计偏移量折算成item宽度个数(余数部分超过半个item宽度则+1,未超过为0)
nearlyChildIndex = (getScrollX() + childWidth / 2) / childWidth;
}
//微优化nearliestchildIndex取值
nearlyChildIndex = Math.max(0, Math.min(nearlyChildIndex, getChildCount() - 1));
//微调:滑动抬起时,偏移策略——1.临近item置左;2.最右item置右
int scrollX;
//当最右边的item完全可见时,最左边的item索引
int resultIndex = getChildCount() - 1 - ScreenUtil.getScreenWidth() / childWidth;
if (nearlyChildIndex >= resultIndex) {//左滑过头时
// 左滑过头时,确保最右边的item可见,强制为偏移在最左边的item索引
nearlyChildIndex = resultIndex;
//左边最近item置左后,确保最右边的item置右,需再偏移的量
int result = childWidth - ScreenUtil.getScreenWidth() % childWidth;
scrollX = nearlyChildIndex * childWidth - getScrollX() + result;
} else {
//微调到最近item,并置左
scrollX = nearlyChildIndex * childWidth - getScrollX();
}
//偏移微调,左正右负
smoothScrollBy(scrollX, 0);
break;
}
velocityTracker.clear();
lastTouchX = touchX;
return super.onTouchEvent(event);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
velocityTracker.recycle();
}
public void smoothScrollBy(int x, int y) {
scroller.startScroll(getScrollX(), getScrollY(), x, y, 500);
invalidate();
}
@Override
public void computeScroll() {
super.computeScroll();
if (scroller.computeScrollOffset()) {
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
}
这是针对LinearLayout扩展的自定义view,以实现横向滑动效果,支持滑动半个childview的微调,支持边界回弹。
也可以针对ViewGroup实现自定义view,这里就需要重写onMeasure和onLayout方法,并分别对子元素测量宽高和位置。
案例3:对案例2中需求,以继承ViewGroup类实现自定义view。
自定义ViewGroup和继承特殊ViewGroup的区别就是需要自己重写onMeasure和onLayout,ViewGroup完成对子元素的衡量和定位。
在onMeasure方法中:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int parentWidth = 0, parentHeight = 0;
int childrenNum = getChildCount();
for (int i = 0; i < childrenNum; i++) {
final View child = getChildAt(i);
if (child == null || child.getVisibility() == GONE) continue;
//测量子元素(含子元素内外间距)
measureChildWithMargins(child, widthMeasureSpec, parentWidth,
heightMeasureSpec, 0);
//根据子元素计算出父容器宽高期望值
parentWidth += child.getMeasuredWidth();
parentHeight = Math.max(child.getMeasuredHeight(), parentHeight);
}
//resloveSize()是api内部对不同测量模式下的测量值的获取方式优化并封装
setMeasuredDimension(resolveSize(parentWidth, widthMeasureSpec),
resolveSize(parentHeight, heightMeasureSpec));
}
setMeasuredDimension(width,height)设置ViewGroup自身的宽高,若ViewGroup的测量模式非MeasureSpec.EXACTLY,则需要遍历并确认子元素宽高值后,才能确定ViewGroup自身的宽高。
onMeasure要支持无子view状态下的衡量,个人推荐如上例直接执行 setMeasuredDimension(resolveSize(0, widthMeasureSpec),resolveSize(0, heightMeasureSpec));
- resolveSize(parentWidth, widthMeasureSpec)是ViewGroup类内部封装的针对不同测量模式下的测量值。
resolveSize最后调用resolveSizeAndState方法,源码如下:
public static int resolveSizeAndState(int size, int measureSpec,
int childMeasuredState) {
final int specMode = MeasureSpec.getMode(measureSpec);
final int specSize = MeasureSpec.getSize(measureSpec);
final int result;
switch (specMode) {
case MeasureSpec.AT_MOST:
if (specSize < size) {
result = specSize | MEASURED_STATE_TOO_SMALL;
} else {
result = size;
}
break;
case MeasureSpec.EXACTLY:
result = specSize;
break;
case MeasureSpec.UNSPECIFIED:
default:
result = size;
}
return result | (childMeasuredState & MEASURED_STATE_MASK);
}
即在MeasureSpec.AT_MOST测量模式下,若期望值size小于该模式下指定值specSize,则采用size;在MeasureSpec.EXACTLY模式下,直接用指定值specSize。
- 上面根据测量模式的测量ViewGroup的宽高,也可如下获得:
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
...
//如果是AT_MOST模式,设置成我们计算的值;如果是EXACTLY模式,设置成父容器指定的值。
setMeasuredDimension((widthMode == MeasureSpec.EXACTLY) ? widthSize : parentWidth,
(heightMode == MeasureSpec.EXACTLY) ? heightSize : parentHeight);
- measureChildWithMargins()方法负责测量子元素宽高(含内外间距),运用这个方法需要配置ViewGroup的LayoutParams,否则会报类型转换异常的问题。
由于该ViewGroup初始化时默认调用含AttributeSet的构造方法,所以推荐采用带AttributeSet的generateLayoutParams方法。
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}
此时无需调用
@Override
protected boolean checkLayoutParams(LayoutParams p) {
return p instanceof MarginLayoutParams;
}
上面是配置ViewGroup自带的LayoutParams,当然也可以自定义LayoutParams属性。
在onLayout方法中:
@Override
protected void onLayout(boolean changed, int left,
int top, int right, int bottom) {
int childrenNum = getChildCount();
int resultLeft = getPaddingLeft();
for (int j = 0; j < childrenNum; j++) {
if (j == 0) {
childWidth = getChildAt(0).getMeasuredWidth();
}
final View child = getChildAt(j);
if (child == null || child.getVisibility() == GONE) continue;
MarginLayoutParams params = (MarginLayoutParams) child.getLayoutParams();
//横向列表 left为累加item宽度和左右间距
left = resultLeft + params.leftMargin;
top = params.topMargin + getPaddingTop();
right = left + child.getMeasuredWidth();
bottom = top + child.getMeasuredHeight();
child.layout(left, top, right, bottom);
resultLeft += params.leftMargin + child.getMeasuredWidth() + params.rightMargin;
}
}
这里主要是通过获取ViewGroup的padding属性和子元素的margin属性,遍历子元素并逐个定位。
resultLeft = resultLeft + 父容器paddingleft+上一个view的左右margin+上一个view的宽度。
view自身的padding在onDraw方法中计算并体现在canvas画布绘制上。
XML布局:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<com.zjrb.sjzsw.widget.MyView
android:layout_width="match_parent"
android:layout_height="@dimen/dp_200"
android:paddingLeft="@dimen/dp_10">
<Button
android:layout_width="@dimen/dp_150"
android:layout_height="match_parent"
android:layout_marginLeft="@dimen/dp_10"
android:layout_marginRight="@dimen/dp_5"
android:background="@color/color_7AD859"
android:text="1" />
<Button
android:id="@+id/button2"
android:layout_width="@dimen/dp_150"
android:layout_height="match_parent"
android:layout_marginTop="@dimen/dp_10"
android:background="@color/color_FF6028"
android:text="2" />
<Button
android:layout_width="@dimen/dp_150"
android:layout_height="match_parent"
android:background="@color/color_DFBC99"
android:text="3" />
<Button
android:layout_width="@dimen/dp_150"
android:layout_height="match_parent"
android:background="@color/color_8666F9"
android:text="4" />
<Button
android:layout_width="@dimen/dp_150"
android:layout_height="match_parent"
android:background="@color/color_37b6ff"
android:text="5" />
</com.zjrb.sjzsw.widget.MyView>
</layout>
最终效果(注意xml中对padding和margin的设置):