现象
两个看起来无关的 bug,根因是同一个模式的两次踩坑。
现象 1:下级内容展不开 / 被截断
分组展开后,靠下的内容看不见,像被切掉一样。分组层级越深、子项越多越明显。
现象 2:展开后要往回滚
先展开一个子项很多的分组 A → 往下滚 → 展开下面的分组 B。B 展开了,但内容跑到视口上方很远的地方,用户得手动往回滚才能看到。
一、为什么展开动画不能用固定 max-height
height: auto 不可过渡
CSS 过渡需要两个可插值的端点。height: auto 是一个计算后的确定值,但浏览器不知道过渡的起点/终点该怎么算,所以 height: auto ⇄ 具体值 无法过渡(transition-behavior: allow-discrete 之外,auto 至今不是可插值的关键字)。
于是常见的权宜之计是:收起用 max-height: 0,展开给一个「足够大」的固定值。
.panel { overflow: hidden; max-height: 0; transition: max-height 0.3s ease; &.expanded { max-height: 1200rpx; /* ← 权宜之计,也是 bug 的来源 */ }}
这个写法有两个独立的毛病
(1)max-height 是「上限」,不是「实际高度」
内容超过这个值就直接被 overflow: hidden 裁掉 —— 这就是现象 1。加得再大也只是把阈值推后,阈值之上仍然会裁。长度不确定的内容,永远可能超过任何一个写死的值。
(2)动画时长会失真
max-height 的过渡速度是按值域匀速分摊的。0 → 6000rpx 配 0.3s,意味着前 0.3s 里高度应该涨 6000rpx。可如果内容实际只有 300rpx,那 300rpx 会在过渡开始的前 5%(约 15ms)内就涨完 —— 视觉上就是「啪」一下弹开,动画等于没有。
内容越短越像瞬开,越长越像慢放。固定值越大,失真越严重。
所以「把值调大一点」同时恶化了两个问题。
二、正确做法:JS 量测真实高度
思路很直接:展开时把内容实际占多高量出来,再用这个值去做过渡。
// 量测:取容器内所有子项的高度之和const measure = (scopeId) => new Promise((resolve) => { const query = uni.createSelectorQuery().in(instance); query.selectAll(`#${scopeId} .panel-item`).boundingClientRect(); query.exec((res) => { const rects = res?.[0] || []; resolve(rects.reduce((sum, r) => sum + (Number(r?.height) || 0), 0)); }); });
<view :id="'panel-' + group.id" class="panel" :class="{ expanded: group.expanded }" :style="heights[group.id] ? { maxHeight: heights[group.id] } : null">
.panel { overflow: hidden; max-height: 0; transition: max-height 0.3s ease; /* 关键:展开态不再给固定高度,只切换其它属性。 高度由内联 style 给出 —— 量测落盘前它仍是 0, 落盘后从 0 过渡到真实高度,动画天然正确。 */ &.expanded { opacity: 1; }}
这样动画端点始终是 0 → 真实高度,既不会裁切,也不会失真。
时序陷阱:必须「先渲染,再量测」
这是最容易写错的地方。 直觉上会想「先量好高度、再展开」,但:
收起状态下,子项往往根本没有渲染(
v-if/ 空数组裁剪),容器里没有任何东西可量。
所以顺序只能是:
点击 → 先切换展开状态(子项进入渲染树) → 等一帧(nextTick,确保布局完成) → 量测真实高度 → 写入 max-height
而这时容器还是 max-height: 0 —— 子项虽然被裁掉不可见,但布局是存在的,boundingClientRect 拿到的仍是它们真实的排版矩形。所以「被裁掉」不影响量测。
const syncHeight = async (groupId) => { await nextTick(); const h = await measure(`panel-${groupId}`); heights.value = { ...heights.value, [groupId]: h > 0 ? `${h}px` : FALLBACK };};
必须给失败兜底
量测失败(布局未就绪、查询取不到)时返回 0,如果照写不误,max-height 就停在 0 —— 内容彻底展不开,比改之前还糟。
let h = await measure(id);if (!h) { await nextTick(); // 偶发未就绪 → 再等一帧重试 h = await measure(id);}// 仍失败则兜底一个大值:宁可丢掉动画,也不能把内容裁掉heights.value = { ...heights.value, [id]: h > 0 ? `${h}px` : '6000rpx' };
兜底值只服务于「量测失败」这条异常路径,不作为常规手段 —— 常规路径一定是真实高度,否则又回到第一节的两个毛病。
量测缓存要失效
可见子项集合会随展开链变化(展开 A 的分支和展开 B 的分支,visibleItems 完全不同)。所以每次切换展开状态都应清空缓存,下次展开重新量:
heights.value = {};
三、单开手风琴特有的滚动跳位
为什么只有「单开」有这个问题
单开手风琴(展开新分组时自动收起上一个)的语义是:同一时刻至多一个分组展开。
这在状态上很干净,但会引入一个纯几何问题:
展开前 展开后┌──────────┐ ┌──────────┐│ 分组 A │ ← 展开,很高 │ 分组 A │ ← 收起,塌成一行│ 子项… │ ├──────────┤│ 子项… │ │ 分组 B │ ← 用户点的行,上移了 A 的高度│ 子项… │ │ 子项… │├──────────┤ │ 子项… ││ 分组 B │ ← 用户在这里点 └──────────┘└──────────┘
A 塌陷掉的高度直接把 B 整体上移。A 越是长(现在能完整展开了,就更长),B 上移得越远 —— 用户点完 B,视线里已经不是 B 了。
这就是现象 2。它和「展开高度」那个问题正交,是多开模式不会遇到的。
解决:把点的那一行滚回可视区
<scroll-view scroll-y :scroll-into-view="scrollTarget"> <view :id="'row-' + group.id">…</view> <view v-for="item in group.visibleItems" :id="'row-' + item.id">…</view></scroll-view>
// 展开时nextTick(() => { scrollTarget.value = `row-${key}`;});
为什么放在 nextTick 里:要等上一个分组收起、布局重排完成之后再去滚,否则滚的是旧布局里的位置,等于白滚。
滚「被点的行」而不是滚「展开出来的内容」:行落在滚动容器顶部、子项在下方依次展开,符合阅读顺序。
坑:scroll-into-view 的值不变就不会触发
这是声明式滚动属性的通病 —— 它比较的是值有没有变,而不是「有没有要求滚动」。同一个值赋两次,第二次是空操作。
于是这个序列会失败:
展开 A → target = 'row-A'收起 A → target 仍是 'row-A'再展开 A → target = 'row-A' ← 值没变,不滚!
解法:在收起时、以及容器重新打开时,把值复位成空串。
// 收起时scrollTarget.value = '';// 容器(弹窗/抽屉)打开、重置内部状态时scrollTarget.value = '';
这样每次展开都是「空串 → 某 id」,必然是变化,必然触发。空串本身不会引起滚动。
已知边界
被点的是最后一个分组、下方内容不足以把它顶到顶部时,滚动只能到最大位置,该行会停在偏下的位置。这种情况下子项可能仍需往下滚一点 —— 但不会再出现「跑到上方去了」。
要彻底消除这个边界,得改成滚动补偿:记录收起前该行的视口 Y,重排后算出新的视口 Y,用差值反推 scrollTop。代价是要读写 scrollTop(小程序里它有「同值不触发」的同类问题,需要额外处理),复杂度明显更高。多数场景下 scroll-into-view 已经够用。
四、Checklist
实现一个带动画的折叠面板时,逐条对照:
- [ ] 收起态用
max-height: 0+overflow: hidden,展开态不给固定max-height - [ ] 高度由 JS 量测后以内联
max-height给出 - [ ] 顺序是「先切换展开状态 →
nextTick→ 量测 → 写高度」,不能先量后展开 - [ ] 量测失败有重试 + 兜底值,保证内容至少能显示
- [ ] 每次切换展开状态清空量测缓存
- [ ] 单开模式下,展开后把被点的行滚回可视区(
scroll-into-view),放在nextTick里 - [ ]
scroll-into-view在收起时和容器重置时复位为空串 - [ ] 量测的作用域选择器用唯一 id 前缀(如
#panel-<id>),避免多实例互相选中 - [ ] id 不能以数字开头(CSS 选择器限制),所以一定要带字母前缀
附:其它方案为什么不用
| 方案 | 为什么不采用 |
|---|---|
height: auto + transition |
auto 不是可插值关键字,无法过渡 |
固定 max-height |
会裁切 + 动画时长失真(见第一节) |
固定 max-height 但「给大一点」 |
只把阈值推后,两个毛病都还在,且动画失真更严重 |
transform: scaleY() |
会拉伸内容文字,不可接受 |
网格 grid-template-rows: 0fr → 1fr |
现代方案,但在小程序等环境的兼容性不如 max-height |
直接 v-if 不做动画 |
可行但丢失过渡,且本问题的滚动跳位依然存在 |
读取 scrollHeight |
小程序没有 DOM,取不到;只能用 boundingClientRect 量排版矩形 |
一句话总结
展开动画的高度必须来自真实量测,且量测只能发生在内容渲染之后;单开模式下还要额外把被点的行滚回可视区 —— 因为收起一个长分组会把下面的内容整体顶上去。
配套的两个细节最容易漏:量测失败时内容会彻底不可见(要兜底),以及 scroll-into-view 值不变不触发(要复位)。