Android自定义控件 | View绘制原理(画在哪?)

1,933 阅读6分钟

View绘制就好比画画,抛开Android概念,如果要画一张图,首先会想到哪几个基本问题:

  • 画多大?
  • 画在哪?
  • 怎么画?

Android绘制系统也是按照这个思路对View进行绘制,上面这些问题的答案分别藏在:

  • 测量(measure)
  • 定位(layout)
  • 绘制(draw)

这一篇将从源码的角度分析“定位(layout)”。

如果想直接看结论可以移步到第三篇末尾。

这是Android自定义控件系列文章的第二篇,系列文章目录如下:

  1. Android自定义控件 | View绘制原理(画多大?)
  2. Android自定义控件 | View绘制原理(画在哪?)
  3. Android自定义控件 | View绘制原理(画什么?)
  4. Android自定义控件 | 源码里有宝藏之自动换行控件
  5. Android自定义控件 | 小红点的三种实现(上)
  6. Android自定义控件 | 小红点的三种实现(下)
  7. Android自定义控件 | 小红点的三种实现(终结)

如何描述位置

位置都是相对的,比如“我在你的右边”、“你在广场的西边”。为了表明位置,总是需要一个参照物。View的定位也需要一个参照物,这个参照物是View的父控件。可以在View的成员变量中找到如下四个描述位置的参数:

public class View implements Drawable.Callback, KeyEvent.Callback,AccessibilityEventSource {
    /**
     * The distance in pixels from the left edge of this view’s parent
     * to the left edge of this view.
     * view左边相对于父亲左边的距离
     */
    protected int mLeft;
    
    /**
     * The distance in pixels from the left edge of this view‘s parent
     * to the right edge of this view.
     * view右边相对于父亲左边的距离
     */
    protected int mRight;
    
    /**
     * The distance in pixels from the top edge of this view’s parent
     * to the top edge of this view.
     * view上边相对于父亲上边的距离
     */
    protected int mTop;
    
    /**
     * The distance in pixels from the top edge of this view‘s parent
     * to the bottom edge of this view.
     * view底边相对于父亲上边的距离
     */
    protected int mBottom;
    ...
}

View通过上下左右四条线围城的矩形来确定相对于父控件的位置以及自身的大小。 那这里所说的大小和上一篇中测量出的大小有什么关系呢?留个悬念,先看一下上下左右这四个变量在哪里被赋值。

确定相对位置

全局搜索后,找到下面这个函数:

public class View implements Drawable.Callback, KeyEvent.Callback,AccessibilityEventSource {
    /**
     * Assign a size and position to this view.
     * 赋予当前view尺寸和位置
     *
     * This is called from layout.
     * 这个函数在layout中被调用
     *
     * @param left Left position, relative to parent
     * @param top Top position, relative to parent
     * @param right Right position, relative to parent
     * @param bottom Bottom position, relative to parent
     * @return true if the new size and position are different than the previous ones
     */
    protected boolean setFrame(int left, int top, int right, int bottom) {
        ...
        mLeft = left;
        mTop = top;
        mRight = right;
        mBottom = bottom;
        ...
    }
}

沿着调用链继续往上查找:

public class View implements Drawable.Callback, KeyEvent.Callback,AccessibilityEventSource {
    /**
     * Assign a size and position to a view and all of its
     * descendants
     * 将尺寸和位置赋予当前view和所有它的孩子
     *
     * <p>This is the second phase of the layout mechanism.
     * (The first is measuring). In this phase, each parent calls
     * layout on all of its children to position them.
     * This is typically done using the child measurements
     * that were stored in the measure pass().</p>
     *
     * <p>Derived classes should not override this method.
     * Derived classes with children should override
     * onLayout. In that method, they should
     * call layout on each of their children.</p>
     * 子类不应该重载这个方法,而应该重载onLayout(),并且在其中局部所有孩子
     *
     * @param l Left position, relative to parent
     * @param t Top position, relative to parent
     * @param r Right position, relative to parent
     * @param b Bottom position, relative to parent
     */
    public void layout(int l, int t, int r, int b) {
        ...
        //为View上下左右四条线赋值
        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
        ...
        //如果布局改变了则重新布局
        if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            onLayout(changed, l, t, r, b);
            ...
        }
    }
    ...
    /**
     * Called from layout when this view should
     * assign a size and position to each of its children.
     * 当需要赋予所有孩子尺寸和位置的时候,这个函数在layout中被调用
     *
     * Derived classes with children should override
     * this method and call layout on each of
     * their children.
     * 带有孩子的子类应该重载这个方法并调用每个孩子的layout()
     * @param changed This is a new size or position for this view
     * @param left Left position, relative to parent
     * @param top Top position, relative to parent
     * @param right Right position, relative to parent
     * @param bottom Bottom position, relative to parent
     */
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    }
}

结合调用链和代码注释,可以得出结论:孩子的定位是由父控件发起的,父控件会在ViewGroup.onLayout()中遍历所有的孩子并调用它们的View.layout()以设置孩子相对于自己的位置。

不同的ViewGroup有不同的方式来布局孩子,以FrameLayout为例:

public class FrameLayout extends ViewGroup {

    @Override
    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();

        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);
            //排除不可见孩子
            if (child.getVisibility() != GONE) {
                final LayoutParams lp = (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();
                final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
                final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK;
                //确定孩子左边相对于父控件位置
                switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
                    case Gravity.CENTER_HORIZONTAL:
                        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;
                }
                //调用孩子的layout(),确定孩子相对父控件位置
                child.layout(childLeft, childTop, childLeft + width, childTop + height);
            }
        }
    }
}

FrameLayout所有的孩子都是相对于它的左上角进行定位,并且在定位孩子右边和下边的时候直接加上了在measure过程中得到的宽和高。

测量尺寸和实际尺寸的关系

FrameLayout遍历孩子并触发它们定位的过程中,会用到上一篇测量的结果(通过getMeasuredWidth()getMeasuredHeight()),并最终通过layout()影响mRightmBottom的值。对比一下getWidth()getMeasuredWidth()

public class View implements Drawable.Callback, KeyEvent.Callback,AccessibilityEventSource {
    public final int getWidth() {
        //控件右边和左边差值
        return mRight - mLeft;
    }
    
    /**
     * Like {@link #getMeasuredWidthAndState()}, but only returns the
     * raw width component (that is the result is masked by
     * 获得MeasureSpec的尺寸部分
     * {@link #MEASURED_SIZE_MASK}).
     *
     * @return The raw measured width of this view.
     */
    public final int getMeasuredWidth() {
        return mMeasuredWidth & MEASURED_SIZE_MASK;
    }
}
  • getMeasuredWidth()是measure过程的产物,它是测量尺寸。getWidth()是layout过程的产物,它是布局尺寸。它们的值可能不相等。
  • 测量尺寸只是layout过程中可能用到的关于控件大小的参考值,不同的ViewGroup会有不同的layout算法,也就有不同的使用参考值的方法,控件最终展示尺寸由layout过程决定(以布局尺寸为准)。

总结

  1. 控件位置和最终展示的尺寸是通过上(mTop)、下(mBottom)、左(mLeft)、右(mRight)四条线围城的矩形来描述的。
  2. 控件定位就是确定自己相对于父控件的位置,子控件总是相对于父控件定位,当根布局的位置确定后,屏幕上所有控件的位置都确定了。
  3. 控件定位是由父控件发起的,父控件完成自己定位之后会调用onLayout(),在其中遍历所有孩子并调用它们的layout()方法以确定子控件相对于自己的位置。
  4. 整个定位过程的终点是View.setFrame()的调用,它表示着视图矩形区域的大小以及相对于父控件的位置已经确定。