/** * 家书 FamilyBook · 家谱图谱(鸿蒙端) * * ============================================================ * 🔴 v73:从「Canvas 全手绘」改成「Canvas 只画线 + 原生卡片」 * ============================================================ * 用户 2026-10-21 原话:「手机端这个家谱实在是很难看,能有什么库之类的,让它好看点吗」 * * 为什么不能再全用 Canvas(原实现 287 行,只有彩色圆点 + 首字): * · Canvas **没有原生字体**:只能写 `'12px sans-serif'` 这种泛字体, * 中文渲染出来又平又硬,和 App 其他地方的排版完全两个味道; * · Canvas **没有圆角/阴影/图片裁切**:卡片做不出「纸白 + 细边 + 微投影」, * 只能退化成纯色圆点 —— 这就是用户说的"难看"; * · Canvas **不是响应式的**:数据一变要自己重画,容易漏。 * ⇒ 现在:**卡片用原生 `Column`/`Text`/`FbAvatar`**, * 字体、圆角、阴影、头像裁切全部走 App 同一套,视觉自然统一。 * * ⚠️ 那为什么连线还留在 Canvas 上,不用 `Path` 组件? * `Path.commands` 的坐标单位是 **px**(官方文档明确写明), * 而卡片定位的 `.position()` 单位是 **vp**。 * 两套单位混用,在高分屏(本机 3.0x)上线会缩到 1/3、与卡片完全错位。 * Canvas 的绘制坐标系**也是 vp**(与 `.position()` 一致,原实现已验证), * 所以「Canvas 画线 + 原生卡片绝对定位」是唯一单位自洽的组合。 * * 布局算法**不在这个文件里** —— 见 `common/KinLayout.ets`(纯函数、可单独验证)。 * * ⚠️ 不做双指缩放:`scale()` 是位图缩放,卡片里的中文会糊; * 内容超出一屏时用 `Scroll` 双向滚动 —— 更符合手机的肌肉记忆。 */ import { FbAvatar } from './Widgets'; import { KinCardPos, KinDim, KinEdge, KinLayout, layoutKin } from '../common/KinLayout'; import { FbColors, FbMetric } from '../common/Theme'; import { FbKinPerson } from '../model/Types'; import { fbImageSrc } from '../data/FbAttachQueue'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; @ComponentV2 export struct FbKinGraphView { /** 家谱里的人 */ @Param people: FbKinPerson[] = []; /** 我(居中显示、加粗) */ @Param meId: number = 0; /** 点某个节点(弹详情) */ @Event onPick: (id: number) => void = (id: number): void => { }; /** 算好的布局 */ @Local layout: KinLayout = new KinLayout(); /** 内容尺寸(`Scroll` 里的子层要有确定宽高,否则滚不动) */ @Local contentW: number = 0; @Local contentH: number = 0; /** 视口宽度(内容比视口窄时居中) */ @Local viewW: number = 0; /** 视口高度(判断"要不要滚") */ @Local viewH: number = 0; /** 内容几何哈希:布局一变就要**重画 Canvas**(见 `drawEdges` 的说明) */ @Local geoStamp: string = ''; /** 滚动容器(要能"一进来就对准我") */ private scroller: Scroller = new Scroller(); /** 已经自动对准过一次(避免用户手动滚动后被拉回去) */ private centered: boolean = false; /** Canvas 上下文(**必须在组件里创建**,不能跨组件共享) */ private settings: RenderingContextSettings = new RenderingContextSettings(true); private ctx: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); aboutToAppear(): void { this.rebuild(); } /** * `@Param` 变化时重算。 * * ⚠️ 用 `@Monitor` 而**不是**在 `build()` 里算:`build()` 每次重渲染都跑, * 在那里算布局等于每帧做一遍「11 人 × 24 轮松弛」。 */ @Monitor('people', 'meId') onDataChange(): void { // 数据换了 ⇒ 允许重新对准我(否则同步刷新后镜头还停在上一个人的位置) this.centered = false; this.rebuild(); } private rebuild(): void { this.layout = layoutKin(this.people, this.meId); // `+ 2`:盖住描边的半像素溢出,否则最右侧卡片的边会被裁掉一条 this.contentW = this.layout.width + 2; this.contentH = this.layout.height + 2; // 🔴 几何指纹:Canvas **不是响应式的**,布局变了必须自己重画。 // 只靠 `onReady`(首帧一次)是不够的 —— 首帧时 `people` 往往还是空的, // 等数据到了布局才变,那时连线会**留在旧位置**(卡片动了线没动)。 // 这里把每张卡的坐标拼成一个串,变了就触发重画。 this.geoStamp = this.stampOf(); } /** 内容几何指纹(卡片坐标 + 线数) */ private stampOf(): string { let s: string = ''; for (const c of this.layout.cards) { s += c.p.id.toString() + ':' + c.x.toFixed(1) + ',' + c.y.toFixed(1) + ';'; } return s + '|' + this.layout.edges.length.toString(); } /** * 把镜头对准「我」。 * * ⚠️ 只在**第一次**做,且内容超出视口才需要滚 —— * 内容比视口窄时已经居中(见 `offsetX`),再滚反而会滚出空白。 */ private centerOnMe(): void { if (this.centered || this.viewW <= 0 || this.viewH <= 0) { return; } if (this.layout.meX < 0) { return; } this.centered = true; const needX: boolean = this.contentW > this.viewW; const needY: boolean = this.contentH > this.viewH; if (!needX && !needY) { return; } this.scroller.scrollTo({ xOffset: needX ? Math.max(0, this.layout.meX - this.viewW / 2) : 0, yOffset: needY ? Math.max(0, this.layout.meY - this.viewH / 2) : 0, animation: { duration: 260, curve: Curve.EaseOut } }); } /** 内容比视口窄 ⇒ 左右居中;否则从左边开始(可滚) */ private offsetX(): number { if (this.contentW >= this.viewW) { return 0; } return (this.viewW - this.contentW) / 2; } /** * 几何变了就补一次重画。 * * 🔴 为什么光有 `onAreaChange` 不够:它只在**宽高变化**时触发。 * 而布局变了、总宽高却没变的情况是真实存在的 —— * 比如两个人互换了位置、或者某个单元整体挪了 20vp。 * 那时 Canvas 里留的是**旧坐标的线**,卡片却在**新位置** ⇒ 线穿到卡片外面。 * `geoStamp` 是坐标指纹,它变就一定重画。 */ @Monitor('geoStamp') onGeoChange(): void { // 推到下一个 tick:此时 `contentW/contentH` 与 Canvas 的布局都已生效 setTimeout((): void => { this.drawEdges(); }, 0); } build() { Scroll(this.scroller) { Stack({ alignContent: Alignment.TopStart }) { // ---- ① 连线层(**在卡片下面**:Canvas 没有 z-index,靠节点顺序)---- Canvas(this.ctx) .width(this.contentW) .height(this.contentH) .onReady((): void => { this.drawEdges(); this.centerOnMe(); }) .onAreaChange((_o: Area, _n: Area): void => { this.drawEdges(); }) // 连线不参与命中:点卡片边缘时不该点到它下面的线 .hitTestBehavior(HitTestMode.None) // ---- ② 卡片层 ---- ForEach(this.layout.cards, (c: KinCardPos) => { this.personCard(c) }, (c: KinCardPos): string => 'kc_' + c.p.id.toString()) } .width(this.contentW) .height(this.contentH) } .width('100%') .height('100%') // ⚠️ `Free`(可双向)不是 `Both` —— `Both` 在 API 9 就废弃了, // SDK 的 `ScrollDirection` 现在只有 Vertical / Horizontal / Free / None。 .scrollable(ScrollDirection.Free) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) // 内容比视口窄时居中:`Scroll` 没法「居中内容」,用对称 padding 顶到中间 .padding({ left: this.offsetX(), right: this.offsetX() }) .backgroundColor(FbColors.paper) .onAreaChange((_o: Area, now: Area): void => { this.viewW = Number(now.width); this.viewH = Number(now.height); this.centerOnMe(); }) } /** * 画全部连线(先血缘、后夫妻 —— 夫妻线更粗,要压在上面)。 * * ⚠️ Canvas 不是响应式的:布局变了必须**自己重画**。 * 这里在 `onReady`(首帧)与 `onAreaChange` 各调一次。 */ private drawEdges(): void { if (this.contentW <= 0 || this.contentH <= 0) { return; } const c: CanvasRenderingContext2D = this.ctx; c.clearRect(0, 0, this.contentW, this.contentH); // ---- 血缘:父母单元中点 → 下 → 横 → 下 → 子女顶端(与 Web 端同款折线)---- for (const e of this.layout.edges) { if (e.kind === 'spouse') { continue; } c.beginPath(); c.lineWidth = 1.4; c.strokeStyle = '#C9BDA9'; c.lineCap = 'round'; c.lineJoin = 'round'; c.moveTo(e.x1, e.y1); c.lineTo(e.x1, e.midY); c.lineTo(e.x2, e.midY); c.lineTo(e.x2, e.y2); c.stroke(); } // ---- 夫妻:卡片之间的一小段横线(粗、品牌色,与血缘一眼区分)---- for (const e of this.layout.edges) { if (e.kind !== 'spouse') { continue; } c.beginPath(); c.lineWidth = 2.2; c.strokeStyle = '#C97B6A'; c.lineCap = 'round'; c.moveTo(e.x1, e.y1); c.lineTo(e.x2, e.y2); c.stroke(); } } /** * 一张人物卡。 * * 视觉与 Web 端 `.kin-card` 对齐:纸白底 + 细边 + 微投影, * 头像在上(服务端头像是 1:1 裁好的),姓名在下,称谓再下一行。 * 「我」用品牌色描边 + 姓名加粗 —— 一眼认出中心。 */ @Builder personCard(c: KinCardPos) { Column({ space: 0 }) { FbAvatar({ name: c.p.shown !== '' ? c.p.shown : c.p.name, color: c.p.avatarColor !== '' ? c.p.avatarColor : '#8A6B4B', // 卡宽 62 ⇒ 头像 40,左右各留 11(含 3 内边距)= 不贴边、也不挤 dia: 40, fontSizeVp: 15, imageUrl: fbImageSrc(c.p.avatar, FB_TOKEN_STORE.get()) }) Text(c.p.shown !== '' ? c.p.shown : (c.p.name !== '' ? c.p.name : '(无名)')) .fontSize(12) .fontWeight(c.p.id === this.meId ? FontWeight.Medium : FontWeight.Normal) .fontColor(c.p.id === this.meId ? FbColors.brandInk : FbColors.ink) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') .textAlign(TextAlign.Center) .margin({ top: 4 }) // 称谓:「我」不重复写一遍(卡片上写"我"是废话) if (c.p.label !== '' && c.p.label !== '我') { Text(c.p.label) .fontSize(10) .fontColor(c.p.id === this.meId ? FbColors.brand : FbColors.brand2) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') .textAlign(TextAlign.Center) .margin({ top: 1 }) } } .width(KinDim.CARD_W) .height(KinDim.CARD_H) .padding({ top: 4, bottom: 3, left: 2, right: 2 }) // ⚠️ `.position()` 是**左上角**定位,布局给的是**中心**坐标 ⇒ 各减一半 .position({ x: c.x - KinDim.CARD_W / 2, y: c.y - KinDim.CARD_H / 2 }) .backgroundColor(FbColors.surface) .borderRadius(FbMetric.radiusS) .border({ width: c.p.id === this.meId ? 1.5 : 1, color: c.p.id === this.meId ? FbColors.brand : FbColors.line }) .shadow({ radius: 3, color: '#1A2B2620', offsetX: 0, offsetY: 1 }) .onClick((): void => { this.onPick(c.p.id); }) } }