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。
@Overrideprotected 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方法载入自定义viewsetContentView(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方法中:
@Overrideprotected 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的设置):
作者:正规程序员
链接:https://www.jianshu.com/p/54ec543fcb9c