【论术】折叠/收起功能
眼前路径须放开阔,才好容人来往,若太拘窄,恐自己亦无展足之地矣。 ---王阳明-
需求:tag列表由于过多,需要有折叠能力,一行内正常展示,大于一行则须折叠,默认折叠态,折叠时按钮展示+剩余tag数量,点击后展开全部,展开后按钮变成收起,始终在最后一行展示,如果最后一行恰好占满,则另起一行位于右侧
当下有了ai,代码的实现早已不是什么难题,思想方面也要与过去做切割,不再思考代码实现的开发,而是着眼于以下三点:
- 站在产品的角度去思考这个需求设计的目的,思考这个需求是为了满足什么,实现什么,应该有怎样的交互,如何更好的实现它,它的本质需求究竟是什么,面对某些物理限制或者现实限制,有没有曲线救国以达到目的思路,但不管怎么说,这一步的最低目标是为了生成最小化可实现的产品/需求
- 站在测试的角度上去考虑这个需求的实现的逻辑边界,要注意的是在什么时机下防止出现什么问题,在代码层面要去规避它,这一步的目的是为了提高代码在业务层面的健壮性。
- 如果前两步是为了完成在业务层面的工作,那么这一步是为了完成开发本身岗位的工作。 完成上述两点后,再站在开发自己的角度去考虑当前代码可能会出现什么问题,或封装,或优化,或重构,这一步是需求在代码层面的收尾。
不管怎么变,不管未来是何种身份,只要还是名义上的开发,大多都绕不过上述三点,尤其以当前ai形势下更甚。
在面对一个新需求时,也是分别按照上述步骤来做,注意不可以层次颠倒,这一点不可取,也是大忌。但是在一个开发周期内,它们也有可能并行,其中差异,则需要读者权衡,但是根本目的就是在维稳的前提下做事。
做事情,要先小步快跑,起码出来一个马马虎虎的东西,而不是全靠自己想,被自己想象的还没成型的困难苦苦缠绕,这是最危险的误区,也是前几年犯的最大的错误。
说回来。
//main.jsx
import React, { useLayoutEffect, useMemo, useRef, useState } from "react";
import "./carbon-copy-fold.css";
const GAP = 8;
export default function CarbonCopyFold({ users = [] }) {
const [expanded, setExpanded] = useState(false);
const [visibleCount, setVisibleCount] = useState(users.length);
const containerRef = useRef(null);
const moreMeasureRef = useRef(null);
const collapseMeasureRef = useRef(null);
const itemMeasureRefs = useRef([]);
const total = users.length;
const hiddenCount = Math.max(0, total - visibleCount);
const needToggle = total > 0 && (expanded || visibleCount < total);
const displayed = useMemo(() => {
return expanded ? users : users.slice(0, visibleCount);
}, [users, expanded, visibleCount]);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container || total === 0) {
setVisibleCount(total);
return;
}
const measure = () => {
const containerWidth = container.clientWidth;
const moreW = moreMeasureRef.current?.offsetWidth || 0;
const collapseW = collapseMeasureRef.current?.offsetWidth || 0;
const toggleW = Math.max(moreW, collapseW, 1);
const widths = itemMeasureRefs.current
.slice(0, total)
.map((el) => el?.offsetWidth || 0);
let used = 0;
let fitCount = 0;
let overflow = false;
for (let i = 0; i < widths.length; i++) {
const next = fitCount === 0 ? widths[i] : used + GAP + widths[i];
if (next <= containerWidth) {
used = next;
fitCount++;
} else {
overflow = true;
break;
}
}
if (!overflow) {
setVisibleCount(total);
return;
}
// 给右侧按钮预留空间(+N / 收起 取更宽者)
while (fitCount > 0 && used + GAP + toggleW > containerWidth) {
used -= widths[fitCount - 1];
if (fitCount > 1) used -= GAP;
fitCount--;
}
setVisibleCount(Math.max(0, fitCount));
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(container);
return () => ro.disconnect();
}, [total, users]);
return (
<div className="cc-root">
<div className="cc-header">抄送 {total} 人</div>
{/* 折叠时单行,展开时自动换行 */}
<div
ref={containerRef}
className={`cc-list ${expanded ? "cc-list--expanded" : "cc-list--collapsed"}`}
>
{displayed.map((u, i) => (
<span className="cc-chip" key={u.id ?? i}>
{u.name}
</span>
))}
{needToggle && (
<button className="cc-toggle" onClick={() => setExpanded((v) => !v)}>
{expanded ? "收起" : `+${hiddenCount}`}
</button>
)}
</div>
{/* 隐藏测量区:测按钮宽度 + 每个chip宽度 */}
<div className="cc-measure">
<span ref={moreMeasureRef} className="cc-toggle">+{hiddenCount}</span>
<span ref={collapseMeasureRef} className="cc-toggle">收起</span>
{users.map((u, i) => (
<span
key={`m-${u.id ?? i}`}
className="cc-chip"
ref={(el) => (itemMeasureRefs.current[i] = el)}
>
{u.name}
</span>
))}
</div>
</div>
);
}
.cc-root {
font-size: 14px;
}
.cc-header {
margin-bottom: 8px;
color: #666;
}
.cc-list {
display: flex;
gap: 8px;
align-items: center;
}
.cc-list--collapsed {
flex-wrap: nowrap;
overflow: hidden;
}
.cc-list--expanded {
flex-wrap: wrap;
}
.cc-chip {
flex: 0 0 auto;
padding: 2px 8px;
border-radius: 12px;
background: #f5f7fa;
color: #333;
white-space: nowrap;
}
.cc-toggle {
flex: 0 0 auto;
border: none;
background: transparent;
color: #1677ff;
cursor: pointer;
padding: 0;
white-space: nowrap;
}
.cc-measure {
position: absolute;
left: -9999px;
top: -9999px;
visibility: hidden;
display: flex;
gap: 8px;
align-items: center;
}
用代码实现,最重要的就是搞懂它的第一性原理,分清它的主次关系,先做什么再做什么,所谓动手,所谓敲代码,无非是自己脑海中的思考在现实世界中的实现,凡事在脑海中过一遍,想明白想透彻,经历了一件事,也就经历了一类事,也就有了一类事的解决方案,终有一天也会成功。
本来想写下它的思路,但是为了考验一下我的cursor小助手,索性便让它代劳了!
先定义本质目标
这个 UI 不是“显示一堆人名”这么简单,而是一个受限资源分配问题:
固定资源:一行可用宽度 W
消耗项:每个抄送 chip 的宽度 w[i]、间距 gap、切换按钮宽度 t
目标:在折叠态下,最大化可见人数,同时保证按钮(+N/收起)可见且不重叠
所以它的本质是:
在约束 sum(chips) + gaps + toggle <= W 下,求最大 k(可见人数)。
算谁:核心只算 3 类量
容器宽度 W
当前这行到底有多少像素可用(clientWidth)
每个 chip 的真实宽度 w[i]
必须是真实 DOM 宽度(offsetWidth),不能靠字符数估算
按钮占位宽度 t
折叠文案是 +N,展开文案是“收起”,宽度可能不同
所以取 max(width(+N), width(收起)),避免状态切换后挤压
先算谁再算谁(顺序很关键)
第 1 步:先测量,再决策
先在隐藏测量区拿到:
W
w[0..n-1]
t = max(t_expand, t_collapse)
没有测量就没有决策,这一步是“输入采样”。
第 2 步:先算“理论能放几个 chip”(不考虑按钮)
从左到右累加:
第一个 chip:used = w0
后续:used += gap + w[i] 直到下一个放不下为止,得到 fitCount
这一步回答:“如果不放按钮,最多能放几个人?”
第 3 步:再扣“按钮预算”
如果确实溢出(fitCount < total),必须留按钮位。
检查约束是否满足:
used + gap + t + rightInset <= W
不满足就从右往左移除 chip(fitCount--,同步回退 used),直到满足。
这一步回答:“为了保证按钮可见,最终可见人数应减到多少?”
第 4 步:得到折叠态最终解
visibleCount = fitCount
折叠显示前 visibleCount 个
隐藏人数 N = total - visibleCount
按钮显示 +N
“展开/收起”不是重新发明算法
展开态:显示全量列表(displayed = all)
收起态:回到 displayed = first visibleCount
关键点:
visibleCount 是“折叠态容量解”,展开时不丢失,这样收起能立即回到正确状态。
为什么还要算按钮 top(你们线上那版)
你们线上实现里按钮是绝对定位,不在 normal flow 里,所以还要解第二个子问题:
找“最后一行 chip”的几何信息(top / rightmost)
判断按钮能否同一行塞下
能:按钮与该行垂直居中
不能:按钮换到下一行
如果按钮底部超出容器,再补 paddingBottom
这本质是“几何定位问题”,和前面的“容量分配问题”是两层独立约束。
一句话总结计算流程
先测量真实宽度 -> 先求不带按钮的最大人数 -> 再为按钮留预算回退人数 -> 用结果驱动折叠展示;展开仅切换展示全集,并按几何关系重新摆按钮位置。
想表达的很多,但是总写不出来,不过代码不重要,重要的是解决问题的思路和步骤,同样也是因为某事有感。
上面都是编的
以上。
浙公网安备 33010602011771号