关于 1k-ui-recipes
面向 OneKey UI 开发者的参考 Skill,汇集 React Native 组件的实现建议与平台问题处理方案,涵盖导航、滚动、键盘、动画和后台运行时。
主要功能
- 01iOS 标签栏滚动偏移处理
- 02Web 状态过渡建议
- 03可折叠标签页标题手势处理
- 04键盘避让模式
- 05平台导航与运行时问题修复
适用场景
- 01避免键盘遮挡输入框
- 02排查关闭 iOS 覆盖页时的导航冻结
- 03处理可折叠标签页标题中的横向滚动
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ onekeyhq/ app-monorepo --skill 1k-ui-recipes安装后在 Claude Code 中输入/1k-ui-recipes确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
agents/openai.yamlreferences/rules/android-background-thread-timers-microtasks.mdandroid-bottom-tab-touch-intercept.mdcollapsible-tab-horizontal-scroll.mdios-modal-fabric-frame-animation.mdios-overlay-navigation-freeze.mdios-tab-bar-scroll-offset.mdkeyboard-avoidance.mdstart-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.md | KeyboardAwareScrollView 自动滚动、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.md | Fabric 回收的视图会保留过期帧;在 Modal 过渡期间,使用 performWithoutAnimation 包裹 updateLayoutMetrics |
| Android 后台线程计时器 + 微任务 | android-background-thread-timers-microtasks.md | Bg 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 秒)失去焦点,且用户没有进行任何操作。
根本原因链:
- Home 标签页上的轮播图设置了
autoPlayInterval={5000}→ 每 5 秒触发一次滚动 - Web PagerView 使用
<ScrollView keyboardDismissMode="on-drag">→ 滚动时调用dismissKeyboard() 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。
仅对后台运行时进行两项更改:
- 每次跨运行时工作执行后清空微任务——在
nativeExecuteWork中,于work(*rt)后调用rt->drainMicrotasks()。这一行代码让Promise.then/async/await得以正常运行。 - 安装 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 层的setTimeoutpolyfill——问题出在 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- 通用编码模式