【翻译】使用 react-native-screen-transitions 构建自定义转场

0 阅读9分钟

使用 react-native-screen-transitions 构建自定义转场

让应用更有「生命力」的做法有不少,而我笃信其中之一就是动效。

大多数人早已把系统动画刻进肌肉记忆里。正因如此,自定义转场如果用对了地方,往往效果很好:它恰好打破一点点惯性,让整条流程显得更有意图。

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-reference.gif

iOS 页面转场参考(MP4,原站)):

我们来拆解原生 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>
  );
}

ios-card-transition.gif

对应卡片栈转场的循环演示(GIF;原画质 MP4):

这样就好:一份非常接近 iOS 页面动画的复刻。

有意思的部分在 screenStyleInterpolator。我们用同一个根级 progress 来描述转场两侧:

  • progress0 走到 1:进入中的屏幕从 width 移动到 0
  • progress1 走到 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

以下为 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 分组进一步扩展

上面的例子在「一个明确的源」和「一个明确的目标」时很好用。图库场景更有趣:索引页可能是瀑布流网格,详情页则可以横向滑动多张图片后再关闭。

boundary-groups.gif

图库 + 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 StackBlank Stackreact-native-screen-transitions 附带的无内置动画、由开发者自定义转场的栈导航器
screenStyleInterpolatorscreenStyleInterpolator根据转场进度与布局返回内容层/背景层等动画样式的函数
transitionSpectransitionSpec打开/关闭转场所用的弹簧或时间曲线配置
Bounds APIBounds API用于测量边界并在屏幕间驱动几何对齐动画的接口
navigation.zoom()navigation.zoom()基于边界测量在源与目标之间做缩放交接的导航辅助方法
boundaryboundary转场包中为建立测量 id 与分组而使用的边界抽象
gesture choreographygesture choreography对手势节奏与交互方式的编排
snap pointssnap points手势结束时可吸附的目标位置
squirclesquircle超椭圆一类过渡圆角形态,观感介于圆角矩形与连续曲线之间
full-bleedfull-bleed内容铺满至边缘、无内边距留白
workletworkletReanimated 中在 UI 线程运行的函数标注
overshootClampingovershootClamping弹簧动画是否禁止越过目标值的过冲限制
shared elementshared element跨屏共享视觉连续性的元素动画范式
Reanimated-firstReanimated-first以 Reanimated 为主要动画与手势模型的工程取向