/* ============================================================ ROOY · 04b ROOY profile(彩蛋) ─────────────────────────────────────────────────────────── 需求(P1 / 支线):让 ROOY 有"真实社交媒体人物"的存在感。从 04 主对话头像进入。 结构(参照 IG / Threads profile): · Avatar + verified ✓ + 实时状态("在缆车上看你滑") · 3 stat 横排(一起滑过 / 看过 / 笔记) · Highlights rail(6 story-circle 玻璃化小封面) · Tabs:关于 / 教学笔记置顶 / 喜好 / 最近动态(默认动态) 交互:头像滚动折叠动画 —— 默认 tab 选了"动态"因为内容超一屏能 触发折叠 + nav pill 淡入;改默认会让交互演示不起来。 ============================================================ */ function RooyProfileScreen({ onNav }) { const [scrollTop, setScrollTop] = React.useState(0); const [liked, setLiked] = React.useState(false); /* Default tab = 动态:内容超一屏才能演示 hero 折叠 + pill 淡入。 改 default 会让交互演示失效。 */ const [activeTab, setActiveTab] = React.useState('resort'); const scrollRef = React.useRef(null); const rafRef = React.useRef(0); const snapTimerRef = React.useRef(null); /* Frame width — measured at runtime so hero collapse math (avatar centering, name center X) tracks the actual stage width. The stage now fills the viewport (no more 372 cap), so this can be 320 on SE up to 430 on Pro Max. Fallback 372 covers the first paint before ResizeObserver fires; the useLayoutEffect below overwrites it before the browser commits the first frame. */ const rootRef = React.useRef(null); const [FW, setFW] = React.useState(372); React.useLayoutEffect(() => { const el = rootRef.current; if (!el) return; const w0 = el.getBoundingClientRect().width; if (w0 > 0) setFW(w0); const ro = new ResizeObserver((entries) => { const w = entries[0].contentRect.width; if (w > 0) setFW(w); }); ro.observe(el); return () => ro.disconnect(); }, []); /* rAF-throttled scroll handler + snap-to-endpoint debounce. Snap 的作用是"别让用户长时间停在 hero 半折叠态"——半折叠的 avatar 尺寸 + name 半位移视觉上不稳,停下来看会一直觉得画面"在动"。 滚动停止 200ms 后,scrollTop ∈ (0, COLLAPSE) 则 smooth 滚到最近端点 (< COLLAPSE/2 回 0,否则去 COLLAPSE)。fully collapsed / fully expanded 时不动手。maxScroll < COLLAPSE 时不能去 COLLAPSE,只 snap 回 0. 200ms(原 150)—— 100~180ms 区间用户慢速浏览的指尖停顿会误触发, 200ms 几乎只在用户真正松手后才触发,不再"和手势打架"。 */ const handleScroll = (e) => { const target = e.currentTarget; if (rafRef.current) return; rafRef.current = requestAnimationFrame(() => { setScrollTop(target.scrollTop); rafRef.current = 0; }); if (snapTimerRef.current) clearTimeout(snapTimerRef.current); snapTimerRef.current = setTimeout(() => { const sc = scrollRef.current; if (!sc) return; const st = sc.scrollTop; if (st <= 0 || st >= COLLAPSE) return; const maxScroll = sc.scrollHeight - sc.clientHeight; const canReachCollapsed = maxScroll >= COLLAPSE; const dest = canReachCollapsed && st >= COLLAPSE / 2 ? COLLAPSE : 0; sc.scrollTo({ top: dest, behavior: 'smooth' }); }, 200); }; /* Tab switch — reset scroll so each tab starts at the top, hero expanded. Without this, switching from a long tab (notes) to short (about) leaves the hero stuck in collapsed state with empty space below. 同时 cancel 待执行的 snap timer,否则 snap 会在 tab 切完 200ms 后误触发. */ const onPickTab = (id) => { setActiveTab(id); if (snapTimerRef.current) clearTimeout(snapTimerRef.current); if (scrollRef.current) scrollRef.current.scrollTop = 0; }; /* Hero collapses 208 → 84pt over 140px of scroll. v5:从 220 → 140,让 04b 短 tab(喜好/动态)也能在自然 maxScroll 范围内完整折叠到 compact 状态。原 220 是为视觉舒展挑的——但实测 4 个 tab 都没法在那个距离内完整收掉。140 让 morph 快 35%,但 仍然是平滑曲线(easeInOutCubic + rAF 节流),不显仓促。 */ const COLLAPSE = 140; /* Dynamic bottom spacer — 原生 iOS 解法 (Apple Music / Photos / IG profile):当 tab 内容不足以让 scrollTop 达到 COLLAPSE 时,contentSize 兜底, 用透明 spacer 把 scrollHeight 补到至少 `clientHeight + COLLAPSE`, hero 不会"弹回"。 单帧 rAF 不够稳健 —— tab 切换后 layout 几帧才稳定 (font subset / svg reflow),第一次测量可能拿到瞬时态。多次 setTimeout (0/40/120/240ms) 覆盖整个 layout-stable 窗口,最终值锁定。Math.max(200, …) 兜底:哪怕 一次都没测准,spacer 至少 200pt 也保证短 tab 能滚到 collapse. */ const [spacerH, setSpacerH] = React.useState(280); React.useLayoutEffect(() => { const sc = scrollRef.current; if (!sc) return; const measure = () => { setSpacerH((cur) => { const containerH = sc.clientHeight; const contentH = sc.scrollHeight - cur; const need = Math.max(200, COLLAPSE + 48 - (contentH - containerH)); return Math.abs(need - cur) > 0.5 ? need : cur; }); }; const ids = [0, 40, 120, 240].map((d) => setTimeout(measure, d)); return () => ids.forEach(clearTimeout); }, [activeTab]); const tRaw = Math.min(1, scrollTop / COLLAPSE); const t = tRaw < 0.5 ? 4 * tRaw * tRaw * tRaw : 1 - Math.pow(-2 * tRaw + 2, 3) / 2; const lerp = (a, b) => a + (b - a) * t; /* 私信 pill — clean two-state crossfade replacing the v2 9-property morph. Pill fades in only after hero is mostly collapsed (tRaw 0.6 → 0.92) so it lands beside the compact avatar, not mid-transition. The inline 私信 button in the action row is a normal flex item — scrolls off naturally with the rest of the row. No shape interpolation, no intermediate "neither rect nor pill" state. 用 tRaw(normalized)而非 scrollTop,让阈值不依赖 COLLAPSE 绝对值。 */ const pillRaw = Math.max(0, Math.min(1, (tRaw - 0.6) / 0.32)); const pillEase = 1 - Math.pow(1 - pillRaw, 3); // easeOutCubic const pillOpacity = pillEase; const pillScale = 0.92 + 0.08 * pillEase; const pillTY = (1 - pillEase) * -6; /* v5:hero 整体上收。原 avTop 50pt + heroH 248 让 avatar 顶部距状态栏 有 100+pt 空白,太空。avTop → 28(接近 compact 24),整组 name/tagline 等同步上移 22pt;heroH 248 → 208 同步收 40pt,去掉 tagline 下面无用的 34pt empty space。 */ const heroH = lerp(208, 84); const avSize = lerp(92, 36); const avLeft = lerp((FW - 92) / 2, 14); const avTop = lerp(28, 24); const avLetter = lerp(40, 18); const nameSize = lerp(28, 17); const nameCenterX = lerp(FW / 2, 60); const nameAnchor = lerp(50, 0); /* Compact name: top 32. 状态合并进 name 行后变单行 ~20pt 高(17pt h1 lineHeight 1.15);avatar center cy = 24+18 = 42;single-line center 32+10 = 42,对齐 avatar 中心。 Expanded:avatar bottom = 28+92=120,gap 16 → name top 136. */ const nameTop = lerp(136, 32); /* Tagline — only thing left in the fading subtext block. Expanded 168 紧贴 name bottom (~136+32=168);compact position 不可见(subOpacity=0)。 */ const subOpacity = Math.max(0, 1 - tRaw * 2.2); const subTop = lerp(168, 130); /* Status "在缆车上" — 04a 风格:expanded 完全不渲染,折叠到 ~45% 后才 fade-in 出现在 ROOY 名右侧,inline `· 在缆车上`(和 rooy.jsx 04a 里 `· 教学中` 完全同结构 / 同 token)。 去掉了旧的顶部 chip(位置 / 背景 / 缩放变量都不再需要)。 */ const statusInlineOpacity = Math.min(1, Math.max(0, (tRaw - 0.45) / 0.45)); const coverOpacity = Math.max(0, 1 - tRaw * 1.3); return (
你的滑雪搭子
{l}
{body}
{tail && ({tail}
)}{when}
{body}
{l}
{d}