3. Android 流式布局打造热门标签 让RecyclerView颤抖!自研流式布局的架构设计与性能突围

1.效果图

2.功能需求

  1. 流式布局模型: 模拟 CSS Flexbox 的自动换行特性
  2. 动态尺寸计算: 根据子元素尺寸自动计算容器大小
  3. 间距处理: 支持水平和垂直间距,兼容子 View 的 margin
  4. 垂直滚动容器 : 继承自 ViewGroup,提供垂直滚动能力
  5. 自适应高度: 流式布局高度根据内容自动调整

3.实现思路

3.1 动态测量

ini 复制代码
@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;  // 当前行高度
    
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == GONE) continue;
        
        // 测量子View(包含margin)
        measureChild(child, widthMeasureSpec, heightMeasureSpec);
        MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
        int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
        int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        
        // 换行判断:当前行宽度+子元素宽度 > 可用宽度
        if (lineWidth + childWidth > widthSize - getHorizontalPadding()) {
            totalWidth = Math.max(totalWidth, lineWidth); // 更新最大行宽
            totalHeight += lineHeight + verticalSpacing;   // 累加行高
            lineWidth = childWidth;                      // 新行起始宽度
            lineHeight = childHeight;                     // 新行起始高度
        } else {
            lineWidth += childWidth + horizontalSpacing;  // 累加行宽
            lineHeight = Math.max(lineHeight, childHeight); // 更新行高
        }
    }
    
    // 最终尺寸计算(考虑padding)
    totalWidth = Math.max(totalWidth, lineWidth) + getHorizontalPadding();
    totalHeight += lineHeight + getVerticalPadding();
    
    // 根据测量模式确定最终尺寸
    setMeasuredDimension(
        (widthMode == MeasureSpec.EXACTLY) ? widthSize : totalWidth,
        (heightMode == MeasureSpec.EXACTLY) ? heightSize : totalHeight
    );
}

/**

  • 测量流程:
    1. 遍历所有子View,测量每个子View的尺寸
    1. 计算每行的宽度和高度
    1. 当一行放不下时换行,并累加高度
    1. 最终确定FlowLayout的总宽高 */
基础信息获取方法
方法 作用 使用场景
getChildCount() 获取子View数量 测量/布局开始时遍历子View
getChildAt(int index) 获取指定位置的子View 遍历时访问每个子View
getPaddingLeft()/Right()/Top()/Bottom() 获取容器内边距 计算可用空间时扣除padding
方法 作用 关键说明
measureChild(View child, ...) 测量子View尺寸 必须调用才能获取子View尺寸
child.getMeasuredWidth()/Height() 获取子View测量后宽高 测量后才能获取有效值
getLayoutParams() 获取布局参数 通常转换为MarginLayoutParams获取margin值
setMeasuredDimension(int, int) 设置容器最终尺寸 必须调用的收尾方法
  1. 换行机制

    • 动态计算每行剩余空间:可用宽度 = 容器宽度 - padding - 当前行已用宽度
    • 子元素宽度 + 水平间距 > 剩余空间 时触发换行
  2. 尺寸自适应

    • 宽度:取所有行宽度的最大值
    • 高度:累加所有行高 + 行间垂直间距
    • 支持 EXACTLY/AT_MOST 测量模式
  3. 间距处理

    arduino 复制代码
    // 水平间距影响
    lineWidth += childWidth + horizontalSpacing;
    
    // 垂直间距影响
    totalHeight += lineHeight + verticalSpacing;
  4. Margin支持

    • 使用 MarginLayoutParams 获取边距值

    • 在测量和布局时额外添加 margin 空间:

      ini 复制代码
      int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
      int left = childLeft + lp.leftMargin; // 布局时考虑左margin
  5. 性能优化

    • 跳过 GONE 状态的子 View

    • 间距改变时触发 requestLayout() 重新布局

arduino 复制代码
// 设置间距方法
public void setHorizontalSpacing(int spacing) {
    this.horizontalSpacing = spacing;
    requestLayout(); // 间距改变需要重新布局
}

为什么要重写generateDefaultLayoutParams? 因为Margin的值 在自定义 ViewGroup 时重写 generateDefaultLayoutParams关键步骤,主要原因如下:

核心问题 :FlowLayout 需要处理子 View 的 margin 属性

  • 默认的 ViewGroup.generateDefaultLayoutParams() 返回的是基础 LayoutParams
  • 基础 LayoutParams 不包含 margin 属性
避免类型转换异常

使用的时候用到了

ini 复制代码
// 获取子View的LayoutParams(我们使用MarginLayoutParams)
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
typescript 复制代码
// 以下方法提供对MarginLayoutParams的支持
@Override
protected LayoutParams generateDefaultLayoutParams() {
    return new MarginLayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}

@Override
protected LayoutParams generateLayoutParams(LayoutParams p) {
    return new MarginLayoutParams(p);
}

@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
    return new MarginLayoutParams(getContext(), attrs);
}

不重写的后果

  1. XML 中的 layout_margin 属性被忽略
  2. 代码中获取 margin 时崩溃(ClassCastException)
  3. 子 View 默认变成 MATCH_PARENT 尺寸(破坏流式布局

这就是为什么所有需要处理 margin 的自定义 ViewGroup 都必须重写此方法(如 LinearLayout、RelativeLayout 源码中都有类似实现)

3.2 动态布局Layout

  • 布局流程:
    1. 遍历所有子View
    1. 计算每个子View的位置
    1. 当一行放不下时换行
    1. 调用child.layout()确定子View的最终位置 */
ini 复制代码
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childLeft = getPaddingLeft();    // 当前子元素X起点
    int childTop = getPaddingTop();      // 当前子元素Y起点
    int lineHeight = 0;                 // 当前行高度
    
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == GONE) continue;
        
        MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
        int childWidth = child.getMeasuredWidth();
        int childHeight = child.getMeasuredHeight();
        
        // 换行条件:当前行剩余空间不足
        if (childLeft + childWidth + lp.leftMargin + lp.rightMargin > getWidth() - getPaddingRight()) {
            childLeft = getPaddingLeft();                // 重置X坐标
            childTop += lineHeight + verticalSpacing;     // 下移Y坐标
            lineHeight = 0;                              // 重置行高
        }
        
        // 计算子元素位置(考虑margin)
        int left = childLeft + lp.leftMargin;
        int top = childTop + lp.topMargin;
        child.layout(left, top, left + childWidth, top + childHeight);
        
        // 更新布局位置
        childLeft += childWidth + lp.leftMargin + lp.rightMargin + horizontalSpacing;
        lineHeight = Math.max(lineHeight, childHeight + lp.topMargin + lp.bottomMargin);
    }
}
方法 作用 关键说明
getWidth() 获取容器最终宽度 仅在布局阶段有效
child.getMeasuredWidth()/Height() 获取子View最终尺寸 布局时使用测量结果
child.layout(int l, int t, int r, int b) 确定子View位置 必须为每个子View调用

3.2.1 测量和布局的核心方法总结

方法 测量阶段 布局阶段 注意要点
getMeasuredWidth() ✅ 主要使用 ✅ 使用测量结果 值在measureChild后有效
getWidth() ❌ 值为0 ✅ 主要使用 布局完成后才有实际值
measureChild() 必须调用 ❌ 禁止调用 布局阶段不可再测量
child.layout() ❌ 禁止调用 必须调用 每个可见子View都要调用
getLayoutParams() ✅ 获取margin ✅ 获取margin 需要类型转换

3.3 滑动

3.3.1 触摸事件处理 (onTouchEvent)

csharp 复制代码
@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_MOVE:
            if (mIsDragging) {
                float dy = mLastY - event.getY();
                scrollBy(0, (int) dy); // 核心滚动方法
                mLastY = event.getY();
            }
            break;
            
        case MotionEvent.ACTION_UP:
            if (mIsDragging) {
                // 获取Y轴速度
                float yVelocity = mVelocityTracker.getYVelocity();
                if (Math.abs(yVelocity) > mMinimumVelocity) {
                    fling(-(int) yVelocity); // 触发惯性滚动
                }
            }
            break;
    }
}
  • 拖动处理 :计算手指移动距离,调用 scrollBy() 实时滚动
  • 惯性滚动:手指抬起时检测速度,满足条件触发 fling

3.3.2 滚动边界控制 (scrollTo)

java 复制代码
@Override
public void scrollTo(int x, int y) {
    // 计算最大可滚动范围
    int maxScrollY = mContentHeight - getHeight() + getPaddingBottom();
    // 限制Y坐标在[0, maxScrollY]范围内
    int clampedY = Math.max(0, Math.min(y, maxScrollY));
    super.scrollTo(x, clampedY);
}
  • 防止滚动超出内容边界
  • 顶部边界:0(不可滚动到paddingTop上方)
  • 底部边界:内容总高度 - 容器高度 + paddingBottom
3.3.3 惯性滚动实现 (fling)
scss 复制代码
private void fling(int velocityY) {
    mScroller.fling(
        getScrollX(), 
        getScrollY(),
        0, 
        velocityY,
        0, 0, 
        0, 
        mContentHeight - getHeight()
    );
    invalidate(); // 触发重绘
}
  • 使用 Scroller.fling() 实现平滑滚动动画

  • 参数说明:

    • 起始位置:当前滚动位置 (getScrollX/Y)
    • 速度:Y轴方向速度
    • 边界:0 到 内容总高度-容器高度
  • 最大滚动距离 = 内容总高度 - 容器高度

  • 公式:maxScrollY = mContentHeight - getHeight() + getPaddingBottom()

3.3.4 滚动动画驱动 (computeScroll)
scss 复制代码
@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate(); // 持续更新
    }
}
  • Scroller 计算动画每一帧的位置
  • 调用 scrollTo() 更新滚动位置
  • 通过 postInvalidate() 持续重绘直到动画结束

为什么要重写scrollTo()方法 防止滚动越界 ,不重写的后果:用户可能将内容滚动到无效区域,出现空白或错位

scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); // 把这里换成mScroller.startScroll()scrollTo() 替换为 startScroll()错误的做法,因为这两个方法的作用完全不同

关键区别说明

方法 作用 调用频率 典型使用场景
startScroll() 启动新动画 单次 触摸抬起时启动 fling
computeScrollOffset() 计算当前帧位置 每帧 computeScroll() 内循环调用
getCurrX()/getCurrY() 获取当前帧位置 每帧 配合 scrollTo() 使用
scrollTo() 执行实际滚动 每帧 将计算结果应用到视图

区别和联系图

  1. computeScroll() 是动画执行器:负责逐帧更新位置
  2. startScroll() 是动画配置器:只应在启动新动画时调用
  3. scrollTo() 是滚动执行器:将计算结果应用到视图
  4. 三者各司其职:错误替换会破坏整个滚动机制

保持 computeScroll() 中的 scrollTo(mScroller.getCurrX(), mScroller.getCurrY()) 是实现流畅滚动动画的核心,这是 Android 滚动机制的标准实现方式。

4. 基于ScrollView的实现原理

scss 复制代码
public class ScrollViewFlowLayout extends ScrollView {
    private FlowLayout flowLayout;
    private int horizontalSpacing = 16;
    private int verticalSpacing = 16;

    public ScrollViewFlowLayout(Context context) {
        super(context);
        init();
    }

    public ScrollViewFlowLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public ScrollViewFlowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        // 禁用ScrollView的滚动条
        setVerticalScrollBarEnabled(false);
        setHorizontalScrollBarEnabled(false);

        // 创建内部的FlowLayout
        flowLayout = new FlowLayout(getContext());
        flowLayout.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
        addView(flowLayout);
    }

与直接继承实现的对比

实现方式 优点 缺点
组合方式(本类) 1. 复用系统ScrollView 2. 避免重写滚动逻辑 3. 开发简单快速 1. 嵌套一层布局 2. 需处理布局参数传递
直接继承(ViewGroup) 1. 无布局嵌套 2. 完全控制滚动行为 1. 需完整实现滚动逻辑 2. 需处理触摸事件拦截 3. 需实现fling效果

5.架构图

6.优化总结

流式布局和滚轮选择器滚动的核心区别是什么?

流式布局:**scrollTo**滚动是通过直接滚动

滚轮选择器通常通过,重置绘制,进行滚动效果, onDraw() 直接绘制文本项

startScroll scrollTo()

组件 核心目标 滚动机制
流式布局(ScrollableFlowLayout) 内容容器 管理子View布局 基于 scrollTo() 的物理滚动
滚轮选择器(WheelPicker) 选择器UI 模拟物理滚轮效果 基于 虚拟位置 的动画

7.源码

项目的地址:github.com/pengcaihua1...

相关推荐
崔庆才丨静觅7 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60618 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了8 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅8 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅8 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅9 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅9 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊9 小时前
jwt介绍
前端
爱敲代码的小鱼9 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax