mcpField
A
animate-expo

animate-expo

github.com/playful-design设计与 UI1679 次安装1 周前更新
代码仓库

关于 animate-expo

animate-expo 是面向 Expo 和 React Native 开发者及编码 Agent 的动画实现技能,涵盖是否需要动画到设备端实现的决策,并强调 UI 运行时和手势处理。

主要功能

  1. 01评估动画是否必要
  2. 02按交互选择 Reanimated API
  3. 03让逐帧工作避开 JS 运行时
  4. 04选择属性、时长与弹簧
  5. 05兼顾按压输入与减少动态效果

适用场景

  1. 01实现手势驱动的交互
  2. 02构建模态框或底部面板动画
  3. 03检查设备上的动画性能

安装

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

$npx skills add https://github.com/playful-design/router --skill animate-expo

安装后在 Claude Code 中输入/animate-expo确认已加载。

附带文件LICENSERECIPES.md

详细介绍

在 Expo 中构建动画

初始回复

首次调用此技能且未提出具体问题时,只回复:

我已准备好在 Expo 和 React Native 中构建适合真实设备的动画,我的知识源自 Emil Kowalski 的动画理念。

在用户提问之前,不要提供任何其他信息。

这是一个用于 React Native 的构建技能。它会将动效需求转化为一套能通过真实设备严格审查的实现方案——不是在模拟器上,也不是在开发模式下的旗舰手机上。

移动设备会改变动画的三个方面,而本技能中的所有内容都由此而来:

  1. 没有悬停状态。 Web 上通过悬停实现的所有提示,都必须通过按压、位置或完全不处理来实现。
  2. 有两个运行时。 Worklet(Reanimated 4)明确区分了这两者:React Native 运行时负责渲染 React 和运行应用逻辑;UI 运行时负责让 worklet 在每一帧运行(还可以选择使用 worker 运行时处理后台工作)。只要动画触及 RN 运行时,应用一忙起来就会卡顿。整个技巧就在于让动效保持在 UI 运行时中。
  3. 用户的手指就在元素上。 手势是主要输入方式,因此可中断性和速度衔接不是锦上添花,而是基本要求。

行事准则

你是一名资深移动工程师,亲自构建动画。做出判断,用一句话说明理由,然后编写代码。绝不要把动效方案列成菜单让人选择。

有两种失败模式,第一种更严重:

  1. 为不该动的内容添加动画。 以下判断流程的目的之一,就是有时不写一行代码。
  2. 让正确的内容在错误的线程上执行动画——例如每帧调用一次 setState、使用 PanResponder、对 height 做动画。在自己的手机上以开发模式测试时看起来不错,但在三年前的 Android 手机上会掉到 20fps。

硬性规则

  1. 按顺序执行流程。 第 1 步和第 2 步是所有后续步骤的前提。
  2. 使用 Reanimated,不用核心 Animated。 核心 Animated 无法在不跨越桥接层的情况下由手势驱动,而且 useNativeDriver 只支持 transform 和 opacity。Reanimated worklet 在 UI 线程上运行,即使 JS 繁忙也能继续运行。
  3. 不要使用近似值。 曲线和弹簧配置均取自下方的表格。
  4. 减少动态效果的支持必须和动画一起交付,不能留到后续处理。
  5. 动画效果必须在你支持的最慢设备的发布版本上评估。 只有这样才能算通过验证。

构建流程

1. 这个动画真的有必要吗?
频率决策
每天 100 次以上——切换标签、打开/关闭键盘、滚动、设置中的切换开关不要添加动画。 使用平台默认效果,或不添加效果。到此为止。
每天几十次——按压反馈、列表导航、行选择仅使用几乎察觉不到的效果:时长低于 150ms,或者不添加效果
偶尔发生——面板、模态框、提示消息、引导步骤使用标准动画
很少发生/首次发生——成功状态、空状态插图、庆祝效果惊喜感应留给这一档

切换标签时绝不使用滑动效果。 标签是并列关系,不是层级关系——滑动会暗示并不存在的深度,而用户每次会话中要为此付出几十次代价。animation: 'none'。

如果需求未通过此判断,就说明原因,不要编写代码。

2. 目的是什么?

继续之前,先用一个词概括:反馈、空间一致性、状态指示、避免突兀变化、解释或惊喜(仅限罕见情形)。

无法概括?就不要构建。

3. 选择工具——选能满足需求且最简单的

从上往下查找;找到第一个符合需求的选项就停止。

需求工具
由状态驱动、无需手势的变化——按下、切换、颜色变化、值翻转Reanimated CSS transition(在样式中使用 transitionProperty)
循环、多阶段,或无需状态变化便在挂载时播放Reanimated CSS animation(使用 animationName 关键帧)
元素挂载或卸载,或列表重新排列布局动画(entering / exiting / itemLayoutAnimation)
手指触碰的任何内容,或由滚动衍生的任何内容useSharedValue + Gesture + useAnimatedStyle
屏幕间导航Expo Router 中的原生堆栈选项。 切勿自行实现
自成屏幕的底部面板presentation: 'formSheet'——它是真正的 UISheetPresentationController,免费且正确
标签栏NativeTabs(来自 expo-router/unstable-native-tabs)——平台真正的标签栏,自带行为和过渡效果
上下文菜单、按住时的预览Link.Menu / Link.Preview(Expo Router,仅限 iOS)——使用原生菜单和预览,切勿用 JS 重建
折叠为大标题的标题栏原生堆栈中的 headerLargeTitleEnabled(仅限 iOS;headerLargeTitle 已弃用)——不要使用滚动 worklet
下拉刷新RefreshControl——只有当它是标志性交互时才自行实现(参见阈值方案)
跟随键盘移动的 UIreact-native-keyboard-controller——逐帧获取键盘的实际位置,并在 UI 线程上更新
矢量插画、庆祝动画、空状态Lottie——仅用于插画,绝不用来表示 UI 状态
大型动画场景、自由绘图@shopify/react-native-skia——画布,适用于视图层级本身成为瓶颈的情况

只有当值连续变化或可被中断时,才使用共享值。按压缩放是 CSS transition;拖动才是共享值。为两态切换使用 worklet,就像为了淡入淡出而安装一个动效库一样不合适。

依赖项。 使用 npx expo install <package> 安装——它会解析与项目 SDK 匹配的版本,而普通的 npm install 不会:

需求包
动画react-native-reanimated + react-native-worklets
手势react-native-gesture-handler
导航、面板、原生标签栏、菜单expo-router
触觉反馈expo-haptics
跟随键盘移动的 UIreact-native-keyboard-controller(需要在根节点配置 KeyboardProvider——参见键盘方案)
插画、庆祝动画lottie-react-native
超大型动画场景、自定义绘图@shopify/react-native-skia
4. 选择属性
  • transform 和 opacity 不会触发布局。 其他属性都会触发布局计算。width、height、margin、padding、flex、top、left、gap 会在每一帧为该节点 及其同级节点 重新运行 Yoga。
  • 唯一的例外:没有子元素的绝对定位元素——例如标签栏胶囊、进度条填充。它脱离文档流,因此不会让其他内容重新布局;而且对 width 做动画还能保留圆角,这是 scaleX 会破坏的。
  • 切勿使用 scale(0)。 从 scale(0.9–0.97) + opacity: 0 开始。现实世界中没有任何东西会凭空出现。
  • transform 是一个数组,顺序很重要——[{ translateY }, { scale }] 会先移动再缩放;反过来,平移也会被缩放。除非你希望两者相乘,否则应将平移放在前面。
  • Android 阴影使用 elevation,对 elevation 做动画会在每一帧重新渲染阴影。 改为对预先添加阴影的图层做透明度动画。
  • 切勿对 BlurView 的强度做动画。 在 Android 上,这会让模糊效果每一帧都重新渲染。应改为对静态 BlurView 的透明度做交叉淡化。
  • translate 支持百分比,且百分比相对于元素自身的尺寸——translateY('100%') 会让面板移动相当于自身高度的距离,无论其内容是什么。
5. 使用时序曲线还是弹簧

如果涉及手指操作,就用弹簧。 弹簧会在中断后保留速度;时序曲线则会重新开始。其他情况都使用时序曲线。

Reanimated 的弹簧可直接使用 Apple 的两个设计参数——使用这种形式,不要使用质量/刚度/阻尼:

交互配置
默认稳定,不超调{ duration: 400, dampingRatio: 1 }
拖动后重新定位/弹回{ duration: 400, dampingRatio: 0.8, velocity }
面板、抽屉{ duration: 300, dampingRatio: 0.8, velocity }
不能越过硬边界添加 overshootClamping: true

只有手势带有惯性时才产生回弹。 淡入的菜单发生超调会显得不自然;被你快速甩出的卡片发生超调则很合适。

缓动适用于所有没有手指参与的情况:

场景缓动
进入或退出ease-out
屏幕上的移动/变形ease-in-out
匀速运动(进度、跑马灯)linear
默认ease-out

UI 中绝不要使用 ease-in。 它会缓慢起步,延迟用户正在等待的时刻。Reanimated 内置的缓动效果和 CSS 一样弱——请使用以下缓动:

import { Easing } from 'react-native-reanimated';

const EASE_OUT = Easing.bezier(0.23, 1, 0.32, 1);      // strong ease-out for UI
const EASE_IN_OUT = Easing.bezier(0.77, 0, 0.175, 1);  // on-screen movement
const EASE_SHEET = Easing.bezier(0.32, 0.72, 0, 1);    // iOS sheet curve

时长:

元素时长
按压反馈100–150ms
开关、标签、小幅状态变化150–200ms
面板、模态框、抽屉弹簧,体感约 300ms
屏幕过渡平台默认值——不要覆盖

移动端 UI 动画应控制在 300ms 以内,与 Web 相同。平台自带的过渡更长(iOS 推入动画为 350ms);导航时与平台保持一致,其他情况则应更快。

6. 不要占用 JS 线程

这是移动端特有的技巧,也是大多数 React Native 动效失效的地方。

  • 绝不要在手势或滚动处理程序中调用 setState。 每帧触发一次 React 渲染是 RN 应用卡顿的最大原因。使用共享值 → useAnimatedStyle,这样 React 完全不会重新渲染。
  • 绝不要在 onUpdate 或滚动处理程序中调度回 RN 运行时。 react-native-worklets 中的 scheduleOnRN(fn, ...args)——即 Reanimated 4 对已弃用的 runOnJS(fn)(...args) 的替代——会将调用排入 RN 运行时队列,而在 onUpdate 中,每秒会调用 60–120 次。它应放在 onEnd 中,或放在值越过阈值时触发的 useAnimatedReaction 中。
  • 绝不要在渲染期间读取共享值(例如 JSX 中的 translateY.get())。这只是一个不会更新的快照,会导致同步悄然失效。渲染期间也绝不要写入共享值——它会在协调过程中触发,而且你没有主动发起的重新渲染也会重放这次写入。只能在 worklet、处理程序和副作用中访问共享值。
  • 使用 .get() / .set(),不要使用 .value。 两者 API 相同,但 React Compiler 无法分析直接的 .value 访问——Reanimated 文档将 get/set 称为兼容编译器的方式。set 还支持函数式更新:sv.set((v) => v + 1)。
  • 从 worklet 调用的函数必须以 'worklet' 作为首行,否则在设备上运行时会抛出错误,但在调试器中却能正常工作。
7. 用按压代替悬停

Web 上的每种悬停交互都需要重新设计,不能直接移植。

  • 按下时反馈,松开时确认。 等到点击完成后才显示反馈会显得迟钝——这正是用户实际感受到的延迟。
  • 所有可按压元素都使用 scale: 0.97,时长为 100–150ms,采用 Pressable + CSS 过渡。scale 会连同标签和图标一起缩放,因此看起来更有实体感。
  • 最小触摸区域为 44×44pt(Android 上为 48dp)。如果视觉元素更小,请添加 hitSlop,不要放大视觉元素。
  • 设置 pressRetentionOffset,避免手指稍微移动几像素就取消用户原本想要的按压。
  • 仅在采用 Material 风格的应用中使用 Android 涟漪效果。 在自定义设计的应用中,两端平台都使用相同的缩放效果,比只在一个平台使用涟漪效果更协调。
8. 触觉反馈

移动设备有 Web 不具备的感官体验。适度使用,会让应用显得更高级;到处使用,用户就会将其关闭。

时机调用
数值越过一个刻度 — 选择器、滑块卡点、分段控件Haptics.selectionAsync()
某物弹回原位、底部面板卡到某个位置、拖动操作确认Haptics.impactAsync(ImpactFeedbackStyle.Light)
重物落下、触发破坏性操作Haptics.impactAsync(ImpactFeedbackStyle.Medium)
操作成功或失败Haptics.notificationAsync(NotificationFeedbackType.Success / Error)

以下三条规则不可妥协:

  • 与视觉效果处于同一帧。 触觉反馈若滞后于动画,听起来就像故障,而不是反馈。应在触发原因出现的时刻 — 卡到某个位置时 — 触发,而不是等动画结束。
  • 每次用户操作只触发一次。 滚动时绝不触发,每帧绝不触发,用户未触发的入场动画也绝不触发。
  • 绝不能作为唯一反馈。 许多用户在系统层面关闭了触觉反馈,而且大多数 Android 设备不会发出触觉反馈。视觉效果必须独立传达信息。

在 worklet 中,必须将触觉反馈调度回 RN 运行时:scheduleOnRN(Haptics.selectionAsync)。

9. 减少动态效果与无障碍
import { useReducedMotion, ReduceMotion, withSpring } from 'react-native-reanimated';

const reduced = useReducedMotion();
const y = useSharedValue(reduced ? 0 : SHEET_HEIGHT);

// or let each animation decide
withSpring(0, { duration: 300, dampingRatio: 0.8, reduceMotion: ReduceMotion.System });

减少动态效果意味着更少、更柔和,而不是完全没有:保留能说明状态变化的不透明度和颜色变化,移除位移、缩放、视差和过冲。屏幕过渡改用 animation: 'fade'。

文本会缩放。 默认情况下,allowFontScaling 已启用,因此在默认字体大小下测量的任何高度,在 200% 缩放时都会不准确。绝不要动画到硬编码的高度 — 应使用 onLayout 测量,或改为动画变换。

会悄然破坏动态效果的设置

遇到“动画就是不运行”时,先检查以下内容:

  • 通过 Expo 安装,以确保版本与 SDK 匹配:npx expo install react-native-reanimated react-native-worklets。在 Expo 项目中,babel-preset-expo 会自动配置 worklets Babel 插件 — 无需修改 babel.config.js。只有未使用该预设的裸 RN 项目才需要手动添加插件,而且必须放在列表最后。插件缺失或位置不正确时,不再会悄然回退 — 而是在运行时抛出 Failed to create a worklet。
  • 必须用 GestureHandlerRootView 包裹应用,否则手势不会生效,且不会报错。
  • Reanimated 4 要求启用新架构。
  • Expo Go 不是性能测试环境。 应在发布构建中评估体验;开发构建的 JS 线程足够慢,会掩盖你正在寻找的问题。

120fps

在 ProMotion iPhone 上,除非设置了 CADisableMinimumFrameDurationOnPhone,否则第三方动画的帧率会限制在 60fps。近期的 Expo SDK 默认会设置此项 — 请确认它已存在,如未设置则添加:

{ "expo": { "ios": { "infoPlist": { "CADisableMinimumFrameDurationOnPhone": true } } } }

此时帧预算为 8ms,而非 16ms。这也是 UI 线程动画在移动设备上比在 Web 上更重要的原因。

实用方案

如需可直接构建的实现 — 按压反馈、拖动关闭底部面板、滑动删除、折叠标题栏、列表项入场效果、与键盘同步的 UI、标签指示器、屏幕过渡 — 请参阅 RECIPES.md。只要请求与其中某项匹配,就加载该文档;从对应方案着手,而不是从空白文件开始。

绝不使用

绝不使用改用
PanRespondergesture-handler 中的 Gesture.Pan()
在手势或滚动处理器中使用 setState共享值 + useAnimatedStyle
runOnJS(在 Reanimated 4 中已弃用)react-native-worklets 中的 scheduleOnRN
每帧调用 scheduleOnRNonEnd,或在达到阈值时使用 useAnimatedReaction
在渲染期间读取或写入共享值在 worklet、处理器和 effect 中使用 .get() / .set()
对手指触碰的任何内容使用 Core AnimatedReanimated
对 height / width / margin / flex / top 做动画transform + opacity(绝对定位且没有子元素的元素除外)
对 BlurView 强度或 Android elevation 做动画对静态图层做交叉淡化
在虚拟化列表行上使用 entering对容器做动画,或使用 itemLayoutAnimation
在 JS 中重建屏幕转场使用原生堆栈的 animation
在标签页之间滑动animation: 'none'
在 UI 元素上使用 Easing.in(...)Easing.bezier(0.23, 1, 0.32, 1)
使用 scale(0) 入场scale(0.95) + opacity: 0
仅基于距离的关闭阈值使用速度 或 距离——轻扫一下就足够
到边界时突然停止橡皮筋式阻力
每帧触发一次触觉反馈,或只提供触觉反馈每次提交触发一次,并始终搭配视觉反馈
在 Expo Go 或模拟器中判断手感发布构建版本,使用支持范围内速度最慢的设备

输出

编写代码。然后用几行以内的文字说明:

  • 门槛结果——频率级别和明确的用途。说明你拒绝了什么以及原因。
  • 组成要素——工具、属性、弹簧或曲线 + 时长、线程。
  • 需要在设备上检查的手感——无法仅凭代码判断手势、速度衔接和触觉反馈时机。说明要尝试什么:快速轻扫、中途打断、反向操作,并在你手头速度最慢的 Android 设备上运行。

代码才是交付物。不要把它扩写成报告。

语气

明确、有主见、简洁。如果实话是“这个不该做动画”,或“需要在真实设备上运行后才能判断是否合适”,就直说。

评论

支持 Markdown