mcpField
1
1k-ui-recipes

1k-ui-recipes

github.com/onekeyhq开发2.5k54 次安装今天更新
代码仓库

关于 1k-ui-recipes

面向 OneKey UI 开发者的参考 Skill,汇集 React Native 组件的实现建议与平台问题处理方案,涵盖导航、滚动、键盘、动画和后台运行时。

主要功能

  1. 01iOS 标签栏滚动偏移处理
  2. 02Web 状态过渡建议
  3. 03可折叠标签页标题手势处理
  4. 04键盘避让模式
  5. 05平台导航与运行时问题修复

适用场景

  1. 01避免键盘遮挡输入框
  2. 02排查关闭 iOS 覆盖页时的导航冻结
  3. 03处理可折叠标签页标题中的横向滚动

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/onekeyhq/app-monorepo --skill 1k-ui-recipes

安装后在 Claude Code 中输入/1k-ui-recipes确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

agents/
openai.yaml
references/rules/
android-background-thread-timers-microtasks.md
android-bottom-tab-touch-intercept.md
collapsible-tab-horizontal-scroll.md
ios-modal-fabric-frame-animation.md
ios-overlay-navigation-freeze.md
ios-tab-bar-scroll-offset.md
keyboard-avoidance.md
start-view-transition.md

详细介绍

OneKey UI Recipes

常见 UI 问题的精简解决方案。

快速参考

配方指南要点
iOS 标签栏滚动偏移ios-tab-bar-scroll-offset.md在 iOS 标签页上使用 useScrollContentTabBarOffset 设置 paddingBottom
平滑状态过渡start-view-transition.md使用 startViewTransition 包裹开销较大的状态更新,以便在 Web 上实现淡入淡出效果
可折叠标签页标题栏中的水平滚动collapsible-tab-horizontal-scroll.md双向 Gesture.Pan() + 通过 CollapsibleTabContext 编程调用 scrollTo
Android 底部标签触摸拦截android-bottom-tab-touch-intercept.md临时方案 — 在 .android.tsx 中使用 GestureDetector + Gesture.Tap(),绕过原生标签栏对触摸事件的拦截
输入框的键盘避让keyboard-avoidance.mdKeyboardAwareScrollView 自动滚动、Footer 动画填充,以及 useKeyboardHeight / useKeyboardEvent 钩子
iOS 覆盖层导航冻结ios-overlay-navigation-freeze.md使用 resetAboveMainRoute(),而非依次调用 goBack(),以便在导航前关闭覆盖层
Web 上 keyboardDismissMode 导致跨标签页失焦—切勿在 Web 上使用 on-drag;它会通过 TextInputState 让全局输入框失焦
iOS Modal Fabric 帧动画ios-modal-fabric-frame-animation.mdFabric 回收的视图会保留过期帧;在 Modal 过渡期间,使用 performWithoutAnimation 包裹 updateLayoutMetrics
Android 后台线程计时器 + 微任务android-background-thread-timers-microtasks.mdBg Hermes 运行时没有 setTimeout,也不会清空微任务队列 — 安装 JSI 计时器 + 调用 rt->drainMicrotasks()(已在 @onekeyfe/react-native-background-thread 3.0.18 中修复)

关键规则摘要

1. iOS 标签栏滚动内容偏移

使用 useScrollContentTabBarOffset 为标签页内的滚动容器添加动态 paddingBottom。在 iOS 上返回标签栏高度,在其他平台上返回 undefined。

import { useScrollContentTabBarOffset } from '@onekeyhq/components';

const tabBarHeight = useScrollContentTabBarOffset();
<ScrollView contentContainerStyle={{ paddingBottom: tabBarHeight }} />
2. 使用 startViewTransition 实现平滑状态过渡

使用 startViewTransition 包裹开销较大的状态更新 — 通过 View Transition API 在 Web / 桌面端实现淡入淡出效果,在原生平台上回退到 setTimeout。

import { startViewTransition } from '@onekeyhq/components';

startViewTransition(() => {
  setIsReady(true);
});
3. 可折叠标签页标题栏中的水平滚动(原生)

在可折叠标签页的 renderHeader 中放置水平滚动组件时,使用可处理两个方向的 Gesture.Pan() — 水平方向驱动 translateX,垂直方向则通过 CollapsibleTabContext 对当前聚焦标签页的 ScrollView 调用 scrollTo。

import { CollapsibleTabContext } from '@onekeyhq/components';

请勿直接从 react-native-collapsible-tab-view/src/Context 导入。请始终使用 @onekeyhq/components 重新导出的内容。

4. Android 底部标签触摸拦截(临时解决方案)

临时修复 — 根本原因是 react-native-bottom-tabs 即使在隐藏时仍会拦截触摸事件。上游问题修复后,应移除此解决方案。

在 Android 上,react-native-bottom-tabs 即使在标签栏处于 GONE 状态时,也会拦截标签栏区域内的触摸事件。屏幕底部附近的按钮会变得无法点击。可以创建 .android.tsx 变体,用 GestureDetector + Gesture.Tap() 包裹按钮来修复:

import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';

const tapGesture = useMemo(
  () => Gesture.Tap().onEnd(() => { 'worklet'; runOnJS(onPress)(); }),
  [onPress],
);

<GestureDetector gesture={tapGesture}>
  <View>
    <Button>Label</Button>
  </View>
</GestureDetector>

使用 .android.tsx 文件扩展名,避免影响其他平台。

5. 输入框的键盘避让

标准的 Page 和 Dialog 组件会自动处理键盘避让。只有自定义布局才需要手动处理。

  • Page 输入框:自动处理 — PageContainer 会使用 KeyboardAwareScrollView 进行包装(bottomOffset 为 90px)
  • Page 页脚:自动处理 — 通过 useReanimatedKeyboardAnimation 为 paddingBottom 添加动画
  • Dialog:自动处理 — 所有 Dialog(包括 showFooter: false)都会在 Dialog 层级处理键盘避让
  • 自定义布局:使用带自定义 bottomOffset 的 Keyboard.AwareScrollView
import { Keyboard } from '@onekeyhq/components';

// Custom scrollable area with keyboard avoidance
<Keyboard.AwareScrollView bottomOffset={150}>
  {/* inputs */}
</Keyboard.AwareScrollView>

// Dismiss keyboard before navigation
await Keyboard.dismissWithDelay();

自定义行为的 Hook:

import { useKeyboardHeight, useKeyboardEvent } from '@onekeyhq/components';

const height = useKeyboardHeight(); // 0 when hidden

useKeyboardEvent({
  keyboardWillShow: (e) => { /* e.endCoordinates.height */ },
  keyboardWillHide: () => { /* ... */ },
});

对于 NavigationContainer 外部的组件(Dialog、Modal),请使用 useKeyboardEventWithoutNavigation。

6. iOS 覆盖层导航冻结(resetAboveMainRoute)

在使用原生 UITabBarController 的 iOS 上,通过连续调用 goBack() 关闭覆盖层路由(Modal、FullScreenPush)会触发 RNSScreenStack 的 window-nil 竞态条件。弹出的页面的屏幕堆栈会失去其 iOS 窗口引用,并进入重试风暴(50 次重试 × 约 100ms),导致导航冻结约 5 秒。

症状:关闭 Modal 后,应用似乎卡在主页。触摸屏幕后,导航就会“恢复正常”。

根本原因:react-freeze(NativeTab 上的 freezeOnBlur: true)会在 Modal 位于 Main 上方时挂起标签页内容。Modal 关闭时,解冻 → Fabric 提交流水线可能无法完成刷新,导致 UI 一直显示冻结前的过时内容,直到触摸事件强制 React 重新评估。原生日志中可见的 RNSScreenStack 重试风暴(giving up after 50 retries)发生在注定要销毁的 Modal 内部堆栈中——它只是 CPU 噪声,并非冻结的原因。

修复:在 iOS NativeTab 层级禁用 freezeOnBlur(TabStackNavigator.native.tsx)。此外,从覆盖层导航到标签页时,使用 switchTabAsync() 替代 switchTab(),以减少 UIKit 转场重叠。

// ❌ WRONG: switchTab overlaps modal dismiss + tab switch
navigation.switchTab(ETabRoutes.Home);

// ✅ CORRECT: switchTabAsync serializes overlay dismiss and tab switch
await navigation.switchTabAsync(ETabRoutes.Home);

关键文件:packages/components/src/layouts/Navigation/Navigator/TabStackNavigator.native.tsx 参考:完整的调查时间线和更正后的根本原因分析,请参阅 ios-overlay-navigation-freeze.md。

7. Web:ScrollView 的 keyboardDismissMode="on-drag" 会导致跨标签页输入框失焦

在 Web 上,react-native-web 的 keyboardDismissMode="on-drag" 会在每次滚动事件时调用 dismissKeyboard()。dismissKeyboard() 使用 TextInputState——一个全局单例,用于跟踪整个应用中当前获得焦点的输入框,而不是按标签页分别跟踪。这意味着,在后台标签页(例如 Home)中滚动 ScrollView,会导致活动标签页(例如 Perps)中的输入框失焦。

症状:输入框会定期(约每 5 秒)失去焦点,且用户没有进行任何操作。

根本原因链:

  1. Home 标签页上的轮播图设置了 autoPlayInterval={5000} → 每 5 秒触发一次滚动
  2. Web PagerView 使用 <ScrollView keyboardDismissMode="on-drag"> → 滚动时调用 dismissKeyboard()
  3. dismissKeyboard() → TextInputState.blurTextInput(currentlyFocusedField()) → 使 Perps 输入框失焦

修复(分两层):

  • Carousel/pager.tsx(仅 Web):强制设置 keyboardDismissMode="none" — Web 没有虚拟键盘,因此取消键盘只会产生副作用
  • Carousel/index.tsx:当轮播图不在视口中时,使用 IntersectionObserver 暂停自动播放

规则:

  • Web 上可能在后台标签页运行的 ScrollView 中,绝不要使用 keyboardDismissMode="on-drag"。在 Web 上,它会通过 TextInputState 使全局获得焦点的输入框失焦。
  • 对于 Carousel/PagerView,Web 端的 pager.tsx 已强制设置为 "none"。对于独立的 ScrollView,如果只有在移动设备上才需要 on-drag,请用 platformEnv.isNative 进行封装。
  • 当后台 Carousel 不可见时,应暂停其自动播放(使用 IntersectionObserver)。
// ❌ WRONG: Will blur inputs on other tabs when this ScrollView scrolls
<ScrollView keyboardDismissMode="on-drag" />

// ✅ CORRECT: Only use on-drag on native
<ScrollView keyboardDismissMode={platformEnv.isNative ? 'on-drag' : 'none'} />

关键文件:packages/components/src/composite/Carousel/pager.tsx、packages/components/src/composite/Carousel/index.tsx

8. iOS Modal 内容在呈现期间发生位移(Fabric)

在使用 Fabric(新架构)的 iOS 上,Modal 页面(pageSheet/formSheet)的内容会在向上滑入动画期间从错误的位置飞入。根本原因:Fabric 会复用保留了过时 frame 的原生视图;这些视图在 Modal 转场期间挂载时,UIKit 会将 frame 修正捕获为隐式动画。

修复方法:在 Modal 转场期间,使用全局 RNSModalTransitionInProgress 标志,将 Fabric 的 updateLayoutMetrics 和 invalidateLayer 包装在 UIView.performWithoutAnimation: 中。

// In UIView+ComponentViewProtocol.mm updateLayoutMetrics:
extern BOOL RNSModalTransitionInProgress;
if (RNSModalTransitionInProgress) {
    [UIView performWithoutAnimation:^{
        self.center = CGPoint{CGRectGetMidX(frame), CGRectGetMidY(frame)};
        self.bounds = CGRect{CGPointZero, frame.size};
        [self layoutIfNeeded];
    }];
}

关键文件:patches/react-native-screens+4.23.0.patch、patches/react-native+0.81.5.patch 参考:ios-modal-fabric-frame-animation.md

9. Android 后台线程缺少 setTimeout / 微任务队列

在 Android 上,由 @onekeyfe/react-native-background-thread 启动的后台 JS 运行时是一个独立的 Hermes 运行时。RN 内置的计时器模块只接入主运行时,而用于将工作分派到后台运行时的自定义 RPC 执行器从不清空 Hermes 微任务队列。因此会出现两种症状:

  • setTimeout / setInterval / requestAnimationFrame / requestIdleCallback 为 undefined → await timerUtils.wait(ms) 永远挂起。
  • Promise.then() / async/await 后续逻辑(甚至 await Promise.resolve())永远不会运行 → 任何异步 RPC 处理程序都会在第一个 await 处卡住,最终表现为桥接调用超时。

修复方法:已在 @onekeyfe/react-native-background-thread 3.0.18 中发布,位于 android/src/main/cpp/cpp-adapter.cpp。更新 apps/mobile/package.json 即可——无需使用 patch-package。

仅对后台运行时进行两项更改:

  1. 每次跨运行时工作执行后清空微任务——在 nativeExecuteWork 中,于 work(*rt) 后调用 rt->drainMicrotasks()。这一行代码让 Promise.then / async/await 得以正常运行。
  2. 安装 JSI 级计时器——installTimersOnRuntime() 将 setTimeout、setInterval、clearTimeout、clearInterval、requestAnimationFrame、cancelAnimationFrame、requestIdleCallback、cancelIdleCallback 注册为宿主函数。这些函数由单个 C++ 工作线程提供支持,并通过 SharedRPC 使用的同一个 RPCRuntimeExecutor 将回调分派回后台 JS 队列。
// Both fixes are required — they address orthogonal bugs on the same code path.
try { work(*rt); } catch (...) { /* ... */ }
rt->drainMicrotasks(); // ← fixes Promise/async hangs

// In nativeInstallSharedBridge, background branch only:
if (!capturedIsMain) { gBgTimerExecutor = executor; }
SharedRPC::install(*rt, std::move(executor), runtimeId);
if (!capturedIsMain) {
    installTimersOnRuntime(*rt); // ← fixes setTimeout/setInterval/rAF/rIC
}

规则:

  • 不要在 polyfillsPlatform.js 中添加 JS 层的 setTimeout polyfill——问题出在 JS 层以下,而 JS polyfill 只会掩盖(而非修复)微任务队列未清空的问题。
  • 今后任何通过 C++ 启动第三个 JSI 运行时的代码,都必须在每次工作执行后调用 drainMicrotasks(),否则 Promise 挂起的问题会再次出现。
  • iOS 不受影响——BackgroundRunnerReactNativeDelegate 会复用 RN 内置的计时器模块和默认微任务处理机制。

关键文件:native-modules/react-native-background-thread/android/src/main/cpp/cpp-adapter.cpp(位于 ~/project/app-modules 中的上游代码)、apps/mobile/package.json(将依赖更新至 3.0.18) 参考:android-background-thread-timers-microtasks.md


相关技能

  • /1k-cross-platform - 平台专属开发
  • /1k-performance - 性能优化
  • /1k-coding-patterns - 通用编码模式

评论

支持 Markdown