Android自定义View 自定义容器

Posted mChenys

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android自定义View 自定义容器相关的知识,希望对你有一定的参考价值。

目录

一、什么是自定义容器

自定义容器本质上也是一个组件,常见的 LinearLayout、FrameLayout、GridLayout、ScrollView和 RelativeLayout 等等组件都是容器,容器除了有自己的外观,还能用来容纳各种组件,以一种特定的规则规定组件应该在什么位置、显示多大。

一般情况下,我们更关注自定义组件的外观及功能,但自定义容器则更关注其内的组件怎么排列和摆放,比如线性布局 LinearLayout 中的组件只能水平排列或垂直排列,帧布局FrameLayout中的组件可以重叠,相对布局 RelativeLayout 中的组件可以以某一个组件为参照定位自身的位 置……容器还关注组件与容器四个边框之间的距离(padding),或者容器内组件与组件之间的距离(margin)

事实上,容器是可以嵌套的,一个容器中,既可以是普通的子组件,也可以是另一个子容器。
容器类一般要继承 ViewGroup 类,ViewGroup 类同时也是 View 的子类,ViewGroup 又是一个抽象类,定义了 onLayout()等抽象方法。当然,根据需要,我们也可以让容器类继承自 FrameLayout 等 ViewGroup 的子类,比如 ListView 继承自 ViewGroup,而 ScrollView
水平滚动容器类则从 FrameLayout 派生。

1.1 ViewGroup类

ViewGroup作为容器类的父类,自然有他自己鲜明的特征,开发自定义容器必须先要了解ViewGroup。在ViewGroup 中,定义了一个 View[]类型的数组mChildren,该数组保存了容器中所有的子组件,负责维护组件的添加、移除、管理组件顺序等功能,另一个成员变量mChildrenCount 则保存了容器中子组件的数量。在布局文件(layout)中,容器中的子元素会根据顺序自动添加到mChildren数组中。

ViewGroup 具备了容器类的基本特征和运作流程,也定义了相关的方法用于访问容器内的组件,获取子View数量和子View的方法:

/**
 * 获取容器内的子组件的个数
 *
 * @return
 */
public int getChildCount();

/**
 * 容器内的所有子组件都存储在名为 mChildren的View[]数组中,该方法通过索引index找到指定位置的子组件
 *
 * @param index
 * @return
 */
public View getChildAt(int index);

添加View有如下方法:

public void addView(View child, int index, LayoutParams params);
public void addView(View child, LayoutParams params);
public void addView(View child, int index);
public void addView(View child);

向容器中添加新的子组件时,子组件不能有父容器,否则会抛出“The specified child already has a parent(该组件已有父容器)”的异常。
删除View有如下方法:

public void removeViewAt(int index);
public void removeView(View view);
public void removeViews(int start, int count);

测量View有如下方法:

// 测量给定的子组件的尺寸
protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec);
// 测量所有子组件的尺寸
protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec);
// 该方法从View类中继承,用于测量组件或容器自己的尺寸,参数widthMeasureSpec和heightMeasureSpec为0时表示按实际大小进行测量,将0传入方法常常会有奇效。
public final void measure(int widthMeasureSpec, int heightMeasureSpec);

ViewGroup 运行的基本流程大致为:

1)测量容器尺寸
重写 onMeasure()方法测量容器大小,和自定义View有所区别的是,在测量容器大小之前,必须先调用measureChildren()方法测量所有包含的子View的大小,不然结果永远为 0。

2)确定每个子组件的位置
重写 onLayout()方法确定每个子组件的位置(这个其实挺麻烦,也是定义容器的难点部分),在onLayout()方法中,调用View的layout()方法确定子组件的位置。

3)绘制容器
重写 onDraw()方法,其实ViewGroup类并没有重写onDraw()方法,除非有特别的要求,自定义容器也很少去重写。比如LinearLayout 重写了该方法用于绘制水平或垂直分割条,而FrameLayout则是重写了draw()方法,作用其实是一样的。

1.2 ViewGroup的工作原理

1.2.1 ViewGroup的onMeasure分析

ViewGroup作为View的子类,流程基本是相同的,但另一方面ViewGroup作为容器的父类,又有些差异,我们通过阅读源码来了解ViewGroup的工作原理,前面说到,重写ViewGroup的onMeasure()方法时,必须先调用measureChildren()方法测量子组件的尺寸,该方法源码如下:

protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) 
    final int size = mChildrenCount;
    final View[] children = mChildren;
    for (int i = 0; i < size; ++i) 
        final View child = children[i];
        if ((child.mViewFlags & VISIBILITY_MASK) != GONE) 
        	// 测量指定子View
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
        
    

measureChildren()方法中,循环遍历每一个子组件,如果当前子组件的可见性不为GONE也,就是没有隐藏则继续调用measureChild(child,widthMeasureSpec,heightMeasureSpec)方法测量当前子组件child的大小,我们继续进入measureChild()方法。

protected void measureChild(View child, int parentWidthMeasureSpec,
        int parentHeightMeasureSpec) 
    final LayoutParams lp = child.getLayoutParams();
    // 计算子View测量模式
    final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom, lp.height);
    // 调用子View的measure方法
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);

measureChild()方法结合父容器的 MeasureSpec、父容器的Padding和子组件LayoutParams 三个因素利用getChildMeasureSpec() 计算出子组件的尺寸模式和尺寸大小(可以跟踪到getChildMeasureSpec()方法中查看,前面基础篇也有介绍),并调用子组件的measure()方法进行尺寸测量measure()方法的实现如下:

public final void measure(int widthMeasureSpec, int heightMeasureSpec) 
    ...
    // 最终触发子View的onMeasure方法
    onMeasure(widthMeasureSpec, heightMeasureSpec);
    ...

真相慢慢露出水面,View的measure()方法调用了 onMeasure(widthMeasureSpec,heightMeasureSpec)方法,该方法正是我们重写子View的用来测量组件尺寸的方法,至此,测量组件尺寸的工作已掌握到开发人员手中。

当measureChildren流程走完之后,该自定义容器内的所有子View就可以通过getMeasureWidth()和getMeasureHeight获取测量后的宽高了,然后容器自身就可以计算出最大宽度和高度来定义自身的宽高了。模板代码如下:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) 
    // 1.先测量所有的子View
    measureChildren(widthMeasureSpec, heightMeasureSpec);

    // 然后测量自身宽高
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int width = MeasureSpec.getSize(widthMeasureSpec);
    int height = MeasureSpec.getSize(heightMeasureSpec);

    // 假设每一行只能放一个子控件,具体按照需求而定
    int maxWidth = 0;
    int totalHeight = 0;
    int count = getChildCount();
    for (int i = 0; i < count; i++) 
        View child = getChildAt(i);
        // 记录每一行的最大宽度
        maxWidth = Math.max(maxWidth, child.getMeasuredWidth());
        totalHeight += child.getMeasuredHeight();
    
    
    if (widthMode != MeasureSpec.EXACTLY) 
        width = Math.min(width, maxWidth);
    
    if (heightMode != MeasureSpec.EXACTLY) 
        height = Math.min(height, totalHeight);
    
    
    // 2.设置父容器的宽高
    setMeasuredDimension(width,height);

1.2.2 ViewGroup的onLayout分析

分析完ViewGroup的onMeasure原理后,再来分析onLayout的原理,在 onLayout()方法中, 我们将调用子组件的 layout()方法,这里要一分为二,如果子组件是一个 View,定位流程到此结束,如果子组件又是一个容器呢?我们进入 layout()方法进行跟踪。

public void layout(int l, int t, int r, int b) 
    ...
    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);

    if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) 
    	// 如果子View也是容器类,那么也会调用onLayout进行分发
        onLayout(changed, l, t, r, b);

       ...
    

如果子组件是一个容器,又会继续调用该容器的 onLayout()方法对孙组件进行定位,所以,onLayout()方法也是一个递归的过程。
举个例子,重写自定义容器的onLayout方法如下:

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) 
    int childCount = getChildCount();
    int top = 0; 
    for (int i = 0; i < childCount; i++) 
        View child = getChildAt(i);
        // 布局子View
        child.layout(l, top, l + child.getMeasuredWidth(), top + child.getMeasuredHeight());
        // 假设一行只能放一个子View ,那么放完上一个后,需要下一个的top
        top += child.getMeasuredHeight();
    

1.2.3 ViewGroup的onDraw分析

onMeasure()方法和onLayout()方法调用完成后,该轮到onDraw()方法了,ViewGroup类并没有重写该方法,通常情况下重写onDraw是不会回调的,除非该自定义容器设置背景色或者背图,从第一章中我们都知道每一个组件在绘制时是会调用View的draw()方法的,我们进入draw()方法进行跟踪。

public void draw(Canvas canvas) 
  ...

    /*
     * Draw traversal performs several drawing steps which must be executed
     * in the appropriate order:
     *
     *      1. Draw the background
     *      2. If necessary, save the canvas' layers to prepare for fading
     *      3. Draw view's content
     *      4. Draw children
     *      5. If necessary, draw the fading edges and restore layers
     *      6. Draw decorations (scrollbars for instance)
     *      7. If necessary, draw the default focus highlight
     */

    // Step 1, draw the background, if needed
    int saveCount;

    drawBackground(canvas);

    // skip step 2 & 5 if possible (common case)
    final int viewFlags = mViewFlags;
    boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
    boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
    if (!verticalEdges && !horizontalEdges) 
        // Step 3, draw the content
        onDraw(canvas);

        // Step 4, draw the children
        dispatchDraw(canvas);

        drawAutofilledHighlight(canvas);

        // Overlay is part of the content and draws beneath Foreground
        if (mOverlay != null && !mOverlay.isEmpty()) 
            mOverlay.getOverlayView().dispatchDraw(canvas);
        

        // Step 6, draw decorations (foreground, scrollbars)
        onDrawForeground(canvas);

        // Step 7, draw the default focus highlight
        drawDefaultFocusHighlight(canvas);

        if (isShowingLayoutBounds()) 
            debugDrawFocus(canvas);
        

        // we're done...
        return;
    
...

draw()方法中执行了语句dispatchDraw(canvas),但是,当我们跟踪到View类的dispatchDraw()方法时发现该方法是空的。

但对于ViewGroup来说,该方法的作用非同小可,因为ViewGroup重写了dispatchDraw()方法。并且该方法是一定会回调的,重写此方法后记得需要调用super.dispatchDraw,因为子View的绘制分发是在ViewGroup的dispatchDraw方法内的,如果不调用super.dispatchDraw,那么子View将不会绘制。

// ViewGroup.java

@Override
protected void dispatchDraw(Canvas canvas) 
   ...
    final int childrenCount = mChildrenCount;
    final View[] children = mChildren;
    int flags = mGroupFlags;

    ...
    for (int i = 0; i < childrenCount; i++) 
        while (transientIndex >= 0 && mTransientIndices.get(transientIndex) == i) 
            final View transientChild = mTransientViews.get(transientIndex);
            if ((transientChild.mViewFlags & VISIBILITY_MASK) == VISIBLE ||
                    transientChild.getAnimation() != null) 
                // 调用drawChild来触发子组件的绘制        
                more |= drawChild(canvas, transientChild, drawingTime);
            
          ...
        

       ...
    
    ...

dispatchDraw()方法的作用是将绘制请求纷发到给子组件,并调用drawChild()方法来完成子组件的绘制,drawChild()方法的源码如下:

protected boolean drawChild(Canvas canvas, View child, long drawingTime) 
    // 非常精简,就一行代码
    return child.draw(canvas, this, drawingTime);

继续看View的draw方法,注意是3个参数的draw方法

boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) 
   
    ...

    if (!drawingWithDrawingCache) 
        if (drawingWithRenderNode) 
            mPrivateFlags &= ~PFLAG_DIRTY_MASK;
            ((RecordingCanvas) canvas).drawRenderNode(renderNode);
         else 
            // Fast path for layouts with no backgrounds
            if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) 
                mPrivateFlags &= ~PFLAG_DIRTY_MASK;
                dispatchDraw(canvas);
             else 
                // 关键代码,调用View的一个参数的draw方法,最终会触发onDraw方法
                draw(canvas);
            
        
     
    
    ...

    return more;

可以看到ViewGroup经过dispathDraw方法最终会回调子View的draw方法,而View的draw方法前面我们已经分析过了,最终会触发子View的onDraw方法。

二、综合案例

2.1 CornerLayout布局

CornerLayout 布局是一个自定义容器,用于将子组件分别显示在容器的4个角落,不接受超过4个子组件的情形,默认情况下,子组件按照从左往右、从上往下的顺序放置,但可以为子组件指定位置(左上角 left_top、右上角 right_top、左下角 left_bottom、右下角right_bottom)。CornerLayout并不具备实用价值,因为FrameLayout布局能轻易实现CornerLayout 的功能,但是,对于理解布局容器的开发却能提供一种非常清晰的方法和思路(这个才是最重要的,不是么?)。

2.1.1 分析容器的宽高

先画一个草图来帮助我们分析

上图中,蓝色框表示CornerLayout布局的区域A、B、C、D 是CornerLayout内的4个子组件,对于CornerLayout来说,首先要测量的是他的尺寸大小,当其layout_width为wrap_content时,它的宽度计算应该满足下面要求:

容器的最小宽度 = 容器的paddingLeft + 容器的paddingRight + 
			   A或者C的最大leftMargin+rightMargin +
 			   A或者C的最大宽度 +
			   B或者D的最大宽度 + 
               B或者D的最大leftMargin+rightMargin

当容器的layout_height为wrap_content时,它的高度计算应该满足下面要求:

容器的最小高度 = 容器的paddingTop + 容器的paddingBottom +
			   A或者B的最大topMargin+bottomMargin + 
			   A或者B的最大高度 + 
			   C或者D的最大高度 + 
			   C或者D的最大topMargin+bottomMargin

这样才不至于子组件出现重叠,当然,如果layout_width 和 layout_height指定了具体值或者屏幕不够大的情况下设置为match_parent,子组件仍有可能会出现重叠现象。

2.1.2 分析容器的内边距

上面分析padding,View类已经提供了对应的方法获取上下左右的内边距了,如下所示:

public int getPaddingLeft();
public int getPaddingTop();
public int getPaddingRight();
public int getPaddingBottom();

2.1.3 分析子View的外边距

而对于子View外边距,我们只能通过MarginLayoutParams来获取,MarginLayoutParams是ViewGroup.LayoutParams的子类,它暴露了公共的属性可以获取View的四个方向的外边距

public static class MarginLayoutParams extends ViewGroup.LayoutParams 
   
    public int leftMargin;
    public int topMargin;
    public int rightMargin;
    public int bottomMargin;

然而ViewGroup在添加子View的时候,使用的LayoutParams并不是MarginLayoutParams,这个可以查看其addView方法源码:

public void addView(View child, LayoutParams params) 
    addView(child, -1, params);

public void addView(View child, int index, LayoutParams params) 
    ...
    requestLayout();
    invalidate(true);
    addViewInner(child, index, params, false);

你可能会说这也看不出什么名堂啊, 我们来想一下我们定义在布局中的子View是如何被加载到父容器的,了解过Activity的setContentView源码的人就会知道布局的解析其实是通过LayoutInflate来完成,我们看看LayoutInflate的inflate方法

public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) 
        ...
        final String name = parser.getName();
        if (TAG_MERGE.equals(name)) 
           ...
         else 
            // Temp is the root view that was found in the xml 注意这句话Temp就是布局的根View
            final View temp = createViewFromTag(root, name, inflaterContext, attrs);
            ViewGroup.LayoutParams params = null;
            if (root != null) 
                // 创建布局根View的布局参数
                params = root.generateLayoutParams(attrs);
                if (!attachToRoot) 
                    temp.setLayoutParams(params);
                
            
		...
            // Inflate all children under temp against its context. 注意这句话,就是解析temp根容器下的所有子view 
            rInflateChildren(parser, temp, attrs, true);
	...
    return result;

final void rInflateChildren(XmlPullParser parser, View parent, AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException 
    // 一句话,看rInflate
    rInflate(parser, parent, parent.getContext(), attrs, finishInflate);


void rInflate(XmlPullParser parser, View parent, Context context,AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException 
 	...
    while (((type = parser.next()) != XmlPullParser.END_TAG 以上是关于Android自定义View 自定义容器的主要内容,如果未能解决你的问题,请参考以下文章

Android自定义View(LineBreakLayout-自动换行的标签容器)

android自定义控件怎么用

Android自定义ViewGroup(四打造自己的布局容器)

Android:创建自定义容器视图

Android 自定义控件之继承ViewGroup创建新容器

Android自定义ViewGroup