自定义 View 真正难的地方,很少是 onDraw。
难的是两件事:把测量和布局算对,以及和父布局协商谁来消费滚动。前者决定你的容器在各种尺寸下会不会崩,后者决定它能不能和 AppBar、BottomSheet、下拉刷新这些系统组件配合。
这篇把 12 个原本散落的实战问题串成一条线:先立契约(LayoutParams),再讲三类自定义容器(换行、瀑布流、吸顶),然后是这套体系里最关键的滚动协议,最后是依赖它的协作组件(CoordinatorLayout、折叠标题栏、PopupWindow)。
每一节都按同一个结构写:机制是什么 → 最容易踩的三个坑 → 什么时候不该用它。
一、先立契约:LayoutParams
LayoutParams 是子 View 交给父布局的布局契约——宽高、margin,以及具体父类型的附加字段都在里面。LinearLayout.LayoutParams 有 weight,RelativeLayout 有规则,ConstraintLayout 有约束。XML 里写的属性,最终都会变成对应类型的 LayoutParams。
理解它,才能正确做动画占位和运行时改布局。
三个容易踩的坑
- 类型必须等于直接父布局。往 LinearLayout 里塞 RelativeLayout 的 params,会崩溃或被静默忽略。
MATCH_PARENT和WRAP_CONTENT是特殊常量。测量时父布局按 Mode 解释,不是 "0" 和 "-2" 的字面意思。- 改 params 要通知布局。只改字段不 set 回去,界面不会动;频繁改应合并成一次
requestLayout()。
什么时候用:运行时增删控件、改宽高,或对接自定义 ViewGroup 时必须正确构造 LayoutParams。纯静态 XML 页面很少直接碰。遇到 ClassCastException,先看父布局类型,再看 inflate 时有没有带正确的 generateLayoutParams。
二、换行类容器:一个算法,三种场景
这是最常被重复造轮子的一类。流式布局、标签换行、子 View 自动排列容器,底层是同一个算法:从左到右填充,行满换行,类似 CSS 的 flex-wrap。
它比线性布局灵活,比网格布局更能吃进不等宽的孩子。
测量阶段:收集每个孩子的尺寸并模拟折行。要量两次——先让 child measure,再根据折行结果定自身高度;width 为 match_parent 时用剩余空间。
布局阶段:按模拟结果摆放。记录每行起点和行高,GONE 跳过,margin 加进占用宽度。
四个容易踩的坑
- 单标签要限制最大宽度。超过父宽度减去 padding 就必须 ellipsize,否则一行被撑破。这是标签场景独有的必修项。
- 行高用该行最高的孩子。混排图标和文字时,不能按第一个标签的高度一刀切。
- 不要在
onLayout里改 LayoutParams。那会触发新一轮请求,容易死循环;动态增删应该在数据层做。 - 数据源保持线性。换行只是视觉,点击和增删仍按一维列表处理,不要按行列二维存。
几个具体场景的注意点
- 搜索历史、商品标签、兴趣选择:短文本集合,先满足换行和间距,再加选中态。
- 单标签过宽和 RTL 方向:这是自己写时最大的两个坑,容易被忽略。
- 对齐是独立规则:左对齐、两端对齐和居中,需要在行内二次分配剩余空间。
GONE和加权要定义清楚:流式布局一般不搞 weight,硬加会让折行行为难以理解。- 最后一行是否拉伸、divider 怎么处理,都要事先定好。
什么时候不该自己写:Google 的 FlexboxLayout 已经覆盖大多数需求。自己写主要用于裁剪依赖、教学,或要极致包体积与特殊手势。规则网格用 GridLayout,可滚动的大量标签用 RecyclerView + Flex 或自定义 LayoutManager。
三、瀑布流:把列高当成状态
瀑布流要把不同高度的卡片落到最短列上,形成参差布局。
核心是维护一个每列当前底部坐标的数组。onLayout 时把下一个 child 放到最短列,并更新该列高度。
三个容易踩的坑
- 列高数组是状态,不是索引轮询。每放一个孩子就更新最短列;如果按
index % 列数轮询,结果会退化成整齐网格,失去瀑布流的意义。 - 自身高度取最长列。
wrap_content时必须用各列最大值,漏算会导致底部被裁切。 - 图片高度异步回来要重新 layout。占位高度一变就会打乱后面所有列,需要稳定的测量缓存,或主动通知父布局。
什么时候不该自己写:展示少量图文卡片、不需要复用时,可以自定义瀑布容器。信息流、无限加载必须用 StaggeredGridLayoutManager——条目一多,自定义 ViewGroup 的测量会在主线程爆炸。实现前先定列数和间距,再写测量,最后才接图片加载。
四、吸顶:画悬停层,不要搬 View
吸顶布局让某一组的标题在滚动时钉在顶部,直到下一组标题顶上来替换。通讯录、订单分组、商品类目都常用。
RecyclerView 可以用 ItemDecoration 绘制吸顶,也可以用 Sticky 第三方库,或 ConcatAdapter 把标题当独立 item 再配合自定义 LayoutManager。CoordinatorLayout 的 pin 是另一种吸顶,面向 AppBar。
三个容易踩的坑
- 数据要带分组锚点。每个 item 得知道自己的组头位置,Decoration 才能算出该钉哪一个标题。
- 绘制吸顶不要改真实 item 位置。用
canvas画悬停层即可,把 View 从树上撕下来会引发一堆问题。 - 点击要单独分发。悬停层接收点击跳转组头,不能点到下面正在滚的 item 上,否则会出现"点击穿透"。
什么时候用:长列表分组浏览、需要持续看到当前组名时做吸顶。短列表或单组用普通标题就够。先保证分组数据正确,再画悬停,最后加动画。
五、嵌套滚动:Android 滚动的底层协议
到这一节才进入正题。前面几个容器一旦要和外部滚动协作,就绕不开这套协议。
NestedScrollingParent 和 NestedScrollingChild 是嵌套滚动协议的两端。子 View 在触摸时先问父要不要预消耗,自己滚完再把剩余量交给父。CoordinatorLayout、RecyclerView 和 NestedScrollView 都实现了这套接口。
自己做头部跟随、底部面板和自定义吸顶,往往要当 Parent 或 Child。NestedScrollingChild3 还区分触摸和非触摸滚动,惯性也能传递。
三个容易踩的坑
- 顺序是
start → preScroll → scroll → preFling → fling。漏掉start或类型不匹配,父布局就收不到增量。 consumed数组是双向账本。子要如实填写已消耗的像素,父才能决定 AppBar 收起多少。- 用 Helper 类能少写错。
NestedScrollingChildHelper和NestedScrollingParentHelper帮你维护窗口偏移和类型。
什么时候用:自定义可滚动容器要和系统 AppBar、BottomSheet 合作时,必须实现这对接口。普通业务页面用现成控件即可。调试时打印 type 和 dy,确认到底是谁把滚动吃掉了。
六、NestedScrollView:什么时候不该用
NestedScrollView 是支持嵌套滚动协议的 ScrollView,可以把未消耗的滚动量交给父布局——比如 CoordinatorLayout 里的 AppBar。
普通 ScrollView 在新版本也实现了嵌套接口,但老代码和部分场景仍要显式用 NestedScrollView。它适合中等长度的表单和详情,不适合超长列表。
三个容易踩的坑
- 嵌套滚动靠接口,不是靠名字。父要是
NestedScrollingParent,子要 dispatch 嵌套滚动,否则 AppBar 不跟手。 - 不要把无限列表塞进去。ScrollView 会让 RecyclerView 量出全部高度,回收直接失效,同时会报高度测量为
UNSPECIFIED的性能问题。内部再嵌 RecyclerView 时,要明确谁来滚动,避免双重滚动条。 fillViewport用于内容不足一屏。表单底部按钮贴底时用它,而不是写死屏幕高度。
什么时候用:详情页、设置页,以及要配合折叠头图的中等内容。Feed 流用 RecyclerView。出现滑动传递失败时,先查父布局是不是 Coordinator,再查有没有中间层拦截。
七、联动滚动:比例映射与防回环
联动滚动指两个可滚动区域共享进度——左侧目录跟右侧内容、头部 banner 跟列表、横向指示器跟 ViewPager。
实现可以监听一方的 scroll 再滚动另一方,也可以用 NestedScrolling 把增量交给父布局统一分发。
三个容易踩的坑
- 用标志位防回环。A 滚动带动 B 之后,B 的回调不能再反过来驱动 A,否则会抖动或飞出边界。
- 进度用比例而不是像素。两边内容高度不同时,应对齐百分比或锚点,而不是 1:1 平移。
- 惯性与吸附分开处理。手指抬起后要对齐到 item,不能只在
onScroll里setScrollY。
什么时候用:双栏目录、沉浸头图和指示器同步时做联动滚动。单列表内部吸顶用 LayoutManager 更合适。先把锚点列表定义清楚,再写监听,最后加动画。
八、CoordinatorLayout:Behavior 决定一切
CoordinatorLayout 是 Material 里的超级 FrameLayout,用 Behavior 描述子 View 之间如何响应滚动和依赖。AppBar、FAB、Snackbar 和 BottomSheet 都靠它协作。
它不是普通的垂直线性容器——孩子默认叠放,位置和行为由 Behavior 和锚点决定。
三个容易踩的坑
- Behavior 是核心 API。自己写时实现
layoutDependsOn和onDependentViewChanged,不要靠 margin 硬凑位置。 - 只保留一个嵌套滚动父。内容滚动应交给 Coordinator,孩子用
NestedScrollingChild把增量交上去。 - 锚点用于 FAB 这类悬浮件。
layout_anchor和gravity决定它跟谁走;换依赖时要同步改 XML。
什么时候用:需要 AppBar、FAB、BottomSheet 协作时用 CoordinatorLayout,普通页面用 ConstraintLayout。滥用多层 Coordinator 会让事件分发很难查——出问题时先看哪个 Behavior 吃掉了滚动,再用 Layout Inspector 看依赖链。
九、折叠标题栏:scrollFlags 要配对
折叠标题栏是 AppBarLayout + CollapsingToolbarLayout 的经典组合:大图或大标题随滚动收缩成 Toolbar。
内容区用 NestedScrollView 或 RecyclerView,通过 CoordinatorLayout 把滚动传给 AppBar。scrollFlags 决定折叠、进场和吸附行为。
它解决的是视觉层次,不是性能问题——重内容仍然要靠列表回收。
三个容易踩的坑
scrollFlags要配对。exitUntilCollapsed适合图片头,snap会在中途吸附;乱加 flag 会抖。- 内容必须是嵌套滚动子。普通 ScrollView 传不动,要用 NestedScrollView 或 RecyclerView。
- 状态栏和 Toolbar 高度要算进折叠距离。
fitsSystemWindows和 margin 配错,会出现露缝或遮挡。
什么时候用:个人主页、文章头图、商品大图详情适合折叠标题栏,普通列表页不必。先把 Coordinator 结构搭对,再调动画和颜色。
十、PopupWindow:附着窗口的生命周期
PopupWindow 是挂在窗口上的浮动面板,用来做下拉菜单、气泡提示和自定义弹出层。
它不走 Dialog 的窗口主题,位置相对锚点 View 计算。showAsDropDown 和 showAtLocation 是两条入口。软键盘、屏幕旋转和锚点被回收,是最常见的崩溃点。
较新的 API 推荐 PopupWindow 配合过渡动画,或改用 PopupMenu、ListPopupWindow 和 Material 的 Popup。
三个容易踩的坑
- 宽高不要 wrap 到测量前。内容还没 layout 时
getWidth为 0,位置会飘——先 measure 再 show。 - 锚点的生命周期要长于弹窗。Activity 销毁后还 show 会
WindowLeaked,要在onDestroy里 dismiss。 - 焦点与输入法。可输入的弹层要
setFocusable(true),否则 EditText 弹不出键盘。自己写时还要处理外部点击消失和 elevation。
什么时候用:需要贴着某个按钮弹出轻量菜单时用 PopupWindow;模态阻断操作用 Dialog。能用系统 PopupMenu 就不要从零写,除非要完全自定义布局和动画。
一张选型表
| 需求 | 用什么 | 不要用什么 |
|---|---|---|
| 运行时改宽高 / 增删控件 | 正确构造 LayoutParams | 直接改 getWidth |
| 不等宽孩子自动换行 | FlexboxLayout 或自定义流式容器 |
硬塞 LinearLayout |
| 少量图文卡片参差排列 | 自定义瀑布容器 | — |
| 信息流、无限加载 | StaggeredGridLayoutManager |
自定义瀑布 ViewGroup |
| 长列表分组浏览 | ItemDecoration 画吸顶 |
把 View 从树上摘下来 |
| 详情页 / 设置页(中等内容) | NestedScrollView + fillViewport |
把 RecyclerView 塞进 ScrollView |
| AppBar / FAB / BottomSheet 协作 | CoordinatorLayout + Behavior |
多层 Coordinator 嵌套 |
| 大图头收缩成 Toolbar | CollapsingToolbarLayout + scrollFlags |
手写高度动画 |
| 自定义容器要接系统滚动 | 实现 NestedScrollingParent/Child | 只监听 onScroll |
| 贴着按钮弹轻量菜单 | PopupMenu / PopupWindow |
从零写弹窗 |
| 模态阻断操作 | Dialog | PopupWindow |
本文由 12 篇实战笔记合并整理:LayoutParams、流式布局、标签换行、子 View 自动排列、瀑布流容器、吸顶布局、NestedScrolling 协议、NestedScrollView、联动滚动、CoordinatorLayout、折叠标题栏、PopupWindow。
Android 自定义 View 实战:测量、布局与滚动协商
https://lautung.com/archives/android-custom-view-in-practice
评论