深入浅出安卓自定义ViewGroup

133 阅读4分钟

深入浅出安卓自定义ViewGroup

一、ViewGroup是什么?——安卓界面的乐高大师

想象ViewGroup就像乐高积木的底板:

  • 基本功能:可以固定其他积木(子View)
  • 扩展能力:能自定义积木排列规则
  • 事件处理:决定哪些积木响应触摸

自定义ViewGroup就是自己设计一块特殊底板,实现独特的布局效果。

二、自定义ViewGroup三大核心方法

1. onMeasure() —— 量尺寸

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // 1. 测量所有子View
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
    }
    
    // 2. 计算自己的尺寸
    int width = calculateTotalWidth();
    int height = calculateTotalHeight();
    
    // 3. 保存测量结果
    setMeasuredDimension(width, height);
}

关键点

  • MeasureSpec包含模式和尺寸(EXACTLY/AT_MOST/UNSPECIFIED)
  • 必须先测量子View再确定自身尺寸
  • 最终必须调用setMeasuredDimension()

2. onLayout() —— 摆位置

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        
        // 计算当前子View的四个边界位置
        int left = calculateChildLeft(i);
        int top = calculateChildTop(i);
        int right = left + child.getMeasuredWidth();
        int bottom = top + child.getMeasuredHeight();
        
        // 布局子View
        child.layout(left, top, right, bottom);
    }
}

记忆口诀

  • 左上是起点(left, top)
  • 右下=左上+宽高(right=left+width, bottom=top+height)
  • 必须调用每个子View的layout()

3. onDraw() —— 画装饰(可选)

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas); // 默认会绘制背景
    
    // 自定义绘制内容
    canvas.drawCircle(...);
    canvas.drawText(...);
}

注意

  • 如果只是布局容器,可以不重写onDraw
  • 需要自定义绘制时才重写

三、自定义ViewGroup实战——实现流式布局

1. 设计目标

  • 从左到右排列子View
  • 行满自动换行
  • 支持行居中/左对齐/右对齐

2. 完整实现代码

public class FlowLayout extends ViewGroup {
    private int mHorizontalSpacing = 10; // 水平间距
    private int mVerticalSpacing = 10;   // 垂直间距
    private int mGravity = Gravity.LEFT; // 对齐方式

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        
        int totalWidth = 0;
        int totalHeight = 0;
        int lineWidth = 0;
        int lineHeight = 0;
        
        // 测量所有子View
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) continue;
            
            measureChild(child, widthMeasureSpec, heightMeasureSpec);
            
            int childWidth = child.getMeasuredWidth();
            int childHeight = child.getMeasuredHeight();
            
            // 判断是否需要换行
            if (lineWidth + childWidth > widthSize - getPaddingLeft() - getPaddingRight()) {
                totalWidth = Math.max(totalWidth, lineWidth);
                totalHeight += lineHeight + mVerticalSpacing;
                lineWidth = 0;
                lineHeight = 0;
            }
            
            lineWidth += childWidth + mHorizontalSpacing;
            lineHeight = Math.max(lineHeight, childHeight);
        }
        
        // 加上最后一行
        totalWidth = Math.max(totalWidth, lineWidth);
        totalHeight += lineHeight;
        
        // 考虑padding
        totalWidth += getPaddingLeft() + getPaddingRight();
        totalHeight += getPaddingTop() + getPaddingBottom();
        
        // 处理AT_MOST模式
        widthSize = widthMode == MeasureSpec.AT_MOST ? 
                   Math.min(widthSize, totalWidth) : totalWidth;
        
        setMeasuredDimension(widthSize, totalHeight);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int width = getWidth();
        int paddingLeft = getPaddingLeft();
        int paddingTop = getPaddingTop();
        
        int x = paddingLeft;
        int y = paddingTop;
        int lineHeight = 0;
        
        // 记录每行的View列表
        List<View> lineViews = new ArrayList<>();
        int lineWidth = 0;
        
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) continue;
            
            int childWidth = child.getMeasuredWidth();
            int childHeight = child.getMeasuredHeight();
            
            // 需要换行
            if (x + childWidth > width - getPaddingRight()) {
                layoutLine(lineViews, lineWidth, lineHeight, y);
                
                y += lineHeight + mVerticalSpacing;
                x = paddingLeft;
                lineViews.clear();
                lineWidth = 0;
                lineHeight = 0;
            }
            
            lineViews.add(child);
            lineWidth += childWidth + mHorizontalSpacing;
            lineHeight = Math.max(lineHeight, childHeight);
            x += childWidth + mHorizontalSpacing;
        }
        
        // 布局最后一行
        if (!lineViews.isEmpty()) {
            layoutLine(lineViews, lineWidth, lineHeight, y);
        }
    }
    
    // 布局单行View
    private void layoutLine(List<View> views, int lineWidth, int lineHeight, int y) {
        int parentWidth = getWidth();
        int paddingLeft = getPaddingLeft();
        
        // 计算起始x坐标(根据gravity)
        int x = paddingLeft;
        if (mGravity == Gravity.CENTER_HORIZONTAL) {
            x = (parentWidth - lineWidth) / 2;
        } else if (mGravity == Gravity.RIGHT) {
            x = parentWidth - lineWidth - getPaddingRight();
        }
        
        // 布局该行所有View
        for (View child : views) {
            int childWidth = child.getMeasuredWidth();
            int childHeight = child.getMeasuredHeight();
            
            child.layout(
                x,
                y,
                x + childWidth,
                y + childHeight
            );
            
            x += childWidth + mHorizontalSpacing;
        }
    }
}

四、自定义ViewGroup进阶技巧

1. 处理触摸事件

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    // 判断是否需要拦截事件
    return shouldIntercept ? true : super.onInterceptTouchEvent(ev);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
    // 处理自己的触摸逻辑
    return true; // 表示消费事件
}

2. 支持XML属性

<!-- res/values/attrs.xml -->
<declare-styleable name="FlowLayout">
    <attr name="horizontal_spacing" format="dimension" />
    <attr name="vertical_spacing" format="dimension" />
    <attr name="gravity">
        <flag name="left" value="0x03" />
        <flag name="center_horizontal" value="0x01" />
        <flag name="right" value="0x05" />
    </attr>
</declare-styleable>
// 在构造方法中读取
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout);
mHorizontalSpacing = ta.getDimensionPixelSize(
    R.styleable.FlowLayout_horizontal_spacing, 10);
mGravity = ta.getInt(R.styleable.FlowLayout_gravity, Gravity.LEFT);
ta.recycle();

3. 性能优化

// 1. 减少onMeasure/onLayout调用
@Override
public boolean shouldDelayChildPressedState() {
    return false;
}

// 2. 使用View的绘制缓存
setLayerType(LAYER_TYPE_HARDWARE, null);

// 3. 避免过度绘制
setWillNotDraw(true); // 如果没有自定义绘制

五、常见问题解决方案

1. 子View显示不全

原因:onMeasure计算尺寸错误或onLayout位置计算错误 排查

  1. 检查子View是否都调用了measureChild()
  2. 检查layout()的right/bottom是否=left+measuredWidth/top+measuredHeight

2. 布局性能差

优化方案

  1. 减少不必要的measure/layout调用
  2. 对于固定尺寸的子View,使用setMeasuredDimension()直接设置
  3. 重写getChildDrawingOrder()优化绘制顺序

3. 触摸事件不响应

检查点

  1. 是否错误拦截了事件(onInterceptTouchEvent)
  2. 子View的clickable属性是否为true
  3. 是否在onTouchEvent中返回了true

六、自定义ViewGroup vs 自定义View

特性自定义ViewGroup自定义View
核心方法onMeasure/onLayoutonMeasure/onDraw
复杂度高(需处理子View)
适用场景自定义布局规则自定义绘制效果
性能考量测量/布局性能绘制性能

七、总结

自定义ViewGroup三板斧:

  1. 量尺寸:onMeasure()里测量所有子View
  2. 摆位置:onLayout()里给每个子View安排座位
  3. 管事件:决定是否拦截触摸事件

进阶技巧:

  • 支持XML属性让布局更灵活
  • 优化测量布局性能
  • 处理复杂的触摸交互

记住:好的自定义ViewGroup应该像优秀的导演——知道每个演员(子View)该站哪里,该什么时候上场!