Android 自定义View之Layout过程

作者: 小鱼人爱编程 | 来源:发表于2020-08-25 23:46 被阅读0次

    前言

    在上篇文章:Android 自定义View之Measure过程,我们分析了Measure过程,本次将会掀开承上启下的Layout过程神秘面纱,
    通过本篇文章,你将了解到:

    1、关于Layout 简单类比
    2、一个简单Demo
    3、View Layout过程
    4、ViewGroup Layout过程
    5、View/ViewGroup 常用方法分析
    6、为什么说Layout是承上启下的作用

    关于Layout 简单类比

    在上篇文章的比喻里,我们说过:

    老王给三个儿子,大王(大王儿子:小小王)、二王、三王分配了具体的良田面积,三个儿子(小小王)也都确认了自己的需要的良田面积。这就是:Measure过程
    既然知道了分配给各个儿孙的良田大小,那他们到底分到哪一块呢,是靠边、还是中间、还是其它位置呢?先分给谁呢?
    老王想按到这个家的时间先后顺序来吧(对应addView 顺序),大王是自己的长子,先分配给他,于是从最左侧开始,划出3亩田给大王。现在轮到二王了,由于大王已经分配了左侧的3亩,那么给二王的5亩地只能从大王右侧开始划分,最后剩下的就分给三王。这就是:ViewGroup onLayout 过程
    大王拿到老王给自己指定的良田的边界,将这个边界(左、上、右、下)坐标记录下来。这就是:View Layout过程
    接着大王告诉自己的儿子小小王:你爹有点私心啊,从爷爷那继承的5亩田地不能全分给你,我留一些养老。这就是设置:padding 过程
    如果二王在最开始测量的时候就想:我不想和大王、三王的田离得太近,那么老王就会给大王、三王与二王的土地之间留点缝隙。这就是设置:margin 过程

    一个简单Demo

    自定义ViewGroup

    public class MyViewGroup extends ViewGroup {
        public MyViewGroup(Context context) {
            super(context);
        }
    
        public MyViewGroup(Context context, AttributeSet attrs) {
            super(context, attrs);
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            int usedWidth = 0;
            int maxHeight = 0;
            int childState = 0;
    
            //测量子布局
            for (int i = 0; i < getChildCount(); i++) {
                View childView = getChildAt(i);
                MarginLayoutParams layoutParams = (MarginLayoutParams) childView.getLayoutParams();
                measureChildWithMargins(childView, widthMeasureSpec, usedWidth, heightMeasureSpec, 0);
                usedWidth += layoutParams.leftMargin + layoutParams.rightMargin + childView.getMeasuredWidth();
                maxHeight = Math.max(maxHeight, layoutParams.topMargin + layoutParams.bottomMargin + childView.getMeasuredHeight());
                childState = combineMeasuredStates(childState, childView.getMeasuredState());
            }
    
            //统计子布局水平,记录尺寸值
            usedWidth += getPaddingLeft() + getPaddingRight();
            maxHeight += getPaddingTop() + getPaddingBottom();
            setMeasuredDimension(resolveSizeAndState(usedWidth, widthMeasureSpec, childState),
                    resolveSizeAndState(maxHeight, heightMeasureSpec, childState << MEASURED_HEIGHT_STATE_SHIFT));
        }
    
        @Override
        protected void onLayout(boolean changed, int l, int t, int r, int b) {
            //父布局传递进来的位置信息
            int parentLeft = getPaddingLeft();
            int left = 0;
            int top = 0;
            int right = 0;
            int bottom = 0;
    
            //遍历子布局
            for (int i = 0; i < getChildCount(); i++) {
                View childView = getChildAt(i);
                MarginLayoutParams layoutParams = (MarginLayoutParams) childView.getLayoutParams();
                left = parentLeft + layoutParams.leftMargin;
                right = left + childView.getMeasuredWidth();
                top = getPaddingTop() + layoutParams.topMargin;
                bottom = top + childView.getMeasuredHeight();
                //子布局摆放
                childView.layout(left, top, right, bottom);
                //横向摆放
                parentLeft += right;
            }
        }
    
        @Override
        public LayoutParams generateLayoutParams(AttributeSet attrs) {
            return new MyLayoutParam(getContext(), attrs);
        }
    
        //自定义LayoutParams
        static class MyLayoutParam extends MarginLayoutParams {
            public MyLayoutParam(Context c, AttributeSet attrs) {
                super(c, attrs);
            }
        }
    

    该ViewGroup 重写了onMeasure(xx)和onLayout(xx)方法:

    • onMeasure(xx) 测量子布局大小,并根据子布局测算结果来决定自己的尺寸
    • onLayout(xx) 摆放子布局位置

    自定义View

    public class MyView extends View {
    
        public MyView(Context context) {
            super(context);
        }
    
        public MyView(Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            canvas.drawColor(Color.GREEN);
        }
    
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            int defaultSize = 100;
            setMeasuredDimension(resolveSize(defaultSize, widthMeasureSpec), resolveSize(defaultSize, heightMeasureSpec));
        }
    
        @Override
        protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
            super.onLayout(changed, left, top, right, bottom);
        }
    }
    

    该View 重写了onMeasure(xx)和onLayout(xx)方法:

    • onMeasure(xx) 测量自身大小,并记录尺寸值
    • onLayout(xx) 什么都没做

    为MyViewGroup 添加子布局

    <?xml version="1.0" encoding="utf-8"?>
    <com.fish.myapplication.MyViewGroup xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:id="@+id/myviewgroup"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:layout_gravity="center_vertical"
        android:background="#000000"
        android:paddingLeft="10dp"
        tools:context=".MainActivity">
        <com.fish.myapplication.MyView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content">
        </com.fish.myapplication.MyView>
    
        <Button
            android:layout_marginLeft="10dp"
            android:text="hello Button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
        </Button>
    </com.fish.myapplication.MyViewGroup>
    

    MyViewGroup里添加了MyView、Button两个控件,最终运行的效果如下:


    image.png

    可以看出,MyViewGroup 里子布局的是横向摆放的。我们重点关注Layout过程。实际上,MyViewGroup里我们只重写了onLayout(xx)方法,MyView也是重写了onLayout(xx)方法。
    接下来,分析View Layout过程。

    View Layout过程

    View.layout(xx)

    与Measure过程类似,连接ViewGroup onLayout(xx)和View onLayout(xx)之间的桥梁是View layout(xx)。

    #View.java
        public void layout(int l, int t, int r, int b) {
            //PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT 在measure时候可能会设置
            if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
                onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
                mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
            }
    
            //记录当前的坐标值
            int oldL = mLeft;
            int oldT = mTop;
            int oldB = mBottom;
            int oldR = mRight;
    
            //新(父布局给的)的坐标值与当前坐标值不一致,则认为有改变
            boolean changed = isLayoutModeOptical(mParent) ?
                    setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
    
            //坐标改变或者是需要重新layout
            if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
                //调用onLayout方法,传入父布局传入的坐标
                onLayout(changed, l, t, r, b);
                ...
    
                //清空请求layout标记
                mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;
                //监听的onLayoutChange回调,通过addOnLayoutChangeListener 设置
                ListenerInfo li = mListenerInfo;
                if (li != null && li.mOnLayoutChangeListeners != null) {
                    ArrayList<OnLayoutChangeListener> listenersCopy =
                            (ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
                    int numListeners = listenersCopy.size();
                    for (int i = 0; i < numListeners; ++i) {
                        listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
                    }
                }
            }
            ...
        }
    
        public static boolean isLayoutModeOptical(Object o) {
            //设置了阴影,发光等属性
            //只有ViewGroup有这属性
            //设置    android:layoutMode="opticalBounds" 或者    android:layoutMode="clipBounds"
            //则返回true,默认没设置以上属性
            return o instanceof ViewGroup && ((ViewGroup) o).isLayoutModeOptical();
        }
    
        private boolean setOpticalFrame(int left, int top, int right, int bottom) {
            //如果设置了阴影、发光灯属性
            //则获取其预留的尺寸
            Insets parentInsets = mParent instanceof View ?
                    ((View) mParent).getOpticalInsets() : Insets.NONE;
            Insets childInsets = getOpticalInsets();
            //重新改变坐标值,并调用setFrame(xx)
            return setFrame(
                    left   + parentInsets.left - childInsets.left,
                    top    + parentInsets.top  - childInsets.top,
                    right  + parentInsets.left + childInsets.right,
                    bottom + parentInsets.top  + childInsets.bottom);
        }
    

    可以看出,最终都调用了setFrame(xx)方法。

    #View.java
        protected boolean setFrame(int left, int top, int right, int bottom) {
            boolean changed = false;
            //当前坐标值与新的坐标值不一致,则重新设置
            if (mLeft != left || mRight != right || mTop != top || mBottom != bottom) {
                changed = true;
                //记录PFLAG_DRAWN标记位
                int drawn = mPrivateFlags & PFLAG_DRAWN;
                
                //记录新、旧宽高
                int oldWidth = mRight - mLeft;
                int oldHeight = mBottom - mTop;
                int newWidth = right - left;
                int newHeight = bottom - top;
                //新、旧宽高是否一样
                boolean sizeChanged = (newWidth != oldWidth) || (newHeight != oldHeight);
                //不一样,走inValidate,最终执行Draw流程
                invalidate(sizeChanged);
    
                //将新的坐标值记录
                mLeft = left;
                mTop = top;
                mRight = right;
                mBottom = bottom;
                //设置坐标值给RenderNode
                mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);
                //标记已经layout过
                mPrivateFlags |= PFLAG_HAS_BOUNDS;
                
                if (sizeChanged) {
                    //调用sizeChange,在该方法里,我们已经能够拿到View宽、高值
                    sizeChange(newWidth, newHeight, oldWidth, oldHeight);
                }
                ...
            }
            return changed;
        }
    

    对于Measure过程在onMeasure(xx)里记录了尺寸的值,而对于Layout过程则在layout(xx)里记录了坐标值,具体来说是在setFrame(xx)里,该方法两个重点地方:

    1、将新的坐标值记录到成员变量mLeft、mTop、mRight、mBottom里
    2、将新的坐标值记录到RenderNode里,当调用Draw过程的时候,Canvas绘制起点就是RenderNode里的位置

    View.onLayout(xx)

    #View.java
        protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        }
    

    View.onLayout(xx)是空实现
    从layout(xx)和onLayout(xx)声明可知,这两个方法都是可以被重写的,接下来看看ViewGroup是否重写了它们。

    ViewGroup Layout过程

    ViewGroup.layout(xx)

    #ViewGroup.java
        @Override
        public final void layout(int l, int t, int r, int b) {
            if (!mSuppressLayout && (mTransition == null || !mTransition.isChangingLayout())) {
                //没有被延迟,或者动画没在改变坐标
                if (mTransition != null) {
                    mTransition.layoutChange(this);
                }
                //父类方法,其实就是View.layout(xx)
                super.layout(l, t, r, b);
            } else {
                //被延迟,那么设置标记位,动画完成后根据标志位requestLayout,重新发起layout过程
                mLayoutCalledWhileSuppressed = true;
            }
        }
    

    ViewGroup.layout(xx)虽然重写了layout(xx),但是仅仅做了简单判断,最后还是调用了View.layout(xx)。

    ViewGroup.onLayout(xx)

    #ViewGroup.java
        protected abstract void onLayout(boolean changed,
                int l, int t, int r, int b);
    

    这重写后将onLayout变为抽象方法,也就是说继承自ViewGroup的类必须重写onLayout(xx)方法。
    我们以FrameLayout为例,分析其onLayout(xx)做了什么。

    FrameLayout.onLayout(xx)

    #FrameLayout.java
        protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
            layoutChildren(left, top, right, bottom, false /* no force left gravity */);
        }
    
        void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) {
            //子布局个数
            final int count = getChildCount();
            //前景padding,意思是子布局摆放的时候不要侵占该位置
            final int parentLeft = getPaddingLeftWithForeground();
            final int parentRight = right - left - getPaddingRightWithForeground();
            final int parentTop = getPaddingTopWithForeground();
            final int parentBottom = bottom - top - getPaddingBottomWithForeground();
    
            //遍历子布局
            for (int i = 0; i < count; i++) {
                final View child = getChildAt(i);
                //GONE状态下无需layout
                if (child.getVisibility() != GONE) {
                    //获取LayoutParams
                    final FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) child.getLayoutParams();
                    //获取之前在Measure过程确定的测量值
                    final int width = child.getMeasuredWidth();
                    final int height = child.getMeasuredHeight();
                    
                    int childLeft;
                    int childTop;
                    //摆放重心落在哪
                    int gravity = lp.gravity;
                    if (gravity == -1) {
                        gravity = DEFAULT_CHILD_GRAVITY;
                    }
    
                    //布局方向,左到右还是右到左,默认左到右
                    final int layoutDirection = getLayoutDirection();
                    //水平反向的Gravity
                    final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
                    //垂直方向的Gravity
                    final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK;
    
                    switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
                        case Gravity.CENTER_HORIZONTAL:
                            //若子布局水平居中,则它的水平方向起始点
                            //扣除父布局padding剩下的位置
                            //结合子布局宽度,使得子布局在剩下位置里居中
                            //再将子布局margin考虑进去
                            //从这里可以看出,若是xml里有居中,也有margin,先考虑居中,然后再考虑margin
                            childLeft = parentLeft + (parentRight - parentLeft - width) / 2 +
                                    lp.leftMargin - lp.rightMargin;
                            break;
                        case Gravity.RIGHT:
                            //靠右,则改变横向的开始坐标值
                            if (!forceLeftGravity) {
                                childLeft = parentRight - width - lp.rightMargin;
                                break;
                            }
                            //默认是从左到右
                        case Gravity.LEFT:
                        default:
                            childLeft = parentLeft + lp.leftMargin;
                    }
    
                    //垂直方向与水平方向类似
                    switch (verticalGravity) {
                        case Gravity.TOP:
                            childTop = parentTop + lp.topMargin;
                            break;
                        case Gravity.CENTER_VERTICAL:
                            childTop = parentTop + (parentBottom - parentTop - height) / 2 +
                                    lp.topMargin - lp.bottomMargin;
                            break;
                        case Gravity.BOTTOM:
                            childTop = parentBottom - height - lp.bottomMargin;
                            break;
                        default:
                            childTop = parentTop + lp.topMargin;
                    }
                    
                    //确定了child的坐标位置
                    //传递给child
                    child.layout(childLeft, childTop, childLeft + width, childTop + height);
                }
            }
        }
    
    

    FrameLayout.onLayout(xx)为子布局Layout的时候,起始坐标都是以FrameLayout为基准,并没有记录上一个子布局占了哪块位置,因此子布局的摆放位置可能会重叠,这也是FrameLayout布局特性的由来。而我们之前的Demo在水平方向上记录了上一个子布局的摆放位置,下一个摆放时只能在它之后,因此就形成了水平摆放的功能。
    由此类推,我们常说的某个子布局在父布局里的哪个位置,决定这个位置的即是ViewGroup.onLayout(xx)。

    View/ViewGroup 常用方法分析

    上边我们分析了View.layout(xx)、View.onLayout(xx)、ViewGroup.layout(xx)、ViewGroup.onLayout(xx),这四者什么关系呢?
    View.layout(xx)

    将摆放位置坐标记录到成员变量里并给RenderNode设值

    View.onLayout(xx)

    空实现

    ViewGroup.layout(xx)

    调用View.layout(xx)**

    ViewGroup.onLayout(xx)

    抽象方法,子类必须重写。子类重写时候需要为每一个子布局计算出摆放位置,并传递给子布局

    View/ViewGroup 子类需要重写哪些方法:

    继承自ViewGroup必须重写onLayout(xx),为子布局计算位置坐标
    继承自View 无需重写layout(xx)和onLayout(xx),因为它已经没有子布局可以摆放

    用图表示:


    image.png

    为什么说Layout是承上启下的作用

    通过上述的描述,我们发现Measure过程和Layout过程里定义的方法比较类似:

    measure(xx)<----->layout(xx)
    onMeasure(xx)<----->onLayout(xx)

    它俩的套路比较类似:measure(xx)、layout(xx)一般不需要我们重写,measure(xx)里调用onMeasure(xx),layout(xx)为调用者设置坐标值。
    若是ViewGroup:onMeasure(xx)里遍历子布局,并测量每个子布局,最后将结果汇总,设置自己测量的尺寸;onLayout(xx)里遍历子布局,并设置每个子布局的坐标。
    若是View:onMeasure(xx)则测量自身,并存储测量尺寸;onLayout(xx)不需要做什么。

    承上

    Measure过程虽然比Layout过程复杂,但仔细分析后就会发现其本质就是为了设置两个成员变量:

    设置 mMeasuredWidth 和 mMeasuredHeight

    而Layout过程虽然比较简单,其本质是为了设置坐标值

    1、设置mLeft、mRight、mTop、mBottom 这四个值确定一个矩形区域
    2、mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom) 给RnederNode设置坐标

    将Measure设置的变量和Layout设置的变量联系起来:

    mRight、mBottom 是根据mLeft、mRight 结合mMeasuredWidth、mMeasuredHeight 计算而得的

    这就是Layout的承上作用

    启下

    我们知道View的绘制需要依靠Canvas绘制,而Canvas是有作用区域限制的。例如我们使用:

    canvas.drawColor(Color.GREEN);
    

    Cavas绘制的起点是哪呢?
    正是通过Layout过程中设置的RenderNode坐标。
    这就是Layout的启下作用
    以上即是Measure、Layout、Draw三者的内在联系。
    当然Layout的"承上"还需要考虑margin、gravity等参数的影响。具体用法参见最开始的Demo。

    经典问题

    getMeasuredWidth()/getMeasuredHeight 与 getWidth/getHeight区别
    我们以获取width为例,分别来看看其方法:

    #View.java
        public final int getMeasuredWidth() {
            return mMeasuredWidth & MEASURED_SIZE_MASK;
        }
    
        public final int getWidth() {
            return mRight - mLeft;
        }
    

    getMeasuredWidth():获取测量的宽,属于"临时值"
    getWidth():获取View真实的宽
    在Layout过程之前,getWidth() 默认为0
    何时可以获取真实的宽、高

    1、重写View.onSizeChanged(xx)方法获取
    2、注册View.addOnLayoutChangeListener(xx),在onLayoutChange(xx)里获取
    3、重写View.onLayout(xx)方法获取

    下篇将分析Draw()过程,我们将分析"一切都是draw出来的"道理

    本篇基于 Android 10.0

    相关文章

      网友评论

        本文标题:Android 自定义View之Layout过程

        本文链接:https://www.haomeiwen.com/subject/dyobjktx.html