【翻译】我们如何打造v0版iOS应用

原文链接:vercel.com/blog/how-we...

作者:Fernando Rojo Head of Mobile

我们近期发布了iOS版v0,这是Vercel的首款移动应用。作为专注于web技术的公司,开发原生应用对我们而言是全新领域。

我们的目标是打造一款配得上苹果设计奖的应用,为此我们对技术栈的选择保持开放态度。在公开测试版发布前,我们进行了数十次产品迭代,尝试了截然不同的技术栈和UI模式。

我们从那些完美契合iPhone语言的应用中汲取灵感,例如Apple Notes和iMessage。v0必须在你的主屏幕上赢得一席之地,与这些杰作比肩。

经过数周的探索,我们最终选择React Native与Expo实现这一目标。我们对成果感到满意,用户同样如此。事实上,大量开发者询问应用为何如此原生的消息涌入,促使我们撰写了技术解析文章,详述实现过程。

我们如何打造v0版聊天体验、

当你离开电脑时,可能会突然灵光乍现,想要立即付诸行动。我们的目标是让你无需切换场景,就能将灵感转化为具体成果。iOS版v0是新一代笔记应用,让你的创意在后台悄然成形。

我们并非要打造功能与网站完全一致的移动集成开发环境,而是致力于构建简单愉悦的移动创作体验,让AI成为您随时随地实现创意的得力助手。而聊天功能正是这种体验的核心所在。

要打造出色的聊天体验,我们设定以下要求:

  • 新消息以平滑动画效果呈现
  • 用户新消息自动滚动至屏幕顶部
  • 助手消息以渐隐效果分批流式显示
  • 编辑器采用液态玻璃材质,悬浮于可滚动内容之上
  • 打开现有聊天时默认滚动至末尾
  • 键盘交互自然流畅
  • 文本输入支持粘贴图片和文件
  • 文本输入支持平移手势聚焦/模糊
  • Markdown支持快速编辑与动态组件

尽管移动端AI聊天已形成多种UI模式,但移动端AI代码生成领域尚无成熟范式。

现有React Native应用中未见这些特性,我们只能即兴创造模式。为达到标准,每个功能都经历了超乎寻常的工作量、测试与跨模块协调。

构建可组合的聊天系统

为满足需求,我们按功能模块对聊天代码进行了可组合化设计。

我们的聊天功能基于多个开源库实现:LegendListReact Native Reanimated 以及 React Native Keyboard Controller。首先,我们设置了多个上下文提供者。

JavaScript 复制代码
export function ChatProvider({ children }) {
  return (
    <ComposerHeightProvider>
      <MessageListProvider>
        <NewMessageAnimationProvider>
          <KeyboardStateProvider>{children}</KeyboardStateProvider>
        </NewMessageAnimationProvider>
      </MessageListProvider>
    </ComposerHeightProvider>
  )
}

提供者封装了MessagesList

JavaScript 复制代码
export function ChatMessagesList({ chatId }) {
  const messages = useMessages({ chatId }).data
  return (
    <ChatProvider key={chatId}>
      <MessagesList messages={messages} />
    </ChatProvider>
  )
}

接下来,我们的消息列表通过可组合插件实现了这些功能,每个插件都拥有专属的钩子。

JavaScript 复制代码
function MessagesList({ messages }) {
  useKeyboardAwareMessageList()
  useScrollMessageListFromComposerSizeUpdates()
  useUpdateLastMessageIndex()
  const { animatedProps, ref, onContentSizeChange, onScroll } = useMessageListProps()
  return (
    <AnimatedLegendList
      animatedProps={animatedProps}
      ref={ref}
      onContentSizeChange={onContentSizeChange}
      onScroll={onScroll}
      enableAverages={false}
      data={messages}
      keyExtractor={(item) => item.id}
      renderItem={({ item, index }) => {
        if (item.role === 'user') {
          return <UserMessage message={item} index={index} />
        }
        if (item.role === 'assistant') {
          return <AssistantMessage message={item} index={index} />
        }
        if (item.role === 'optimistic-placeholder') {
          return <OptimisticAssistantMessage index={index} />
        }
      }}
    />
  )
}

以下各节将逐一剖析每个钩子,以展示它们如何协同工作。

发送第一条消息

当你在v0版本发送消息时,消息气泡会平滑淡入并滑动至顶部。用户消息动画完成后,助手消息会立即淡入显示。

当用户发送消息时,我们会设置一个名为 Reanimated 的共享值来指示动画应开始播放。共享值使我们能够更新状态而不触发重新渲染。

JavaScript 复制代码
const { isMessageSendAnimating } = useNewMessageAnimation()
const chatId = useChatId()

const onSubmit = () => {
  const isNewChat = !chatId
  
  if (isNewChat) {
    isMessageSendAnimating.set(true)
  }
  
  send()
}

在 Reanimated 中追踪状态后,现在可以为 UserMessage 添加动画效果了。

JavaScript 复制代码
export function UserMessage({ message, index }) {
  const isFirstUserMessage = index === 0
  
  const { style, ref, onLayout } = useFirstMessageAnimation({
    disabled: !isFirstUserMessage,
  })
  
  return (
    <Animated.View style={style} ref={ref} onLayout={onLayout}>
      <UserMessageContent message={message} />
    </Animated.View>
  )
}

请注意,UserMessageContent 被包裹在 Animated.View 中,该组件从 useFirstMessageAnimation 接收 props。

useFirstMessageAnimation 如何工作

此钩子负责三项任务:

  • 使用共享值 itemHeight(Reanimated 类)测量用户消息的高度
  • isMessageSendAnimating 时淡入消息
  • 向助手消息发出动画完成信号
JavaScript 复制代码
export function useFirstMessageAnimation({ disabled }) {
  const { keyboardHeight } = useKeyboardContextState()
  const { isMessageSendAnimating } = useNewMessageAnimation()
  const windowHeight = useWindowDimensions().height
  const translateY = useSharedValue(0)
  const progress = useSharedValue(-1)
  const { itemHeight, ref, onLayout } = useMessageRenderedHeight()

  useAnimatedReaction(
    () => {
      const didAnimate = progress.get() !== -1

      if (disabled || didAnimate || !isMessageSendAnimating.get()) {
        return -1
      }

      return itemHeight.get()
    },
    (messageHeight) => {
      if (messageHeight <= 0) return

      const animatedValues = getAnimatedValues({
        itemHeight: messageHeight,
        windowHeight,
        keyboardHeight: keyboardHeight.get(),
      })
      const { start, end, duration, easing, config } = animatedValues

      translateY.set(
        // initialize values at the "start" state with duration 0
        withTiming(start.translateY, { duration: 0 }, () => {
          // next, transition to the "end" state
          translateY.set(withSpring(end.translateY, config))
        })
      )
      progress.set(
        withTiming(start.progress, { duration: 0 }, () => {
          progress.set(withTiming(end.progress, { duration, easing }), () => {
            isMessageSendAnimating.set(false)
          })
        })
      )
    }
  )

  const style = useAnimatedStyle(...)
  const didUserMessageAnimate = useDerivedValue(() => progress.get() === 1)
  
  return { style, ref, onLayout, didUserMessageAnimate }
}

得益于 React Native 的新架构,useLayoutEffect 中的 ref.current.measure() 操作是同步的,因此首次渲染时即可获取高度值。后续更新则在 onLayout 中触发。

基于消息高度、窗口高度和当前键盘高度,getAnimatedValues 构造 translateYprogress 的缓动、startend 状态。生成的共享值分别作为 transformopacity 传递给 useAnimatedStyle

至此,我们的首条消息已通过 Reanimated 实现淡入效果。动画完成后,即可开始淡入首个助手消息回复。

淡入第一助理的留言

UserMessage 类似,助手消息内容被包裹在一个动画视图中,该视图会在用户消息动画完成后淡入显示。

JavaScript 复制代码
function AssistantMessage({ message, index }) {
  const isFirstAssistantMessage = index === 1
  
  const { didUserMessageAnimate } = useFirstMessageAnimation({
    disabled: !isFirstAssistantMessage,
  })

  const style = useAnimatedStyle(() => ({
    opacity: didUserMessageAnimate.get() ? withTiming(1, { duration: 350 }) : 0,
  }))
  
  return (
    <Animated.View style={style}>
      <AssistantMessageContent message={message} />
    </Animated.View>
  )
}

此淡入效果仅适用于聊天中的首条助手消息(即 index === 1 的消息)。现有聊天中的消息与新聊天中的消息将呈现不同行为。

若打开一个已存在聊天(包含一条用户消息和一条助手消息),是否会再次触发动画?不会,因为该动画仅在 isMessageSendAnimatingtrue 时生效------该属性在消息 onSubmit 时设置,切换聊天时则被清除。

在现有聊天中发送消息

我们已介绍过v0如何处理新聊天消息中的动画效果。但对于现有聊天,其逻辑则完全不同。我们不再依赖Reanimated动画(如 usedFirstMessageAnimation 中使用的动画),而是采用 scrollToEnd() 的实现方案。

那么在现有聊天中发送消息时,我们只需滚动到末尾即可,对吧?

JavaScript 复制代码
useEffect(function onNewMessage() {
  const didNewMessageSend = // ...some logic
  if (didNewMessageSend) {
    listRef.current?.scrollToEnd()
  }
}, ...)

在理想状态下,这套逻辑本应足够完善。但让我们探讨为何它仍显不足。

回顾开篇所述,我们的需求之一是新消息必须滚动至屏幕顶部。若直接调用scrollToEnd()方法,新消息只会显示在屏幕底部。

我们需要一种策略将用户消息推至聊天顶部。我们将此称为"空白大小":即最后一条助手消息底部与聊天结束之间的距离。

为将内容浮动至聊天顶部,我们需将其向上推送与空白区域等量的距离。得益于React Native新架构中的同步高度测量机制,我们得以在每帧更新时实现无闪烁效果。但这仍需大量技巧与协调配合。

在上图中,您会注意到空白区域具有动态特性。其高度取决于键盘的展开状态。由于助手消息以不可预测的大小快速流式传输,每次渲染时空白区域都可能发生变化。

动态高度是虚拟化列表的常见难题,而频繁更新的空白区域将这一挑战推向新高度:我们的列表项具有动态未知高度且更新频繁,同时需要保持顶部悬浮状态。

当助手消息足够长时,空白区域可能归零,这又引入了一系列新的边界情况。

如何解决这个问题

我们尝试了多种实现空白区域的方法:在 ScrollView 底部添加带高度的 View、为 ScrollView 本身设置底部填充、对可滚动内容应用 translateY 属性、以及在最后一条系统消息设置最小高度。这些方案最终都导致了奇怪的副作用和性能问题,通常源于需要配合Yoga布局引擎进行渲染。

最终我们采用 ScrollViewcontentInset 属性来处理空白区域,避免了抖动现象。contentInset 属性直接映射至 UIKit 中 UIScrollView 的原生属性。

在发送消息时,我们结合使用 contentInsetscrollToEnd({ offset }) 方法。

助手消息的空白区域由三部分共同决定:其自身高度、前置用户消息的高度,以及聊天容器的高度。

实现 useMessageBlankSize

要实现空白尺寸功能,我们首先在助手消息中使用名为 useMessageBlankSize 的钩子:

JavaScript 复制代码
function AssistantMessage({ message, index }) {
  // ...styling logic
  const { onLayout, ref } = useMessageBlankSize({ index })
  return (
    <Animated.View ref={ref} onLayout={onLayout}>
      <AssistantMessageContent message={message} />
    </Animated.View>
  )
}

useMessageBlankSize 负责以下逻辑:

  • 同步测量助手消息
  • 测量其前方的用户消息
  • 计算助手消息下方空白区域的最小距离
  • 记录键盘展开或收起时应设置的空白区域大小
  • 在根上下文提供者中设置共享的 blankSize

最后,我们获取 blankSize 大小并将其传递给 ScrollView 的内容 contentInset

JavaScript 复制代码
export function MessagesList(props) {
  const { blankSize, composerHeight, keyboardHeight } = useMessageListContext()

  const animatedProps = useAnimatedProps(() => {
    return {
      contentInset: {
        bottom: blankSize.get() + composerHeight.get() + keyboardHeight.get(),
      },
    }
  })

  return <AnimatedLegendList {...props} animatedProps={animatedProps} />
}

Reanimated 的 useAnimatedProps 功能让我们能在每个帧上更新 UI 线程的 props,而不会触发重新渲染。contentInset 展现了卓越的性能表现,远胜于以往所有尝试。

驯服键盘

打造优质的聊天体验,关键在于优雅的键盘处理。在React Native中实现原生般的操作体验曾是艰巨而繁琐的任务。当v0 iOS处于公开测试阶段时,苹果发布了iOS 26。每次iOS测试版更新,我们的聊天功能似乎都会彻底崩溃。每次iOS版本发布,都变成一场追逐细微差异和抖动的猫鼠游戏。

所幸,react-native-keyboard-controller 的维护者 Kiryl 协助我们解决了这些问题,通常在苹果发布新测试版后的 24 小时内就更新了库文件。

构建useKeyboardAwareMessageList

我们利用React Native键盘控制器提供的多个钩子,构建了专为v0版本聊天功能定制的键盘管理系统。

useKeyboardAwareMessageList 是我们自定义的React钩子,负责所有键盘处理逻辑。它与聊天列表并行渲染,并抽象化了所有确保键盘交互体验的关键组件。

JavaScript 复制代码
function MessagesList() {
  useKeyboardAwareMessageList()
  
  // ...rest of the message list
}

虽然该功能的调用仅需一行代码,但其内部实现包含约1000行代码及大量单元测试。useKeyboardAwareMessageList 主要依赖上游的 useKeyboardHandler 处理 onStartonEndonInteractive 等事件,同时结合多次调用Reanimated的 useAnimatedReaction 方法,在特定边界条件下重试事件。

该组件还处理了iOS系统中的若干异常行为。例如当键盘开启时将应用切换至后台,随后重新聚焦应用时,iOS会莫名触发三次键盘 onEnd 事件。由于我们依赖命令式行为处理事件触发,因此设计了特殊机制来消除重复事件并追踪应用状态变化。

useKeyboardAwareMessageList 实现以下功能:

  1. 键盘弹出时缩小blankSize
  2. 若滚动至聊天末尾且无空白区域,则键盘弹出时将内容向上移动
  1. 若滚动到足够高的位置且没有空白区域,则在内容上方显示键盘,且不移动内容本身。
  1. 当用户通过滚动视图或文本输入交互式地关闭键盘时,将其平滑地向下拖动。
  1. 若已滚动至聊天窗口末尾,且空白区域大于键盘尺寸,内容应保持原位不变。
  1. 若滚动至聊天窗口底部时空白区域大于零,但键盘展开时该区域应为零,则将内容上移至键盘上方。

要让这一切顺利运行,并没有什么一劳永逸的诀窍。我们耗费数十小时反复使用这款应用,发现缺陷、追踪问题,不断重写逻辑,直到感觉一切都恰到好处。

初始滚动至末尾

当您打开现有聊天时,v0版本会将聊天界面滚动至末尾。这类似于在React Native的 FlatList 中使用 inverted 属性,这种设计常见于自下而上的聊天界面。

然而我们最终放弃了 inverted 方案,因为它与AI聊天场景存在冲突------AI聊天每秒会产生多次消息流。我们选择不采用助手消息流自动滚动方案,而是让内容自然填充在键盘下方区域,并配有滚动至末尾的按钮。此设计与ChatGPT的iOS应用行为一致。

但我们仍希望在首次打开现有聊天时提供倒序列表式体验。为此,当聊天首次可见时,我们会调用 scrollToEnd 方法实现滚动至末尾的效果。

由于动态消息高度与空白区域的复杂组合,我们不得不多次调用 scrollToEnd 方法。若不如此操作,列表要么无法正常滚动,要么滚动时机过晚。内容完成滚动后,我们调用 hasScrolledToEnd.set(true) 方法,使聊天内容渐显。

JavaScript 复制代码
import { scheduleOnRN } from 'react-native-worklets'

export function useInitialScrollToEnd(blankSize, scrollToEnd, hasMessages) {
  const hasStartedScrolledToEnd = useSharedValue(false)
  const hasScrolledToEnd = useSharedValue(false)
  const scrollToEndJS = useLatestCallback(() => {
    scrollToEnd({ animated: false })
    // Do another one just in case because the list may not have fully laid out yet
    requestAnimationFrame(() => {
      scrollToEnd({ animated: false })

      // and another one again in case
      setTimeout(() => {
        scrollToEnd({ animated: false })

        // and yet another!
        requestAnimationFrame(() => {
          hasScrolledToEnd.set(true)
        })
      }, 16)
    })
  })

  useAnimatedReaction(
    () => {
      if (hasStartedScrolledToEnd.get() || !hasMessages) {
        return false
      }
      return blankSize.get() > 0
    },
    (shouldScroll) => {
      if (shouldScroll) {
        hasStartedScrolledToEnd.set(true)
        scheduleOnRN(scrollToEndJS)
      }
    }
  )

  return hasScrolledToEnd
}

漂浮编辑器

受iOS 26中iMessage底部工具栏的启发,我们打造了一款采用渐进式模糊效果的液态玻璃编辑器。

我们使用@callstack/liquid-glass库实现了交互式液态玻璃效果。通过用 LiquidGlassContainerView 包裹玻璃视图,即可自动获得视图变形效果。

JavaScript 复制代码
<LiquidGlassContainerView spacing={8}>
  <LiquidGlassView interactive>...</LiquidGlassView>
  <LiquidGlassView interactive>...</LiquidGlassView>
</LiquidGlassContainerView>

悬浮

添加液体玻璃后,下一步是让它漂浮在聊天内容的顶部。

为使编辑器悬浮于可滚动内容之上,我们采取了以下步骤:

  1. 在编辑器中添加:position: absolute; bottom: 0
  2. 使用 react-native-keyboard-controller 中的 KeyboardStickyView 包裹编辑器
  3. 同步测量编辑器高度,并通过共享值将其存储在上下文中
  4. composerHeight.get() 添加到 ScrollView 的原生 contentInset.bottom 属性中
JavaScript 复制代码
function Composer() {
  const { composerHeight } = useComposerHeightContext()
  const { onLayout, ref } = useSyncLayoutHandler((layout) => {
    composerHeight.set(layout.height)
  })
  const insets = useInsets()

  return (
    <KeyboardStickyView
      style={{ position: 'absolute', bottom: 0, left: 0, right: 0 }}
      offset={{ closed: -insets.bottom, opened: -8 }}
    >
      <View
        ref={ref}
        onLayout={onLayout}
      >
        {/* ... */}
      </View>
    </KeyboardStickyView>
  )
}

然而这还不够。我们仍缺少一个关键行为。

当您输入文字时,文本输入框的高度会随之增加。当您输入新行时,我们需要模拟在常规非绝对定位输入框中的输入体验。为此我们必须找到一种方法,在您滚动到聊天记录末尾时将聊天内容向上移动。

在下面的视频中,你可以看到两种情况。视频开始时,由于聊天滚动到末尾,内容会随着新行向上移动。然而,在聊天中向上滚动后,输入新行不会移动内容。

useScrollWhenComposerSizeUpdates

使用useScrollWhenComposerSizeUpdates钩子。该钩子监听组合器高度变化,并在需要时自动滚动至末尾。要使用它,只需在MessagesList中调用即可:

JavaScript 复制代码
export function MessagesList() {
  useScrollWhenComposerSizeUpdates()
 
  // ...message list code  
}

首先,它通过 useAnimatedReaction 设置效果来追踪编辑器高度变化。

接着调用 autoscrollToEnd 方法。只要你靠近可滚动区域的末尾,系统就会自动滚动至聊天窗口底部。若无此机制,在编辑器中输入新行时内容会覆盖可滚动区域的底部。

useScrollWhenComposerSizeUpdates 让我们能够条件性地模拟非绝对定位视图的交互体验。

JavaScript 复制代码
export function useScrollWhenComposerSizeUpdates() {
  const { listRef, scrollToEnd } = useMessageListContext()
  const { composerHeight } = useComposerHeightContext()

  const autoscrollToEnd = () => {
    const list = listRef.current
    if (!list) {
      return
    }

    const state = list.getState()
    const distanceFromEnd =
      state.contentLength - state.scroll - state.scrollLength

    if (distanceFromEnd < 0) {
      scrollToEnd({ animated: false })
      // wait a frame for LegendList to update, and fire it again
      setTimeout(() => {
        scrollToEnd({ animated: false })
      }, 16)
    }
  }

  useAnimatedReaction(
    () => composerHeight.get(),
    (height, prevHeight) => {
      if (height > 0 && height !== prevHeight) {
        scheduleOnRN(autoscrollToEnd)
      }
    }
  )
}

正如我们在之前的代码中所见,我们不得不依赖多个 setTimeoutrequestAnimationFrame 调用来实现 scrollToEnd 功能。这段代码难免会让人感到困惑,但当时这是唯一能让滚动结束功能正常运作的方法。我们正与 LegendList 的维护者 Jay 积极合作,致力于构建更可靠的解决方案。

逼近原生体验

React Native内置的 TextInput 组件在原生聊天应用中显得格格不入。

默认情况下,当设置multiline={true}时,TextInput 会显示难看的滚动指示器,这与大多数聊天应用不符。在输入框上下滑动会导致内部内容弹跳,即使尚未输入任何文本也是如此。此外,该输入框不支持交互式键盘关闭功能。

为解决这些问题,我们在原生代码中为 RCTUITextView 应用了补丁。该补丁禁用了滚动指示器,移除了弹跳效果,并启用了交互式键盘关闭功能。

我们的补丁还新增了向上滑动聚焦输入区域的功能。在观察测试人员因期待键盘弹出而焦躁地向上滑动后,我们意识到这个功能的必要性。

JavaScript 复制代码
diff --git a/Libraries/Text/TextInput/Multiline/RCTUITextView.mm b/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
index 6e9c3841cee19632eaa59ae2dbd541a85ce7cabf..e3f920acbc2bb074582ed2b531ddd90e2017d59c 100644
--- a/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
+++ b/Libraries/Text/TextInput/Multiline/RCTUITextView.mm
@@ -55,6 +55,16 @@ - (instancetype)initWithFrame:(CGRect)frame
     self.textContainer.lineFragmentPadding = 0;
     self.scrollsToTop = NO;
     self.scrollEnabled = YES;
+
+    // Fix bouncing, scroll indicator, and keyboard mode gesture
+    self.showsVerticalScrollIndicator = NO;
+    self.showsHorizontalScrollIndicator = NO;
+    self.bounces = NO;
+    self.alwaysBounceVertical = NO;
+    self.alwaysBounceHorizontal = NO;
+    self.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive;
+    [self.panGestureRecognizer addTarget:self action:@selector(_handlePanToFocus:)];
+
     _initialValueLeadingBarButtonGroups = nil;
     _initialValueTrailingBarButtonGroups = nil;
   }
@@ -62,6 +72,18 @@ - (instancetype)initWithFrame:(CGRect)frame
   return self;
 }
 
+- (void)_handlePanToFocus:(UIPanGestureRecognizer *)g
+{
+    if (self.isFirstResponder) { return; }
+    if (g.state != UIGestureRecognizerStateBegan) { return; }
+    CGPoint v = [g velocityInView:self];
+    CGPoint t = [g translationInView:self];
+    // Add pan gesture to focus the keyboard
+    if (v.y < -250.0 && !self.isFirstResponder) {
+        [self becomeFirstResponder];
+    }
+}
+
 - (void)setDelegate:(id<UITextViewDelegate>)delegate
 {
   // Delegate is set inside `[RCTBackedTextViewDelegateAdapter initWithTextView]` and

虽然在React Native更新中维护补丁并非理想方案,但这是我们找到的最切实可行的解决方案。我们更希望存在官方API来扩展原生视图而无需打补丁,若社区对此感兴趣,我们计划将此补丁贡献给React Native核心团队。

粘贴图片

为支持在文本输入框中粘贴图片和文件,我们使用了一个Expo模块,该模块会监听来自原生 UIPasteboard 的粘贴事件。

若粘贴的文本足够长,onPaste 将自动将粘贴内容转换为 .txt 文件附件。

JavaScript 复制代码
<TextInputWrapper onPaste={pasted => ...}>
  <TextInput />
</TextInputWrapper>

由于难以在原生代码中扩展现有的 TextInput 组件,我们采用了 TextInputWrapper 组件------它通过Swift语言封装 TextInput 并遍历其 subviews 。若需深入了解原生封装组件的创建示例,欢迎观看我2024年的演讲《勇于构建原生库》

流媒体内容渐隐效果

当AI助手的消息流传入时,必须保持流畅体验。为实现这一目标,我们创建了两个组件:

  1. <FadeInStaggeredIfStreaming />
  2. <TextFadeInStaggeredIfStreaming />

只要某个元素被这些组件之一包裹,其子元素就会以错落有致的动画效果平滑淡入。

JavaScript 复制代码
const mdxComponents = {
  a: function A(props) {
    return (
      <Elements.A {...props}>
        <TextFadeInStaggeredIfStreaming>
          {props.children}
        </TextFadeInStaggeredIfStreaming>
      </Elements.A>
    )
  },
  // ...other components
}

在底层实现中,这些组件渲染了 FadeInStaggered 的变体,该组件负责状态管理:

JavaScript 复制代码
const useIsAnimatedInPool = createUsePool()

function FadeInStaggered({ children }) {
  const { isActive, evict } = useIsAnimatedInPool()
  return isActive ? <FadeIn onFadedIn={evict}>{children}</FadeIn> : children
}

useIsAnimatedInPool 是 React 外部的一个自定义状态管理器,允许同时渲染有限数量的有序元素。元素在挂载时请求加入池,isActive 属性指示其是否应渲染动画节点。

onFadedIn 回调触发后,我们会将元素从池中移除,直接渲染其子元素而不使用动画包装器。这有助于限制同时活动的动画节点数量。

最后,FadeIn 实现交错动画效果,元素间延迟为 32 毫秒。交错动画按计划执行,每次批量渲染 2 个元素。当交错元素队列超过 10 个时,我们会根据队列大小增加批处理元素数量。

JavaScript 复制代码
const useStaggeredAnimation = createUseStaggered(32)

function FadeIn({ children, onFadedIn, Component }) {
  const opacity = useSharedValue(0)

  const startAnimation = () => {
    opacity.set(withTiming(1, { duration: 500 }))
    setTimeout(onFadedIn, 500)
  }

  useStaggeredAnimation(startAnimation)

  return <Component style={{ opacity }}>{children}</Component>
}

TextFadeInStaggeredIfStreaming 采用类似策略。我们首先将单词拆分为独立文本节点,随后创建一个容纳文本元素的独立池,并设置4个元素的上限。这确保每次淡入显示的单词不超过4个。

JavaScript 复制代码
const useShouldTextFadePool = createUsePool(4)

function TextFadeInStaggeredIfStreaming(props) {
  const { isStreaming } = use(MessageContext)
  const { isActive } = useShouldTextFadePool()

  const [shouldFade] = useState(isActive && isStreaming)

  let { children } = props
  if (shouldFade && children) {
    if (Array.isArray(children)) {
      children = Children.map(children, (child, i) =>
        typeof child === 'string' ? <AnimatedFadeInText key={i} text={child} /> : child,
      )
    } else if (typeof children === 'string') {
      children = <AnimatedFadeInText text={children} />
    }
  }

  return children
}

function AnimatedFadeInText({ text }) {
  const chunks = text.split(' ')

  return chunks.map((chunk, i) => <TextFadeInStaggered key={i} text={chunk + ' '} />)
}

function TextFadeInStaggered({ text }) {
  const { isActive, evict } = useIsAnimatedInPool()
  return isActive ? <FadeIn onFadedIn={evict}>{text}</FadeIn> : text
}

这种方法存在的一个问题是它高度依赖于在挂载时触发动画效果。因此,如果你发送一条消息后切换到其他聊天窗口,然后在消息发送完成前返回原聊天窗口,动画效果会重新挂载并再次播放。

为缓解此问题,我们设计了跨聊天记录追踪动画内容的系统。具体实现是在消息树顶层使用 DisableFadeProvider 组件,并在根级淡出组件中调用该组件,以避免影响资源池。

JavaScript 复制代码
function TextFadeInStaggeredIfStreaming(props) {
  const { isStreaming } = use(MessageContext)
  const { isActive } = useShouldTextFadePool()
  const isFadeDisabled = useDisableFadeContext()

  const [shouldFade] = useState(!isFadeDisabled && isActive && isStreaming)
  
  if (shouldFade) // here we render TextFadeIn...
  
  return props.children
}

虽然在非响应式场景下显式依赖 useState 的初始值看似不寻常,但这种做法能让我们根据元素挂载顺序可靠地追踪其动画状态。

在网页和原生应用之间共享代码

在开发v0版iOS应用时,一个自然的问题浮现:Web端与原生端应共享多少代码?

鉴于v0版Web单仓库的成熟度,我们决定共享类型和辅助函数,但不共享UI或状态管理。同时我们着力将业务逻辑从客户端迁移至服务器端,使v0版移动应用成为API的轻量封装层。

构建共享API

在成熟的Next.js应用与新移动应用之间共享后端API路由带来了挑战。v0网页应用采用React服务器组件和服务器操作驱动,而移动应用则更像单页React应用。

为解决此问题,我们基于自研后端框架构建了API层。该框架通过强制要求使用Zod定义输入输出类型,实现了运行时类型安全。

定义路由后,系统会根据各路由的Zod类型生成https://api.v0.dev/v1/openapi.json文件。移动端通过Hey API消费OpenAPI规范,该工具会生成辅助函数供Tanstack Query调用。

JavaScript 复制代码
import { termsFindOptions } from '@/api' // this folder is generated
import { useQuery } from '@tanstack/react-query'

export function useTermsQuery({ after }) {
  return useQuery(termsFindOptions({ after }))
}

这项工作促成了v0平台API的开发。我们最初旨在为自有原生客户端打造理想的API,最终决定将该API开放给所有用户。得益于此策略,v0移动端与v0平台API客户使用相同的接口路径和逻辑。

每次提交代码时,我们都会运行测试以确保OpenAPI规范的变更与移动应用兼容。

未来我们计划通过在平台API外层构建类型级RPC封装器,彻底消除代码生成环节。

样式

v0版本采用react-native-unistyles进行样式和主题管理。基于对React Native的实践经验,我始终对渲染阶段的操作保持谨慎。与我们评估过的其他样式库不同,Unistyles能在不重新渲染组件或访问React上下文的情况下实现全面主题管理。

原生菜单

除了Unistyles主题和样式库外,我们并未采用基于JS的组件库,而是尽可能依赖原生元素。

对于菜单,我们采用了Zeego框架,该框架在底层依赖https://github.com/dominicstop/react-native-ios-context-menu来渲染原生UIMenu。当使用Xcode 26进行构建时,Zeego会自动渲染Liquid Glass样式的菜单。

原生Alert

在 iOS 26 系统上运行的 React Native 应用存在 Alert 渲染偏离屏幕的问题。我们在自家应用及众多热门 React Native 应用中复现了该问题。我们通过本地补丁修复了该问题,并与 Callstack 和 Meta 的开发者合作,将修复方案提交至 React Native

原生底部菜单

对于底层表单,我们采用了内置的React Native模态框并设置presentationStyle="formSheet"。但该方案存在若干缺陷,我们通过补丁进行了修复。

修复Yoga闪烁问题

若将 flex: 1View 嵌入带背景色的模态框中,随后上下拖动模态框,该视图底部会出现剧烈闪烁现象。

为解决此问题,我们对 React Native 进行了本地补丁修复,使 Yoga 框架支持模态框的同步更新。通过与 Callstack、Expo 和 Meta 的开发者协作,该改进已合并至 React Native 核心库。此功能现已随 React Native 0.82 版本正式发布

期待

在使用React Native和Expo构建了首个应用后,我们再未回头。若您尚未体验iOS版v0,请立即下载并通过App Store评论告诉我们您的想法。

我们正在招募开发者加入Vercel移动团队。若此类工作令您心动,我们期待您的加入

在Vercel,我们致力于打造最高水准的雄心勃勃的产品。我们希望让Web和原生开发者同样轻松实现这一目标,并计划开源我们的研究成果。若您有意参与AI聊天应用开源库的测试版测试,请通过X平台联系我们。期待与社区携手,共同推动React Native持续进步。

相关推荐
编程猪猪侠1 小时前
打造高灵活度动态表单:基于 React + Ant Design 的 useDynamicForm hooks 实现思路
前端·react.js·前端框架
阿民不加班1 小时前
【React】使用browser-image-compression在上传前压缩图片、react上传图片压缩
前端·javascript·react.js
前端_yu小白1 小时前
前端实现录音,获取流分析音量大小,设置相应的动画
前端·mediarecorder·录音·浏览器安全性检查·https部署
虎子_layor1 小时前
小程序登录到底是怎么工作的?一次请求背后的三方信任链
前端·后端
草字1 小时前
css 父节点设置display: flex; align-items: center;,子节点如何跟随其他子节点撑高的高度
前端·javascript·css
我命由我123451 小时前
微信小程序 - 页面跳转并传递参数(使用路由参数、使用全局变量、使用本地存储、使用路由参数结合本地存储)
开发语言·前端·javascript·微信小程序·小程序·前端框架·js
DJ斯特拉1 小时前
日志技术Logback
java·前端·logback
HIT_Weston1 小时前
49、【Ubuntu】【Gitlab】拉出内网 Web 服务:http.server 单/多线程分析(一)
前端·ubuntu·gitlab
涔溪1 小时前
Vue3 中ref和reactive的核心区别是什么?
前端·vue.js·typescript