Live2d Test Env

【论术】折叠/收起功能

眼前路径须放开阔,才好容人来往,若太拘窄,恐自己亦无展足之地矣。 ---王阳明-

需求:tag列表由于过多,需要有折叠能力,一行内正常展示,大于一行则须折叠,默认折叠态,折叠时按钮展示+剩余tag数量,点击后展开全部,展开后按钮变成收起,始终在最后一行展示,如果最后一行恰好占满,则另起一行位于右侧

当下有了ai,代码的实现早已不是什么难题,思想方面也要与过去做切割,不再思考代码实现的开发,而是着眼于以下三点:

  1. 站在产品的角度去思考这个需求设计的目的,思考这个需求是为了满足什么,实现什么,应该有怎样的交互,如何更好的实现它,它的本质需求究竟是什么,面对某些物理限制或者现实限制,有没有曲线救国以达到目的思路,但不管怎么说,这一步的最低目标是为了生成最小化可实现的产品/需求
  2. 站在测试的角度上去考虑这个需求的实现的逻辑边界,要注意的是在什么时机下防止出现什么问题,在代码层面要去规避它,这一步的目的是为了提高代码在业务层面的健壮性。
  3. 如果前两步是为了完成在业务层面的工作,那么这一步是为了完成开发本身岗位的工作。 完成上述两点后,再站在开发自己的角度去考虑当前代码可能会出现什么问题,或封装,或优化,或重构,这一步是需求在代码层面的收尾。

不管怎么变,不管未来是何种身份,只要还是名义上的开发,大多都绕不过上述三点,尤其以当前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
这本质是“几何定位问题”,和前面的“容量分配问题”是两层独立约束。

一句话总结计算流程
先测量真实宽度 -> 先求不带按钮的最大人数 -> 再为按钮留预算回退人数 -> 用结果驱动折叠展示;展开仅切换展示全集,并按几何关系重新摆按钮位置。



想表达的很多,但是总写不出来,不过代码不重要,重要的是解决问题的思路和步骤,同样也是因为某事有感。

上面都是编的

以上。

posted @ 2026-04-30 23:47  致爱丽丝  阅读(90)  评论(0)    收藏  举报