光斑怎么在 RN 的原生视图里叠出体积、菜单怎么贴着锚点翻转、npm 包怎么被 RNOH 自动链接——这篇文章用一个跑在鸿蒙真机上的 React Native 组件库,把这三个问题一次讲透。

仓库地址:https://atomgit.com/lqjmac/spatial-glass-rn
版本地址:https://atomgit.com/oh-react-native/ohos_react_native/tree/0.84-main
三方库框架:https://atomgit.com/oh-react-native

0. 环境与实测设备

项版本/型号
DevEco Studio26.0.0.821(build DS-261.23567.138.36.2600821;HarmonyOS SDK 26.0.0 Release / API 26)
React Native0.84
React19.2.3(必须与 RN 0.84.1 内置的 renderer 完全一致)
RNOH@rnoh/react-native-openharmony 0.84.3(README 的系列基准为 0.84.4)
语言与构建TypeScript 5.9.3、Babel 7、Vitest 3.2.7、Node.js 24.12.0
鸿蒙 SDK26.0.0.105 Release(API 26);HAR 构建记录使用 6.0.1(21)
hvigor6.26.4(DevEco Studio 自带)
spatial-glass-rn1.0.0(本仓库,npm 包)
实测设备HUAWEI ALN-AL00,HarmonyOS 7.0.0.109(SP6C00E105R4P3)
示例应用com.lqjmac.spatialglassrn(SpatialGlassRNExample)

1. 玻璃是"平"的——这个问题是怎么找上我的

起因很朴素。RN 里做玻璃拟态,第一版几乎都是同一套:backgroundColor: 'rgba(10, 16, 30, 0.72)'、borderWidth: 1、borderRadius: 22,再补一个阴影。单独看还行,放到深色页面上,它立刻变成一块"颜色稍微浅一点的黑板"——没有厚度,也没有任何"玻璃"该有的东西。

问题不在透明度,而在材质里没有光。玻璃是介电质,本身几乎不发光:你看到的其实是光源在表面上的反射、边缘那圈收光、以及内部非常微弱的散射。深色背景下,如果表面只有一层均匀的暗色,眼睛读不出它的厚度。

我先去试了背景模糊。RN 没有 CSS 的 backdrop-filter;社区 blur 库多数依赖各自的原生实现,RNOH 上不一定有对应版本,即便有,也意味着引入第二套原生渲染和版本绑定。这个库的目标是零原生依赖、所有 RN 平台同一条渲染链路,所以模糊这条路走不通。

于是换了个方向:不去采样背景,而是把"光"画在表面上。背景模糊能做的是"透过它看背景",但这只是玻璃感的一小部分;真正让表面有厚度的是边缘高光、内部的光斑、粗糙度和镜面反射。第一版我只画了一条渐变高光,看起来像贴纸;把材质拆成"底片 + 四张光斑 + 镜面高光 + 颗粒 + 边缘收光"之后,它才立起来。

所以这个题目的边界很清楚:材质由原生视图树里的透明光斑叠出来,动画由原生驱动,浮层用 Modal 承载。具体拆成三件事:

  • 光怎么叠(不用 WebView、不用原生模糊扩展,四张透明光斑 + 颗粒 + 边缘收光);
  • 浮层怎么贴(窗口锚点测量、上下翻转、安全区夹取、返回键与遮罩);
  • 包怎么装(标准 npm 三方库形态、RNOH HAR autolinking、可运行的示例宿主)。

下面是干完之后的完整记录,包括我踩的每一个坑。

2. 效果展示

按老规矩,先给效果再讲原理。Demo 叫 SpatialGlassRNExample:深空底、两团极光、标题"把光留在表面。",下面是两个入口和一个选中状态。

点「打开发光菜单」,菜单贴着触发按钮下方出现:

在这里插入图片描述

菜单宽 304、圆角 22,左上是青色的光,右上偏紫,下方偏暖;边缘有一圈被光照亮的描边,表面还有一层很细的颗粒。它不是一块均匀的半透明矩形:卡片内部的光斑在缓慢漂移,手指按上去、移动时,四张光斑会以不同的幅度跟着偏移——这就是标题里说的"把光留在表面"。

几个交互细节,截图看不出来,但上手就能感觉到:

  • 菜单出现时从 0.965 弹到 1,同时带 8px 的位移滑入(朝下开从 -8 滑到 0,朝上开从 +8 滑到 0),阻尼 24、刚度 250;
  • 点击遮罩、按返回键、点右上角 ×,三种方式都能收起;
  • 收起时先播 150ms 淡出,再把 Modal 卸载;系统开启"减弱动态效果"时,淡出缩到 80ms,漂移和视差完全冻结;
  • 菜单贴着锚点,下方空间不够时自动翻到上方,左右会被夹进 12px 的安全边距。

选中「聚焦光场」之后,菜单收起,页面上的状态更新为「当前:聚焦光场」:

在这里插入图片描述

这个状态由页面自己维护:菜单只负责 onSelect 和 onDismiss,选中之后要不要更新、更新成什么,是业务的事。库本身不保存选择,也不接管路由。

「打开自定义浮层」走的是同一个材质内核,只是内容换成了任意节点:标题、正文、页脚都由调用方传进去,浮层只负责材质、定位和交互。示例里那层浮层的页脚写着「视差与漂移会遵循系统的’减弱动态效果’设置」——这不是文案装饰,useReducedMotion 真的会冻结它们。菜单和浮层共用一套 SpatialSurface + SpatialOverlay,差别只在内容层。

3. 光感到底从哪来:一层底片和四张光斑

写代码之前得先把"玻璃感"拆开。

玻璃的视觉线索至少有五条:菲涅尔边缘(越斜越亮)、镜面高光、内部散射、表面粗糙度、以及边缘厚度。背景折射只是其中一条,而且在深色 UI 里往往是最弱的一条——背景本来就黑,折不折射看不出来,真正起作用的是光在表面上的分布。

所以这个库不采样背景,改用加色光场来近似:

层实现作用
光学底片SpatialSurface 的半透明底色保证文字对比度,建立深空基色
光斑叠层4 张 256px 径向 alpha 贴图 + tintColor青、紫、暖三色在表面内部扩散,不污染背景
镜面高光同一张光斑横向拉伸、旋转 −16°形成类似玻璃边缘的反射带
颗粒层96px 噪点贴图,透明度 5.5%打散大面积渐变色带,避免"塑料感"
边缘收光顶部 1px 高光 + 底部暗边 + 半透明描边让悬浮层有厚度,而不是贴纸

再加一条空间响应:原生驱动的漂移 + 触摸视差(最大 ±14px)。菜单不是静态弹层,而是有轻微"空间深度"。

这套模型的关键在于一张贴图反复用。halo.png 本身只是一张 256×256 的径向 alpha 图,没有颜色;四张光斑用同一个 Image,通过 tintColor 染成青、紫、暖、深四种颜色。主题一换,材质跟着变,不需要重新出图:

// src/theme.ts
export const SPATIAL_THEME: SpatialTheme = Object.freeze({
  surface: 'rgba(8, 13, 25, 0.88)',
  surfaceStrong: 'rgba(13, 20, 38, 0.96)',
  border: 'rgba(214, 232, 255, 0.18)',
  edge: 'rgba(255, 255, 255, 0.30)',
  text: '#F4F7FF',
  muted: 'rgba(226, 236, 255, 0.64)',
  accent: '#62E8FF',
  accentAlt: '#B6A4FF',
  warm: '#FFD4A8',
  danger: '#FF8FA3',
  scrim: 'rgba(2, 5, 12, 0.46)',
  pressed: 'rgba(255, 255, 255, 0.10)',
  radius: 22,
});

底色必须足够暗,加色光斑才读得出来;这也是为什么整页是深空的。把主题换成浅色,光斑会立刻"糊"在背景上——这套材质路径的前提就是暗底。

光斑之外还有两件事:

一是镜面高光。 同一张 halo.png 被横向拉成 160% 宽、48 高的条带,旋转 −16°,压在卡片 38% 的高度上,透明度只有 0.2×强度。它模拟的是玻璃边缘那道被拉长的反射带——没有它,四张光斑会像四个独立的圆,有了它,表面才连成一块玻璃。

二是运动。 四张光斑的漂移偏移不同,触摸视差的深度也不同(14 / 10 / 6 / 4px)。四层以不同的幅度移动,眼睛就会把它们解读成"不同深度",这就是"空间感"的来源。漂移是 16 秒一个循环的极慢节奏,视差只在触摸时发生;两者都只改变 transform,不触发布局。

也要说清楚能力边界:这条路径不做折射,也不采样背景。需要"透过玻璃看到背景扭曲"时,应该用同系列的其他实现——Ionic 的 SVG 折射、Qt 的 GLSL、ArkWeb 的 WebGL;RN 这条的优势是零原生依赖、可着色、成本低。另外,因为每张光斑都是一个 Image 节点,建议单屏同时存在的浮层不超过 2 个;菜单关闭后 Modal 会卸载子树,贴图不会一直参与合成。

4. 工程怎么搭:一个 npm 包、一个 HAR、一个示例宿主

先看整体结构。这是一个标准 React Native 三方库,不是一个 DevEco HAR 工程;库源码、HAR、示例宿主分在三个位置,打开方式也不同:

spatial-glass-rn/                      # npm 库根目录(package.json,v1.0.0)
├─ src/                                # TypeScript 源码
│  ├─ SpatialSurface.tsx               # 四张光斑 + 颗粒 + 边缘收光
│  ├─ SpatialOverlay.tsx               # Modal、定位、入场/退场、返回键
│  ├─ SpatialMenu.tsx                  # 菜单内容与无障碍语义
│  ├─ SpatialPopover.tsx               # 任意内容浮层
│  ├─ geometry.ts                      # 翻转 / 夹取 / 高度估算
│  ├─ measureSpatialAnchor.ts          # measureInWindow 封装
│  ├─ theme.ts                         # 深空光学主题
│  └─ assets/                          # halo.png / grain.png
├─ lib/                                # Babel CJS/ESM + tsc 声明(构建产物)
├─ harmony/                            # RNOH HAR
│  ├─ rn_spatial_glass.har             # npm 分发的预构建 HAR
│  └─ rn_spatial_glass/
│     ├─ Index.ets                     # export SpatialGlassPackage
│     ├─ oh-package.json5              # @rnoh/spatial-glass-rn
│     └─ src/main/ets/SpatialGlassPackage.ets
├─ example/
│  ├─ App.tsx                          # RN 示例页面
│  └─ harmony/                         # ★ DevEco 打开这个目录运行
│     └─ entry/                        # HAP + EntryAbility + RNApp
├─ __tests__/                          # Vitest(13 个用例)
├─ docs/                               # 材质路径、构建记录、包校验
├─ scripts/                            # Babel / HAR / 包校验脚本
├─ index.d.ts
├─ react-native.config.js
└─ package.json

三个目录的分工要拎清,不然很容易在 DevEco 里打开错地方:

  • src/ 是库源码,只依赖 react 和 react-native;
  • harmony/ 是只能构建 HAR、不能 Run 的库工程;
  • example/harmony/ 才是带 entry 的示例宿主,DevEco 打开它才能跑。

这次整理最关键的变化,是从一个"能跑的 Demo"变成"能被 RNOH 自动链接的 npm 包":

  • src/ 是纯 TS/TSX,没有 TurboModule、没有 Fabric 组件,UI 全部由 RN 的核心视图合成;
  • lib/ 是给不使用 Metro 源码的工程准备的预编译产物:lib/commonjs、lib/module、lib/typescript;
  • harmony/ 的 HAR 里只有一个空的 SpatialGlassPackage,但它让这个包拥有了 RNOH 三方库的标准身份;
  • example/harmony/ 通过 file:../../../harmony/rn_spatial_glass 依赖 HAR,用来验证 autolinking 和真机运行。

package.json 里同时声明了 npm 入口和 RNOH autolinking 元数据(节选):

{
  "name": "spatial-glass-rn",
  "version": "1.0.0",
  "source": "./src/index.ts",
  "main": "./lib/commonjs/index.js",
  "module": "./lib/module/index.js",
  "types": "./index.d.ts",
  "exports": {
    ".": {
      "react-native": "./src/index.ts",
      "import": {
        "types": "./lib/typescript/index.d.ts",
        "default": "./lib/module/index.js"
      },
      "require": {
        "types": "./lib/typescript/index.d.ts",
        "default": "./lib/commonjs/index.js"
      }
    }
  },
  "harmony": {
    "autolinking": {
      "ohPackageName": "@rnoh/spatial-glass-rn",
      "etsPackageClassName": "SpatialGlassPackage"
    }
  }
}

react-native 条件让 Metro 直接读 src/index.ts,Babel 负责转译;不使用 Metro 源码的工程则落到 main/module 的 lib/。harmony.autolinking 是 RNOH 的约定:它告诉 RNOH 这个包对应的 ohpm 包名和 ETS 类名。

HAR 里的 SpatialGlassPackage 是空实现,但注释写得很清楚:

// harmony/rn_spatial_glass/src/main/ets/SpatialGlassPackage.ets
export class SpatialGlassPackage extends RNOHPackage {
  override getAnyThreadTurboModuleFactoryByNameMap(): Map<
    string,
    (ctx: AnyThreadTurboModuleContext) => AnyThreadTurboModule | null
  > {
    return new Map<string, (ctx: AnyThreadTurboModuleContext) => AnyThreadTurboModule | null>();
  }
}

它不提供任何 TurboModule,也不注册原生视图——材质完全由 RN 的 View / Image / Animated 绘制。保留这个空包,是为了让库能被 RNOH 正常发现、版本化、被 DevEco 检查,而不是在 autolinking 时被当成一个"没有鸿蒙实现"的包跳过。

示例宿主的入口只多了一行注册:

// example/harmony/entry/src/main/ets/RNPackagesFactory.ets
import { RNOHPackage, RNPackageContext } from '@rnoh/react-native-openharmony';
import { SpatialGlassPackage } from '@rnoh/spatial-glass-rn';

export function createRNPackages(ctx: RNPackageContext): RNOHPackage[] {
  return [new SpatialGlassPackage(ctx)];
}

Index.ets 把 createRNPackages 交给 RNApp,EntryAbility 继承 RNAbility,jsBundleProvider 先找 rawfile 里的 bundle.harmony.js,找不到再回退 Metro。这样示例既能离线跑打包好的 bundle,也能在开发时连 Metro 热更新。

对使用方来说,最终看到的 API 是这样的:

const anchorRef = useRef<View>(null);
const [anchor, setAnchor] = useState<SpatialRect | null>(null);
const [open, setOpen] = useState(false);

const show = async () => {
  const rect = await measureSpatialAnchor(anchorRef);
  if (rect) {
    setAnchor(rect);
    setOpen(true);
  }
};

<View ref={anchorRef} collapsable={false}>
  <Pressable onPress={show}><Text>更多</Text></Pressable>
</View>

{anchor ? (
  <SpatialMenu
    visible={open}
    anchor={anchor}
    title="空间操作"
    items={items}
    onSelect={(item) => setSelected(item.label)}
    onDismiss={() => setOpen(false)}
  />
) : null}

下面把库内部拆开讲:材质、光怎么动、浮层怎么定位、Modal 怎么收尾,以及这个 npm 包怎么被 RNOH 装上。

5. 把代码讲透

5.1 材质:四张光斑、一层颗粒、两道边

SpatialSurface 是整个库的核心。它没有用任何模糊,材质全部由原生视图叠出来。先看四张光斑的数据(节选,warm、deep 两张同构):

// src/SpatialSurface.tsx - plates
const plates: LightPlate[] = [
  {
    key: 'cyan',
    left: '-36%', top: '-38%', size: '108%',
    tint: theme.accent, opacity: 0.34,
    driftX: 18, driftY: 12, pointerDepth: 14,
    rotate: '-12deg', scale: 1.06,
  },
  {
    key: 'violet',
    left: '24%', top: '-26%', size: '102%',
    tint: theme.accentAlt, opacity: 0.3,
    driftX: -16, driftY: 16, pointerDepth: 10,
    rotate: '16deg', scale: 1.12,
  },
  // warm、deep 两张同构,位置与深度不同
];

四张光斑的位置是故意错开的:青色在左上,紫色在右上,暖色落在下方,最后一张 deep 用 surfaceStrong 把中心压暗。它们各自还有旋转和缩放,所以边缘不会重合——四张圆叠在一起,看起来才不是四个圆。

渲染时,每张光斑就是一个 Animated.View 包一个 Image:

// src/SpatialSurface.tsx - render(节选)
<Animated.View
  key={plate.key}
  style={[
    styles.plate,
    {
      left: plate.left,
      top: plate.top,
      width: plate.size,
      height: plate.size,
      opacity: Math.min(1, plate.opacity * safeIntensity),
      transform: [
        {
          translateX: Animated.add(
            driftX,
            pointer.x * plate.pointerDepth + plate.driftX,
          ),
        },
        {
          translateY: Animated.add(
            driftY,
            pointer.y * plate.pointerDepth + plate.driftY,
          ),
        },
        { rotate: plate.rotate },
        { scale: plate.scale },
      ],
    },
  ]}
>
  <Image
    source={halo}
    tintColor={plate.tint}
    style={styles.image}
    resizeMode="cover"
  />
</Animated.View>

halo.png 是同一张 256×256 的径向 alpha 图,四张光斑只是 tintColor 不同。opacity 再乘 intensity(默认 1,上限 1.5),所以调亮/调暗不需要改贴图。Image 的 resizeMode="cover" 保证方形贴图铺满光斑区域。

镜面高光带和颗粒层叠在光斑之上:

// src/SpatialSurface.tsx - streak / grain / edges
<Animated.View
  style={[
    styles.streak,
    {
      opacity: 0.2 * safeIntensity,
      transform: [
        { translateX: Animated.multiply(driftX, 1.4) },
        { translateY: pointer.y * 4 },
        { rotate: '-16deg' },
      ],
    },
  ]}
>
  <Image source={halo} tintColor={theme.warm} style={styles.image} resizeMode="stretch" />
</Animated.View>

<Image source={grain} style={[StyleSheet.absoluteFill, styles.grain]} resizeMode="cover" />

<View pointerEvents="none" style={[styles.topEdge, { backgroundColor: theme.edge }]} />
<View pointerEvents="none" style={styles.bottomEdge} />

streak 的高度是 48,宽度是卡片的 160%,resizeMode="stretch" 把圆斑拉成一条横向的反射带;grain 的透明度只有 5.5%,刚好打散渐变里的色带,又不至于看起来像噪点。顶部用 theme.edge 画一条 hairline 的高光,底部画一条 55% 黑的内侧暗边——一明一暗,玻璃的"厚度"就出来了。

最外层还是普通的 View,负责底色、描边、圆角和裁剪:

// src/SpatialSurface.tsx - base
<View
  onLayout={onLayout}
  onTouchStart={trackPointer}
  onTouchMove={trackPointer}
  onTouchEnd={() => setPointer({ x: 0, y: 0 })}
  onTouchCancel={() => setPointer({ x: 0, y: 0 })}
  style={[
    styles.base,
    {
      backgroundColor: theme.surface,
      borderColor: theme.border,
      borderRadius: theme.radius,
    },
    style,
  ]}
>

这里有几个我调试时才想明白的点:

第一,透明光斑要放进 pointerEvents="none" 的裁剪层。 光斑是装饰,不能拦截点击;外层 View 负责手势,内层 View 用 overflow: 'hidden' 把超出圆角的光斑裁掉。少了这层,光斑会溢出卡片,圆角也白设了。

第二,tintColor 是有平台差异的。 同一个 Image 在 Android/iOS 上被染成主题色,RNOH 上的实现也支持;但极少数广色域设备上青紫色相会有轻微差异——这是贴图色彩空间的问题,不是布局问题。如果主题色对品牌很重要,应该在目标设备上校一次色。

第三,颗粒层的透明度是一次性调出来的。 5.5% 这个值在深色卡片上刚好:再低压不住色带,再高会显脏。它没有公式,只能盯着真机调。

第四,intensity 是整块材质的亮度旋钮。 直接使用 SpatialSurface 时可以按场景传不同的强度(0 退回纯底色,1.5 是上限);SpatialMenu / SpatialPopover 走默认值 1,需要更亮或更暗时再自己包一层。它比"改颜色"更细,又比"换一套贴图"便宜。

5.2 光怎么动:16 秒漂移与 ±14px 视差

材质里有两套运动,刻意分开。

第一套是自漂移:一个 Animated.Value 从 0 到 1 循环 16 秒,线性缓动,原生驱动:

// src/SpatialSurface.tsx - drift loop
const loop = Animated.loop(
  Animated.timing(drift, {
    toValue: 1,
    duration: 16000,
    easing: Easing.linear,
    useNativeDriver: true,
    isInteraction: false,
  }),
);
loop.start();
return () => loop.stop();

drift 再被插值成横向 [-10, 12, -10]、纵向 [8, -10, 8] 的位移。每张光斑在这个公共位移上再加自己的 driftX / driftY 偏移,所以四张光斑的覆盖区域和起始相位不同,画面不会像整体平移。

isInteraction: false 是细节:它告诉 RN 这个循环不要参与 InteractionManager 的"交互结束"判定,否则一个永不停止的循环可能让某些依赖交互结束的逻辑一直等下去。

第二套是触摸视差。onTouchStart / onTouchMove 把触点归一化到 -1…1:

// src/geometry.ts - normalizePointer
return {
  x: clamp((x / safeWidth - 0.5) * 2, -1, 1),
  y: clamp((y / safeHeight - 0.5) * 2, -1, 1),
};

四张光斑按各自的 pointerDepth 乘以这个 -1…1 的值,最大偏移分别是 14、10、6、4px。手指从卡片中心移到边缘,光斑移动的幅度不同,层与层之间就产生了视差。松手或取消时,pointer 归零,光斑回到漂移的基准位置。

这里有一个需要说清楚的取舍:漂移是原生驱动的,触摸视差不是。 pointer 是一个 React state,每次 touchMove 都会触发一次 setState;它改的只是 transform,不触发布局,也不是 requestAnimationFrame 的逐帧循环,但严格说它仍然会走一次 React 渲染。之所以没把它也做成 Animated.Value,是因为视差需要"四张光斑乘不同系数",用 state 写起来最直观;如果以后要榨性能,可以把 pointer.x/y 换成两个 Animated.Value,用 Animated.multiply 直接算,让视差也走原生驱动。这是这个版本里我知道的、最值得优化的一处。

系统开启"减弱动态效果"时,useReducedMotion() 会返回 true:漂移循环不启动,drift 归零,触摸视差也被禁用;浮层只保留 80ms 的淡出。

// src/useReducedMotion.ts
const subscription = AccessibilityInfo.addEventListener(
  'reduceMotionChanged',
  setReduced,
);

这个 hook 还处理了老版本 RNOH 可能没有 isReduceMotionEnabled 的情况:查询失败时默认开着动效,而不是让整个组件报错。

5.3 定位:贴着锚点、翻转、夹进安全区

布局这块全是纯函数,所以它也是整个库里最好测的部分。

第一步是拿到锚点的窗口坐标。measureSpatialAnchor 把 measureInWindow 包成 Promise:

// src/measureSpatialAnchor.ts
export function measureSpatialAnchor(ref: RefObject<View | null>): Promise<SpatialRect | null> {
  const node = ref.current;
  if (!node) {
    return Promise.resolve(null);
  }

  return new Promise((resolve) => {
    node.measureInWindow((x, y, width, height) => {
      if (width <= 0 || height <= 0) {
        resolve(null);
        return;
      }
      resolve({ x, y, width, height });
    });
  });
}

这里有个非常容易踩的 RN 坑:锚点 View 必须加 collapsable={false}。RN 会把没有样式、没有事件的布局节点"折叠"掉,节点被优化掉之后,measureInWindow 要么拿不到值,要么拿到 0。README 的示例里那一行不是装饰,是必需的。

第二步是决定朝上还是朝下:

// src/geometry.ts - resolvePlacement
const fitsBelow = anchorBottom + gap + menuHeight <= viewportHeight - margin;
const fitsAbove = anchorTop - gap - menuHeight >= margin;

if (preferred === 'bottom') {
  return fitsBelow || !fitsAbove ? 'bottom' : 'top';
}
if (preferred === 'top') {
  return fitsAbove || !fitsBelow ? 'top' : 'bottom';
}
return fitsBelow ? 'bottom' : fitsAbove ? 'top' : 'bottom';

逻辑不复杂,但顺序很重要:先看偏好的一侧放不放得下,放不下再看另一侧;如果两边都放不下,auto 仍然优先下方,然后交给夹取把卡片压回视口。宁可让卡片贴边,也不要让它跑到屏幕外。

第三步是真正的布局:

// src/geometry.ts - computeSpatialLayout
const width = Math.max(
  180,
  Math.min(input.menuWidth, Math.max(180, viewport.width - margin * 2)),
);
const maxHeight = Math.max(120, viewport.height - margin * 2);
const height = clamp(menuHeight, 1, maxHeight);
// ...翻转、定 top...
const centeredLeft = anchor.x + anchor.width / 2 - width / 2;
const left = clamp(centeredLeft, margin, Math.max(margin, viewport.width - margin - width));

return {
  left: Math.round(left),
  top: Math.round(top),
  width: Math.round(width),
  placement: resolved,
  maxHeight,
};

width 先被限制在 180 到"视口宽减两个安全边距"之间,窄屏上菜单会自动缩窄;maxHeight 是视口高减 24,卡片内部再用 ScrollView 滚动。left 以锚点中心居中,然后夹进 margin;top 同样是夹取。最后统一 Math.round,避免亚像素定位让边框发虚。

菜单高度在首帧是不知道的,所以先用 estimateMenuHeight 估一个:

// src/geometry.ts
export function estimateMenuHeight(itemCount: number, hasHeader = false): number {
  const safeCount = Math.max(0, Math.floor(itemCount));
  return 12 + (hasHeader ? 58 : 0) + safeCount * SPATIAL_ITEM_HEIGHT + 8;
}

SPATIAL_ITEM_HEIGHT 是 52,加上头部的 58 和上下留白,先给出一个方向正确的估算;onLayout 拿到真实高度后,布局会重新计算一次。估算只影响"先朝哪边开",真实高度决定最终位置;如果估算和实际差得太多,方向仍可能在首帧后翻一次——所以 52 这个项高不是随便定的,它要尽量贴近真实行高。

13 个用例里,布局相关的就占了大半:

// __tests__/geometry.test.ts
expect(layout.width).toBe(304);
expect(layout.left).toBe(43);
expect(layout.top).toBe(154);
expect(layout.placement).toBe('bottom');
// ...
expect(layout.left).toBe(viewport.width - 12 - layout.width);
expect(layout.placement).toBe('top');

computeSpatialLayout 是纯函数,给定锚点、菜单尺寸、视口和边距就能算出唯一结果,不需要渲染一棵组件树。这也是为什么这个库在没有 RN 组件测试环境的情况下,仍然能把最容易出错的定位逻辑测扎实。

5.4 浮层:Modal、弹性入场与返回键

SpatialOverlay 负责所有"浮"的部分:Modal、Scrim、定位、入场/退场、返回键。菜单和浮层都复用它。

先是挂载与退场的状态机。visible 变 true 时先挂载,再用 requestAnimationFrame 把动画推到下一帧,避免"刚挂载就播动画"被合帧吃掉;visible 变 false 时不立刻卸载,而是先播完退场动画:

// src/SpatialOverlay.tsx - enter / exit
if (visible) {
  setMounted(true);
  progress.setValue(0);
  frame = requestAnimationFrame(() => {
    Animated.spring(progress, {
      toValue: 1,
      damping: 24,
      stiffness: 250,
      mass: 0.85,
      useNativeDriver: true,
      isInteraction: false,
    }).start();
  });
} else {
  Animated.timing(progress, {
    toValue: 0,
    duration: reduceMotion ? 80 : 150,
    easing: Easing.out(Easing.quad),
    useNativeDriver: true,
    isInteraction: false,
  }).start(({ finished }) => {
    if (finished && !cancelled) {
      setMounted(false);
    }
  });
}

progress 再被插值成透明度 0→1、缩放 0.965→1、位移 ±8px。方向由 layout.placement 决定:朝下开时从 -8 滑入,朝上开时从 +8 滑入,看起来像从锚点"长"出来。

然后是 Modal 本身:

// src/SpatialOverlay.tsx - Modal(节选)
<Modal
  visible={mounted}
  transparent
  animationType="none"
  statusBarTranslucent
  navigationBarTranslucent
  onRequestClose={onDismiss}
>
  <View style={styles.root} accessibilityViewIsModal>
    <Animated.View style={[StyleSheet.absoluteFill, { opacity }]}>
      <Pressable
        accessibilityRole="button"
        accessibilityLabel="关闭悬浮层"
        style={[StyleSheet.absoluteFill, { backgroundColor: theme.scrim }]}
        onPress={onDismiss}
      />
    </Animated.View>
    <Animated.View
      style={[
        styles.positioner,
        {
          left: layout.left,
          top: layout.top,
          width: layout.width,
          opacity,
          transform: [{ translateY }, { scale }],
        },
      ]}
    >
      <View
        style={[
          styles.shadow,
          {
            borderRadius: theme.radius,
            backgroundColor: theme.surfaceStrong,
            shadowColor: theme.accent,
          },
        ]}
      >
        <SpatialSurface
          testID={testID}
          theme={theme}
          reduceMotion={reduceMotion}
          style={[
            styles.card,
            { borderRadius: theme.radius, maxHeight: layout.maxHeight },
            style,
          ]}
          accessibilityLabel={accessibilityLabel}
        >
          {children}
        </SpatialSurface>
      </View>
    </Animated.View>
  </View>
</Modal>

几个细节:

  • transparent + statusBarTranslucent + navigationBarTranslucent 让 Scrim 尽量铺满状态栏和导航栏区域,否则顶部会露出一条亮边;
  • Scrim 是一个全屏 Pressable,点它关闭;accessibilityLabel="关闭悬浮层" 让读屏用户知道它是什么;
  • onRequestClose 处理系统返回,同时组件在挂载期间还注册了 BackHandler:两套都留着,是因为不同平台的返回路径不完全一样;
  • accessibilityViewIsModal 在支持的平台上把浮层标记为模态,配合菜单项的 accessibilityRole="menuitem",读屏不会跑到后面的页面;
  • 定位层是 position: 'absolute' 的 Animated.View,left/top/width 来自纯函数布局,位移和缩放只作用在 transform 上。

退场时有一个容易忽略的边界:如果用户在 150ms 的退场动画里又点开了菜单,visible 会重新变 true,但旧的 timing 回调可能还在路上。代码用 cancelled 标记和 finished 判断把这条路堵住了;没有它,快速连点会出现"菜单闪一下又消失"。

遮罩的颜色是 rgba(2, 5, 12, 0.46),不是纯黑。纯黑会把页面的极光和青色光晕全部压死,深蓝黑则能把背景"推远"但不至于变成一块死色。

5.5 打包:让 RNOH 认出这个 npm 包

这部分是最不像"UI 代码"、却最容易卡住的一环。

一个 RN 三方库要同时满足三种消费者:

  1. Metro:开发时直接读 TS 源码,走 react-native 条件导出;
  2. 不使用源码的工程:读 main / module 的预编译产物和 types;
  3. RNOH:通过 harmony.autolinking 找到 ohpm 包和 ETS 类。

所以构建分成三条线:

npm run verify          # tsc --noEmit + 13 个 Vitest + Babel CJS/ESM + 声明
npm run build:har       # 构建 harmony/rn_spatial_glass.har
npm run build:example   # 打 bundle + ohpm install + assembleHap

verify 里的 Babel 会生成两套产物:lib/commonjs 和 lib/module;tsc -p tsconfig.types.json 只产声明到 lib/typescript。package.json 的 exports 再把 import / require 分别指到两边,index.d.ts 作为根声明转发。

HAR 的构建走 DevEco 自带的 hvigor 和 ohpm:

export DEVECO_HVIGOR="/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw"
export DEVECO_SDK_HOME="/Applications/DevEco-Studio.app/Contents/sdk"

cd harmony
ohpm install --all
"$DEVECO_HVIGOR" assembleHar --mode module -p module=rn_spatial_glass@default --no-daemon

产物是 rn_spatial_glass.har(约 2.9 KB),复制到 harmony/ 根目录随 npm 包分发。HAR 的关键内容是 Index.d.ets、oh-package.json5、ets/modules.abc 和 module.json——因为库本来就没有原生实现,HAR 只是 autolinking 的"身份证"。

示例宿主的构建多一步 Metro 打包:

node node_modules/react-native/cli.js bundle-harmony \
  --config example/metro.config.js \
  --entry-file example/index.js \
  --dev false --minify true \
  --bundle-output example/harmony/entry/src/main/resources/rawfile/bundle.harmony.js \
  --assets-dest example/harmony/entry/src/main/resources/rawfile

生成的 bundle.harmony.js 约 1.1 MB,assets-dest 会把 halo.png / grain.png 一起拷进 rawfile——这一步漏了,真机上光斑会全部消失,只剩下底色。

包形态最后用脚本校验,而不是靠肉眼:

npm pack
npm run verify:package

verify-package.cjs 会解包 tarball,检查入口、类型声明、HAR 内的 Index.d.ets / oh-package.json5 / ets/modules.abc,并拒绝 oh_modules、build、.hvigor、签名材料等开发期文件。当前结果是 95 个文件、81,053 字节,HAR 与包内容全部通过。

6. 我踩过的坑(每一条都有真实报错)

这一节可能是全文最值钱的部分。以下坑全部来自这次开发的真实报错和排查记录。

坑一:React 版本差一个小版本,真机直接红屏。 react-native@0.84.1 只声明了 react: ^19.2.3 的 peer 范围,我一开始装了 19.2.9,真机启动报:

Error: Incompatible React versions: The "react" and "react-native-renderer"
packages must have the exact same version. Instead got:
 - react:                 19.2.9
 - react-native-renderer: 19.2.3

react 和 RN 内置的 renderer 必须是完全相同的版本,^ 范围在这里不算数。把 react 钉在 19.2.3 之后,红屏消失。这个坑的教训是:RN 生态里 peerDependencies 的范围只是"声明兼容",真正的运行时绑定可能更严格;遇到 renderer 相关的报错,先对齐 react 和 react-native 的精确版本。

坑二:macOS 的大小写不敏感,assert.h 被 RNOH 的 Assert.h 顶掉了。 在 Mac 上构建 RNOH 原生工程,报的是铺天盖地的:

error: use of undeclared identifier 'assert'

一开始以为是 NDK 或 C++ 标准库的问题,翻到 RNOH 的 CMake 才看清楚:RNOH 把 src/main/cpp/RNOH 加进了 include path,而那个目录里有一个 Assert.h。macOS 的 APFS 和 Windows 的 NTFS 默认大小写不敏感,#include <assert.h> 会先命中 Assert.h;那个头文件不定义标准的 assert 宏,所以每个包含 <cassert> 的编译单元都炸了,Boost、double-conversion、jsi 里全是这个错。

修复方式是在 example/harmony/entry/src/main/cpp/compat/ 放一个真正的 assert.h(内容对齐 OpenHarmony NDK 的 musl 版本),再在 CMakeLists.txt 里把它插到 RNOH 的 include 目录前面:

include_directories(BEFORE "${CMAKE_CURRENT_SOURCE_DIR}/compat")

这个坑只在大小写不敏感的文件系统上出现,Linux CI 上反而看不到;如果 RNOH 哪天把 Assert.h 改名,这个 shim 就可以删掉。

坑三:能跑不等于能装——npm 包少了 HAR 和入口。 第一版只在本地 example 里跑通了,npm pack 出来的 tarball 却装不进另一个 RNOH 工程:没有 harmony/ 下的 HAR,package.json 里也没有 harmony.autolinking,RNOH 根本不知道这个包该怎么链接。

这个问题不会在编译期报错,只会在宿主工程里表现为"包装上了,但菜单没出来"。修复是把这个仓库改成标准 RN 三方库结构:src + lib + harmony + example,补 main/module/types/exports,补 harmony.autolinking,最后用 verify-package.cjs 把"tarball 里必须有什么"固化成断言。能被 npm pack 检查出来的问题,不要留给真机。

坑四:hvigor 不是同一个 hvigor。 HAR 构建需要 DevEco Studio 自带的 hvigor,而不是"只带 SDK 的 CLI hvigor"——两者版本不同,SDK/API 级别经常对不上。换到 DevEco 的 hvigor 6.26.4 之后构建才稳定。HAR 的构建记录用的是 6.0.1(21) SDK,示例宿主则是 compile/target 26.0.0、compatible 5.0.5(17)。两套工程的目标不同,不要拿同一份 SDK 配置去套。

scripts/build-harmony.sh 把 DevEco 的 hvigor/ohpm 路径写成了默认值,也允许用 DEVECO_ROOT 覆盖;换机器时先确认这三个路径存在,再谈构建。

坑五:签名是单独一步,不是 assembleHap 的附属品。 不配置签名时,构建只能产出 entry-default-unsigned.hap,大小约 39 MB;hdc install 会被系统拒绝。在 DevEco 里配好 File → Project Structure → Signing Configs,让 product 引用 signingConfig 之后,同一个任务才会额外产出签名 HAP(约 40 MB)。

签名材料(.p12 / .p7b / .cer 和口令)不要进仓库。仓库的 .gitignore 已经把这几类文件排除;verify-package.cjs 也会拒绝 tarball 里的签名文件。本机的签名配置只留在本机。

坑六:菜单被父容器的 overflow: hidden 裁掉。 把 SpatialMenu 挂在滚动区或圆角容器里时,菜单明明弹出来了,下半截却被裁掉;有时锚点坐标也是错的。正确做法是把菜单放到页面根节点下渲染,让 Modal 自己成为窗口层,同时给锚点加 collapsable={false},否则 measureInWindow 可能拿到 0。Modal 是独立窗口层,但触发它的节点和锚点测量仍然在你的组件树里,父级的裁剪和折叠照样会影响它们。

这两个问题经常一起出现:菜单位置偏了,看起来像"定位算错了",其实是锚点被折叠或者父级裁切。排查时先打印 anchor 的四个值,再检查父级样式,比盯着 computeSpatialLayout 看快得多。

7. 现在怎么用:从示例到依赖

经过上面的整理,这个工程现在是一个标准的 React Native 三方库(包名 spatial-glass-rn,版本 1.0.0)。仓库已经按 npm 发布形态准备好,但包还没有正式发到 npm;现阶段用 path 或 git 依赖接入(git 依赖会走 prepare 自动构建,本地 path 依赖先在该目录执行一次 npm install):

# 本地 path
npm install ../spatial-glass-rn

# 或者 git 依赖
npm install git+https://atomgit.com/lqjmac/spatial-glass-rn.git

它只使用 peer 依赖,不捆绑 react / react-native。运行时依赖为 react ≥ 18.2、react-native ≥ 0.76;本文的验证环境是 React 19.2.3 + RN 0.84.1 + RNOH 0.84.3。

最小用法:

import React, { useRef, useState } from 'react';
import { Pressable, Text, View } from 'react-native';
import {
  SpatialMenu,
  measureSpatialAnchor,
  type SpatialRect,
} from 'spatial-glass-rn';

export function MoreButton() {
  const anchorRef = useRef<View>(null);
  const [anchor, setAnchor] = useState<SpatialRect | null>(null);
  const [open, setOpen] = useState(false);

  const show = async () => {
    const rect = await measureSpatialAnchor(anchorRef);
    if (rect) {
      setAnchor(rect);
      setOpen(true);
    }
  };

  return (
    <>
      <View ref={anchorRef} collapsable={false}>
        <Pressable onPress={show}><Text>更多</Text></Pressable>
      </View>

      {anchor ? (
        <SpatialMenu
          visible={open}
          anchor={anchor}
          title="空间操作"
          items={[
            { id: 'focus', label: '聚焦光场', shortcut: 'F' },
            { id: 'pin', label: '固定到空间层', shortcut: 'P' },
            { id: 'remove', label: '移出空间', tone: 'danger' },
          ]}
          onSelect={(item) => console.log(item.id)}
          onDismiss={() => setOpen(false)}
        />
      ) : null}
    </>
  );
}

anchor 用的是窗口坐标,不依赖某个导航器或 SafeArea 实现;菜单会自动翻转、限制左右边距,并在窗口尺寸变化时重新计算。

组件与工具函数:

API说明
SpatialMenu菜单组件,支持标题、副标题、图标、描述、快捷键、禁用态和 danger 态
SpatialPopover任意内容浮层,支持 title / subtitle / footer / width / placement
SpatialSurface只渲染材质,不处理弹出逻辑,可放进卡片、HUD 或自定义弹窗
measureSpatialAnchor把 measureInWindow 包成 Promise,空引用或零尺寸返回 null
computeSpatialLayout纯函数,计算翻转、夹取和安全区位置
estimateMenuHeight首帧高度估算(12 + 58 头部 + 52×项数 + 8)
normalizePointer把触点归一化到 -1…1
createSpatialTheme / withAlpha主题覆盖与颜色工具
useReducedMotion跟随系统的减弱动态效果设置

SpatialMenu / SpatialPopover 的主要属性:visible、anchor、onDismiss,以及 title、subtitle、width、placement(auto / top / bottom)、theme、reduceMotion。菜单额外有 items、onSelect;浮层额外有 footer 和任意 children。

构建与验证:

npm install
npm run verify            # 类型检查 + 13 个测试 + 双格式构建 + 声明
npm run build:har         # 构建 RNOH HAR
npm run build:example     # 构建示例 HAP(需要 DevEco 的 hvigor/ohpm)
npm run verify:package    # 校验 npm tarball 与 HAR 内容

更完整的说明在仓库的文档里:

  • README.md:组件、API、安装、发布形态与设计边界;
  • README.OpenHarmony_CN.md:RNOH 接入、目录结构、构建命令;
  • docs/material-path.md:材质路径与系列其它实现的差异;
  • docs/verification.md、VALIDATION.md:构建记录、包校验与真机验证;
  • example/App.tsx:本文截图对应的示例页面。

当前版本的验证结果:

项目状态
npm run verify类型检查通过;13 个 Vitest 用例通过;CJS/ESM 与声明生成成功
npm run verify:package95 个文件、81,053 字节;HAR 与入口校验通过
npm run build:harBUILD SUCCESSFUL;HAR 约 2.9 KB
npm run build:examplebundle.harmony.js 约 1.1 MB;HAP 约 39 MB(签名后约 40 MB)
真机安装HUAWEI ALN-AL00,HarmonyOS 7.0.0.109 / API 26,安装启动成功
交互菜单打开/选择/收起、遮罩关闭按预期;返回键与减弱动态效果由 SpatialOverlay / useReducedMotion 处理

这套材质的边界也写清楚:它是组件库,不是弹窗管理器,多个菜单的互斥和路由联动由宿主负责;它不做背景模糊,需要系统级模糊时可以把 SpatialSurface 叠在原生模糊层上;它不提供折射,需要"透过玻璃看到背景扭曲"时应该用同系列的 Ionic / Qt / ArkWeb 实现;单屏建议不超过 2 个浮层。

同系列的另外六个实现(KMP/CMP、Cordova、Ionic、Electron、Qt、ArkWeb)共享同一套视觉语言,但材质生成路径刻意不同。RN 这条选了"原生视图叠层光场",换来的是零原生依赖、可主题化、以及所有 RN 平台同一条渲染链路——这也是它最值得存在的地方。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐