单开手风琴组件常见的展开动画与滚动定位问题

现象

两个看起来无关的 bug,根因是同一个模式的两次踩坑。

现象 1:下级内容展不开 / 被截断

分组展开后,靠下的内容看不见,像被切掉一样。分组层级越深、子项越多越明显。

现象 2:展开后要往回滚

先展开一个子项很多的分组 A → 往下滚 → 展开下面的分组 B。B 展开了,但内容跑到视口上方很远的地方,用户得手动往回滚才能看到。


一、为什么展开动画不能用固定 max-height

height: auto 不可过渡

CSS 过渡需要两个可插值的端点。height: auto 是一个计算后的确定值,但浏览器不知道过渡的起点/终点该怎么算,所以 height: auto ⇄ 具体值 无法过渡(transition-behavior: allow-discrete 之外,auto 至今不是可插值的关键字)。

于是常见的权宜之计是:收起用 max-height: 0,展开给一个「足够大」的固定值。

scss
.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 → 6000rpx0.3s,意味着前 0.3s 里高度应该涨 6000rpx。可如果内容实际只有 300rpx,那 300rpx 会在过渡开始的前 5%(约 15ms)内就涨完 —— 视觉上就是「啪」一下弹开,动画等于没有。

内容越短越像瞬开,越长越像慢放。固定值越大,失真越严重。

所以「把值调大一点」同时恶化了两个问题。


二、正确做法:JS 量测真实高度

思路很直接:展开时把内容实际占多高量出来,再用这个值去做过渡。

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));    });  });
html
<view  :id="'panel-' + group.id"  class="panel"  :class="{ expanded: group.expanded }"  :style="heights[group.id] ? { maxHeight: heights[group.id] } : null">
scss
.panel {  overflow: hidden;  max-height: 0;  transition: max-height 0.3s ease;  /* 关键:展开态不再给固定高度,只切换其它属性。     高度由内联 style 给出 —— 量测落盘前它仍是 0,     落盘后从 0 过渡到真实高度,动画天然正确。 */  &.expanded {    opacity: 1;  }}

这样动画端点始终是 0 → 真实高度,既不会裁切,也不会失真。

时序陷阱:必须「先渲染,再量测」

这是最容易写错的地方。 直觉上会想「先量好高度、再展开」,但:

收起状态下,子项往往根本没有渲染v-if / 空数组裁剪),容器里没有任何东西可量。

所以顺序只能是:

code
点击  → 先切换展开状态(子项进入渲染树)  → 等一帧(nextTick,确保布局完成)  → 量测真实高度  → 写入 max-height

而这时容器还是 max-height: 0 —— 子项虽然被裁掉不可见,但布局是存在的boundingClientRect 拿到的仍是它们真实的排版矩形。所以「被裁掉」不影响量测。

js
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 —— 内容彻底展不开,比改之前还糟

js
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 完全不同)。所以每次切换展开状态都应清空缓存,下次展开重新量:

js
heights.value = {};

三、单开手风琴特有的滚动跳位

为什么只有「单开」有这个问题

单开手风琴(展开新分组时自动收起上一个)的语义是:同一时刻至多一个分组展开

这在状态上很干净,但会引入一个纯几何问题:

code
展开前                        展开后┌──────────┐                 ┌──────────┐│ 分组 A    │  ← 展开,很高     │ 分组 A    │  ← 收起,塌成一行│  子项…    │                 ├──────────┤│  子项…    │                 │ 分组 B    │  ← 用户点的行,上移了 A 的高度│  子项…    │                 │  子项…    │├──────────┤                 │  子项…    ││ 分组 B    │  ← 用户在这里点   └──────────┘└──────────┘

A 塌陷掉的高度直接把 B 整体上移。A 越是长(现在能完整展开了,就更长),B 上移得越远 —— 用户点完 B,视线里已经不是 B 了。

这就是现象 2。它和「展开高度」那个问题正交,是多开模式不会遇到的。

解决:把点的那一行滚回可视区

html
<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>
js
// 展开时nextTick(() => {  scrollTarget.value = `row-${key}`;});

为什么放在 nextTick:要等上一个分组收起、布局重排完成之后再去滚,否则滚的是旧布局里的位置,等于白滚。

滚「被点的行」而不是滚「展开出来的内容」:行落在滚动容器顶部、子项在下方依次展开,符合阅读顺序。

坑:scroll-into-view 的值不变就不会触发

这是声明式滚动属性的通病 —— 它比较的是值有没有变,而不是「有没有要求滚动」。同一个值赋两次,第二次是空操作。

于是这个序列会失败:

code
展开 A        → target = 'row-A'收起 A        → target 仍是 'row-A'再展开 A      → target = 'row-A'   ← 值没变,不滚!

解法:在收起时、以及容器重新打开时,把值复位成空串。

js
// 收起时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 值不变不触发(要复位)。