使用 react-native-screen-transitions 构建自定义转场
- 原文链接:reactnavigation.org/blog/2026/0…
- 原文作者:Ed(GitHub)
让应用更有「生命力」的做法有不少,而我笃信其中之一就是动效。
大多数人早已把系统动画刻进肌肉记忆里。正因如此,自定义转场如果用对了地方,往往效果很好:它恰好打破一点点惯性,让整条流程显得更有意图。
react-native-screen-transitions 是一套面向 React Navigation 的转场工具集,适合那些希望对导航动效拥有更强掌控力的流程。本文会先复刻一段 iOS 风格的页面转场,再逐步扩展到一个由边界驱动的 navigation.zoom() 流程。
动笔之前先说明一点:它并不是 @react-navigation/native-stack 或 @react-navigation/stack 的万能替代品。如果 native-stack 已经够用,就继续用它;如果 JS Stack 给你的控制力已经足够,那也很好。react-native-screen-transitions 更适合某种特定流程需要更大自由度时:自定义手势编排、吸附点(snap points)、由边界驱动的运动,或以 Reanimated 为先的转场模型。
环境搭建
react-native-screen-transitions 软件包提供了转场原语。请在项目目录下执行安装命令(下文可按标签切换 npm / yarn / pnpm / bun):
npm
npm install react-native-screen-transitions
yarn
yarn add react-native-screen-transitions
pnpm
pnpm add react-native-screen-transitions
bun
bun add react-native-screen-transitions
安装 peer 依赖
接下来安装 react-native-screen-transitions 所需的 peer 依赖,以及后文示例会用到的 @react-native-masked-view/masked-view:
请在项目目录下运行 Expo 安装命令(由 npx expo install 选择与当前 SDK 匹配的版本):
npx expo install react-native-reanimated react-native-gesture-handler \
@react-navigation/native @react-navigation/native-stack \
@react-navigation/elements react-native-screens \
react-native-safe-area-context \
@react-native-masked-view/masked-view
这会安装与你的 Expo SDK 版本兼容的各库版本。
npm
npm install react-native-reanimated react-native-gesture-handler \
@react-navigation/native @react-navigation/native-stack \
@react-navigation/elements react-native-screens \
react-native-safe-area-context \
@react-native-masked-view/masked-view
yarn
yarn add react-native-reanimated react-native-gesture-handler \
@react-navigation/native @react-navigation/native-stack \
@react-navigation/elements react-native-screens \
react-native-safe-area-context \
@react-native-masked-view/masked-view
pnpm
pnpm add react-native-reanimated react-native-gesture-handler \
@react-navigation/native @react-navigation/native-stack \
@react-navigation/elements react-native-screens \
react-native-safe-area-context \
@react-native-masked-view/masked-view
bun
bun add react-native-reanimated react-native-gesture-handler \
@react-navigation/native @react-navigation/native-stack \
@react-navigation/elements react-native-screens \
react-native-safe-area-context \
@react-native-masked-view/masked-view
如果你在 Mac 上并为 iOS 开发,请通过 CocoaPods 安装 pods 以完成链接:
npx pod-install ios
复刻 iOS 页面转场
我们来拆解原生 iOS 的页面动画,并尽可能贴近它:
- 进入中的屏幕从右侧滑入
- 下方的屏幕会略微向左移动
- 可选地,我们可以给页面加圆角;在较新的 iOS 上,圆角观感会更接近 squircle(超椭圆圆角)
从 Blank Stack 开始
Blank Stack 是随 react-native-screen-transitions 一起提供的导航器。它不带任何内置动画,因此每一次转场都由你定义——这正是我们想要的。
- 静态
import { createBlankStackNavigator } from 'react-native-screen-transitions/blank-stack';
const RootStack = createBlankStackNavigator({
screens: {
Home: HomeScreen,
Detail: DetailScreen,
},
});
- 动态
import { createBlankStackNavigator } from 'react-native-screen-transitions/blank-stack';
const Stack = createBlankStackNavigator();
function RootStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Detail" component={DetailScreen} />
</Stack.Navigator>
);
}
定义转场
定义转场需要配置两件事:手势如何表现,以及屏幕如何动画。
transitionSpec 控制打开与关闭的弹簧配置。screenStyleInterpolator 是一个函数,根据 progress、屏幕布局等值返回转场的动画样式。本例中我们保持简单,全部用根级的 progress 辅助量来驱动。
import { interpolate } from "react-native-reanimated";
import Transition, {
type ScreenTransitionConfig,
} from "react-native-screen-transitions";
const iosCardStackTransition: ScreenTransitionConfig = {
gestureEnabled: true,
gestureDirection: "horizontal",
transitionSpec: {
open: Transition.Specs.DefaultSpec,
close: Transition.Specs.DefaultSpec,
},
screenStyleInterpolator: ({ active, current, progress }) => {
"worklet";
const width = current.layouts.screen.width;
const translateX = interpolate(
progress,
[0, 1, 2],
[width, 0, -width * 0.3],
"clamp",
);
return {
content: {
borderRadius: active.settled ? 0 : DEVICE_CORNER_RADIUS,
borderCurve: active.settled ? "continuous" : "circular",
overflow: "hidden",
transform: [{ translateX }],
},
backdrop: {
backgroundColor: "rgba(0,0,0,1)",
opacity: interpolate(active.progress, [0, 1], [0, 0.1], "clamp"),
},
};
},
};
然后把该配置应用到栈上:
- 静态
const RootStack = createBlankStackNavigator({
screens: {
Home: HomeScreen,
Detail: {
screen: DetailScreen,
options: iosCardStackTransition,
},
},
});
- 动态
const Stack = createBlankStackNavigator();
function RootStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen
name="Detail"
component={DetailScreen}
options={iosCardStackTransition}
/>
</Stack.Navigator>
);
}
对应卡片栈转场的循环演示(GIF;原画质 MP4):
这样就好:一份非常接近 iOS 页面动画的复刻。
有意思的部分在 screenStyleInterpolator。我们用同一个根级 progress 来描述转场两侧:
- 当
progress从0走到1:进入中的屏幕从width移动到0 - 当
progress从1走到2:先前那一屏从0继续走到-width * 0.3 current.layouts.screen.width给出可用的完整距离transform: [{ translateX }]作用在content上,因此整块屏幕作为一个整体移动borderRadius: active.settled ? 0 : DEVICE_CORNER_RADIUS只在运动过程中圆角,结束后回到铺满(full-bleed)borderCurve让圆角在运动过程中更接近系统观感backdrop的淡入给当前活动屏幕下方增加一点点纵深感
为什么不用 JS Stack?
JS Stack 已经能做到这些,那意义何在?
就上面的例子而言,确实没有额外收益。JS Stack 做得很好,如果你只需要这些,用它就行。
react-native-screen-transitions 真正开始划算,是在转场需要「几何信息」的时候:一屏上某个组件的位置与尺寸,要动画到另一屏上的某个组件。这不是 JS Stack 能清晰表达的,也正是下一个例子得以成立的原因。
navigation.zoom()
以下为 navigation.zoom() 的界面演示(GIF;时长较长,需原画质可打开 MP4):
在 v3.4 里我很自豪地宣布 navigation.zoom()。
navigation.zoom() 是一个由边界驱动的辅助方法,用来复刻那种从源元素到目标屏幕的导航缩放交接。它通过 Bounds API 测量组件 A 与组件 B,然后在两者之间做动画。这并不是传统的共享元素(shared element)系统;如果你需要的是那一套,我建议等 Reanimated 的方案更成熟。
先从源屏幕开始。在真实流程里,卡片通常已经知道它对应哪条数据,因此把该条目的 id 作为 boundary id,并在导航时一路传下去。
function FeedCard({ item, navigation }) {
return (
<Transition.Boundary.Trigger
id={item.id}
onPress={() => {
navigation.navigate("Detail", { id: item.id });
}}
>
<Image source={item.image} style={styles.card} />
</Transition.Boundary.Trigger>
);
}
在目标屏幕上,使用来自 route.params 的同一个 id。你并不一定要在目标侧定义 Transition.Boundary.View,但若希望目标侧按组件 A 的边界来调整自身尺寸,就应该定义。
function DetailScreen({ route }) {
const item = getItem(route.params.id);
return (
<View style={styles.screen}>
<Transition.Boundary.View id={item.id} style={styles.hero}>
<Image source={item.image} style={styles.hero} />
</Transition.Boundary.View>
</View>
);
}
接下来编排动画。options 会收到 route,因此可以在其中解析同一个 id 并传入 bounds 辅助方法:
- 静态
const RootStack = createBlankStackNavigator({
screens: {
Feed: FeedScreen,
Detail: {
screen: DetailScreen,
options: ({ route }) => {
const zoomId = route.params.id;
return {
navigationMaskEnabled: Platform.OS === "ios",
gestureEnabled: true,
gestureDirection: ["vertical", "vertical-inverted", "horizontal"],
gestureDrivesProgress: false,
transitionSpec: {
open: Transition.Specs.DefaultSpec,
close: Transition.Specs.FlingSpec,
},
screenStyleInterpolator: ({ bounds }) => {
"worklet";
return bounds({ id: zoomId }).navigation.zoom({
target: "bound",
});
},
};
},
},
},
});
- 动态
const Stack = createBlankStackNavigator();
function RootStack() {
return (
<Stack.Navigator>
<Stack.Screen name="Feed" component={FeedScreen} />
<Stack.Screen
name="Detail"
component={DetailScreen}
options={({ route }) => {
const zoomId = route.params.id;
return {
navigationMaskEnabled: Platform.OS === "ios",
gestureEnabled: true,
gestureDirection: ["vertical", "vertical-inverted", "horizontal"],
gestureDrivesProgress: false,
transitionSpec: {
open: Transition.Specs.DefaultSpec,
close: Transition.Specs.FlingSpec,
},
screenStyleInterpolator: ({ bounds }) => {
"worklet";
return bounds({ id: zoomId }).navigation.zoom({
target: "bound",
});
},
};
}}
/>
</Stack.Navigator>
);
}
其中几项配置值得单独说明。
navigationMaskEnabled 依赖 @react-native-masked-view/masked-view。我保留平台判断,是因为在蒙版元素上动画化布局属性时,iOS 通常比 Android 稳定得多。
gestureDrivesProgress: false 表示拖动手势并不会直接「擦洗」栈的主转场进度。手势仍会更新实时拖动量,并仍在松手时参与是否关闭的判定,但缩放辅助方法保持对交互的主导,而不会像常见的可交互返回那样表现。
close: Transition.Specs.FlingSpec 会关闭 overshootClamping 并使用更松的弹簧,因此松手或甩动退出时会更自然。
用 boundary 分组进一步扩展
上面的例子在「一个明确的源」和「一个明确的目标」时很好用。图库场景更有趣:索引页可能是瀑布流网格,详情页则可以横向滑动多张图片后再关闭。
图库 + group 的演示原画质 MP4
这时 boundary 的 group 属性就派上用场了。可以把 group 理解为一族相关边界的命名空间。id 仍然选中具体条目,而 group 告诉系统这条目属于哪个集合。
先定义一个稳定的分组,以及用于当前激活条目的可变值:
export const GALLERY_GROUP = "gallery";
export const activeGalleryId = makeMutable(GALLERY_ITEMS[0].id);
在源屏幕上,每个缩略图使用各自的 id,但共享同一个 group:
<Transition.Boundary.Trigger
id={item.id}
group={GALLERY_GROUP}
onPress={() => {
activeGalleryId.set(item.id);
navigation.navigate("Detail", { id: item.id });
}}
>
<Image source={{ uri: item.uri }} style={styles.image} />
</Transition.Boundary.Trigger>
在目标屏幕上,对应的图片使用相同的 id 与相同的 group:
<Transition.Boundary.View
id={item.id}
group={GALLERY_GROUP}
style={{ width: imageWidth, height: imageHeight }}
>
<Image source={{ uri: item.uri }} style={styles.image} />
</Transition.Boundary.View>
随后转场同时向 bounds 请求这两个值:
const id = activeGalleryId.get();
return bounds({
id,
group: GALLERY_GROUP,
}).navigation.zoom({ target: "bound" });
当目标页保持挂载、但激活条目可能变化时,分组尤其有用。可变的 activeGalleryId 追踪当前激活 id,从而在需要重新测量边界时——例如在拖动或关闭之前——系统知道应该针对哪个元素重新测量。
图库示例还会在横向详情列表停到新一页时更新 activeGalleryId。这样,如果用户点开一张图、滑到另一张再关闭,转场会回到他们实际正在看的那张图,而不是最初点开的那张。
以上就是全部:纯 JS 里做出 SwiftUI navigation.zoom() 的味道。
如果你想动手改代码,两个示例的完整源码在 react-native-screen-transitions 的 GitHub 仓库。
Screen Transitions 的下一步
我一直在默默推进下一轮改进:把架构迁移到 Reanimated 4、Gesture Handler v3,以及 React 19 新的 Activity 组件。捏合缩小与捏合放大的转场也在进行中,不久后应该会有一些令人兴奋的更新。
感谢大家对 Screen Transitions 的支持。对我来说它几乎是 dream package,也很高兴其他人似乎同样兴奋。如果你用它做出了东西,我很乐意看到!
术语表(本篇命中)
| 术语 | 英文 | 释义 |
|---|---|---|
| Blank Stack | Blank Stack | react-native-screen-transitions 附带的无内置动画、由开发者自定义转场的栈导航器 |
| screenStyleInterpolator | screenStyleInterpolator | 根据转场进度与布局返回内容层/背景层等动画样式的函数 |
| transitionSpec | transitionSpec | 打开/关闭转场所用的弹簧或时间曲线配置 |
| Bounds API | Bounds API | 用于测量边界并在屏幕间驱动几何对齐动画的接口 |
| navigation.zoom() | navigation.zoom() | 基于边界测量在源与目标之间做缩放交接的导航辅助方法 |
| boundary | boundary | 转场包中为建立测量 id 与分组而使用的边界抽象 |
| gesture choreography | gesture choreography | 对手势节奏与交互方式的编排 |
| snap points | snap points | 手势结束时可吸附的目标位置 |
| squircle | squircle | 超椭圆一类过渡圆角形态,观感介于圆角矩形与连续曲线之间 |
| full-bleed | full-bleed | 内容铺满至边缘、无内边距留白 |
| worklet | worklet | Reanimated 中在 UI 线程运行的函数标注 |
| overshootClamping | overshootClamping | 弹簧动画是否禁止越过目标值的过冲限制 |
| shared element | shared element | 跨屏共享视觉连续性的元素动画范式 |
| Reanimated-first | Reanimated-first | 以 Reanimated 为主要动画与手势模型的工程取向 |