手记

View的绘制流程

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的绘制由几步组成:

  1. 绘制背景 background.draw(canvas)

  2. 绘制自己(onDraw)

  3. 绘制children(dispatchDraw)

  4. 绘制装饰(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的几种形式

  1. 继承View并重写onDraw方法;如案例1。

  2. 继承ViewGroup,重写onMeasure,onLayout方法;

  3. 继承现有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


0人推荐
随时随地看视频
慕课网APP