将图像粘贴到TextInput中不应如此困难

聊天应用中一次小小的粘贴交互如何演变成expo-paste-input——一个为React Native TextInput添加图片、GIF和贴纸粘贴功能的原生Expo模块。

中文
复制
Pasting images into TextInput should not be this hard

本文是客座文章,作者是 Arunabh Verma ——React Native 开发者、 Powstać 创始人,大部分时间都在用 React Native 做移动产品,关注性能、交互设计,以及那些让应用显得精致、顺手的小细节。

Powstać 当时在做一款聊天应用。聊天本身已经支持媒体:用户可以添加图片、上传文件、分享媒体,和任何一款现代移动应用该有的样子一样。

但有一个很小的交互缺失了:复制一张图片,点开聊天输入框,粘贴。

从用户角度看,这理所当然。原生应用一直都能这么做。你复制一张截图、一个表情包、一张 GIF,或者从别的应用里复制一张图片,粘贴进输入框,它就作为附件出现了。不用打开文件选择器,不用多点几下,没有任何多余步骤。粘贴就完了。

于是我决定把它加上。

原有的方案

一开始我们用的是 Mattermost 的 react-native-paste-input,当时 Bluesky 用的也是这个库。

它够用。iOS 和 Android 都支持粘贴图片,不用自己写原生代码就解决了眼前的问题。对我们这款聊天应用来说,体验上的提升立竿见影:用户复制一张图片,直接粘贴进对话就行。

功能很小,但带来的改善出乎意料地明显。

后来我们迁移到 React Native 的新架构,问题开始出现。

不是那种干净利落的「一条明确的报错信息」式的坏掉。这个库是一块一块坏的。有些部分还能用,有些不能;有些功能在一个平台上正常,在另一个平台上就挂了。

我们打补丁,再打补丁。最后 iOS 勉强还能用,Android 却越来越难维护。这个库并没有针对我们需要的变化持续演进,每打一个补丁都像是又一次临时修补。

到某个时候我意识到,我们花在绕开这个方案上的时间,已经超过了它带来的价值。

重新造一个输入框并不是答案

差不多同一时间,Software Mansion 正在通过 react-native-enriched 做更丰富的输入能力。这个项目很有意思,也是 React Native 里做富文本和富输入体验比较好的方向之一。

我考虑过采用它,也考虑过完全自己写一个自定义输入框。

然后我停下来想了想这意味着什么。

做一个输入框听起来简单,直到你开始列举原生文本输入框已经具备的功能:

  • 文本选择

  • 光标管理

  • 自动填充

  • 无障碍

  • 键盘行为

  • 剪贴板集成

  • 输入法支持

  • 输入辅助项

  • 各平台的边界情况

这些是用户早已习惯的、多年积累下来的原生行为。我不想再造一个 TextInput,我只想要粘贴支持。

react-native-enriched 解决的是一个庞大得多的问题。它支持富内容编辑和嵌入式媒体,功能很强。但我的使用场景不一样。

当用户把一张图片粘贴进聊天应用时,我并不一定想把这张图片插入到输入框里。我要的是图片文件,是一个 URI,把它当作附件来处理。

这个区别最终成了整件事的关键。

然后我读到了 Fernando Rojo 的博客

在研究各种方案时,我看到了 Fernando Rojo 写的一篇关于 v0 应用如何处理粘贴功能的文章。

思路简单得漂亮:不要重建 TextInput,把它包起来。

我一下就明白了。

与其新建一个输入组件,自带一套样式规则、API 接口和维护负担,不如用一个包装层套在普通的 React Native TextInput 外面,观察原生的粘贴行为。输入框仍然归应用自己掌控,包装层只负责加上粘贴的智能处理。

这感觉对了。

我想要的 API

目标很简单:开发者继续用普通的 React Native TextInput,但能拿到一个专门针对媒体的 onPaste 事件。

import { TextInput } from "react-native";
import { PasteInputWrapper } from "expo-paste-input";

export function Composer() {
  return (
    <PasteInputWrapper
      onPaste={(event) => {
        if (event.type === "images") {
          console.log(event.uris);
        }

        if (event.type === "text") {
          console.log(event.value);
        }
      }}
    >
      <TextInput placeholder="Type a message" />
    </PasteInputWrapper>
  );
}

输入框完全由应用控制,这个库只负责加上粘贴的智能处理。

没有自定义编辑器,没有 prop 镜像,没有特殊的样式系统,也没有替换开发者已经信任的组件。只是一个 wrapper。

iOS

我先从 iOS 入手,学到的第一件事就是:剪贴板访问出奇地敏感。

在现代 iOS 版本中,如果 app 过早检查剪贴板内容,可能会弹出剪贴板隐私提示。也就是说,每次用户聚焦输入框时都去读一遍剪贴板,体验会非常糟糕。

所以这个 wrapper 只在用户明确执行粘贴操作之后才读取 UIPasteboard

粘贴发生后,原生层会检查剪贴板内容:文本、图片、GIF、WebP、HEIC,以及其他任何有用的东西。如果检测到媒体,库会把它写入临时文件,并把本地文件 URI 回传给 JavaScript。

payload 刻意保持得很小:

type PasteEventPayload =
  | { type: "text"; value: string }
  | { type: "images"; uris: string[] }
  | { type: "unsupported" };

这样我就得到了从一开始就想要的 API:一个 paste 事件,让 app 自己决定接下来做什么。

Android 难得多

Android 的剪贴板和内容系统比 iOS 更碎片化。Android 12 及以上版本的正确做法是 OnReceiveContentListener,它允许原生 view 接收图片、媒体这类富内容。

但光有这些还不够。

Android 还有插入菜单、选择菜单、剪贴板管理器,以及多条可能触发粘贴行为的路径。为了让体验稳定可靠,这个库还通过 Android 的文本编辑 API 接入了原生粘贴操作。

预期行为很简单:

  • 粘贴文本 → 文本出现

  • 粘贴图片 → 图片变成附件

但要让它用起来像原生,得下功夫。如果剪贴板里是文本,Android 应该保持正常行为。用户不该失去标准的文本输入体验。如果剪贴板里是媒体,wrapper 就应该拦截,把内容存进缓存,再把文件 URI 回传给 JavaScript。

这就是用户看到一句「无法粘贴图片」和一个能正常工作的聊天输入框之间的区别。

边界情况永远没完

基础功能跑通之后,真正的工作才刚开始。第一批边界情况看起来都不复杂:

  • 多张图片

  • GIF

  • 透明 PNG

  • 直接从系统截图界面粘贴的截图

但每一个的表现都略有不同。

GIF 必须保持为 GIF,不能莫名其妙变成静态图。透明图片必须保持透明,所以库里在存在 alpha 通道时保留 PNG 输出,只在合适的时候才用 JPEG。

截图又是另一个意外。从 Photos 应用复制的图片,和直接从系统截图界面复制的截图,行为并不一样。剪贴板里的载荷看起来不同,底层数据类型不同,我最初的假设是错的。

于是实现不再假设所有图片都走同一条路径,而是更聪明地识别内容类型。

“简单”功能通常就是在这里变成真正的工程活。

没人正确处理的那个功能

后来有人提了一个关于 iOS 贴纸的 issue。

即便当时还不支持贴纸,他们指出这个库至少不该往输入框里插入奇怪的额外字符。那个 issue 彻底改变了我对这个问题的理解,因为他们说得对。

大多数应用对贴纸的处理都不怎么样。

视频:final1031_16x9 — 在 expo.dev 上观看

在 iOS 上,贴纸并不总是像普通图片那样暴露出来。较新的 iOS 版本可以通过文本附件和自适应图像字形插入贴纸,而不是传统的剪贴板图片格式。

于是这个库开始监听:

  • NSTextAttachment

  • iOS 18 上的 NSAdaptiveImageGlyph

当贴纸内容出现时,封装层会提取底层图像数据,从输入框中移除多余的附件内容,保留光标位置,把媒体写入临时存储,然后发出一个普通的图片粘贴事件。

最初实现只支持静态贴纸。后来我也加上了动态贴纸支持。

这成了我在这个库里最喜欢的部分之一,因为这类功能用户默认就该能用。能用的时候,没人会注意到它——好的基础设施本该如此。

结果

这个项目最终变成了 expo-paste-input。最初只是客户应用里的一个小需求,后来长成了一个原生 Expo 模块,支持:

  • 粘贴文本

  • 粘贴图片

  • 粘贴多张图片

  • 粘贴 GIF

  • 透明图片

  • 截图

  • iOS 贴纸

  • 动态贴纸

同时开发者仍然使用他们熟悉的 React Native TextInput,不需要自定义编辑器,不需要自定义输入框,也不需要特殊的渲染管线。只是在现有原生行为外面包了一层薄薄的封装。

开源源于真实问题

有意思的是,它一开始并不是一个开源项目,而是我们在 Powstać 为客户做产品时遇到的一个问题。

正因如此,它才值得认真解决。

它不是 demo 点子,不是周末实验,也不是为了发布而发布的库。它来自一个真实产品,有真实用户、真实的平台限制,以及一个需要做得足够原生的细微交互。

我们先为那个应用解决了它。等实现逐渐完整之后,我意识到其他 React Native 开发者很可能也在踩同一个坑。开源是后来的事。

过程中我联系了 Bluesky 团队,因为他们当时用的也是我们最初那套基于 Mattermost 的方案。他们对这个想法感兴趣,我最终也提交了一些改动,推动了那边的进展。

这大概是我最喜欢 React Native 的地方:一个应用里的小问题,最后可能对很多其他开发者都有用。

最后

粘贴看起来简单,其实不简单。

一个 Paste 按钮背后,是剪贴板隐私、临时文件、GIF 处理、Android 内容 API、原生文本编辑系统、图片格式、贴纸、截图,以及用户从来不会想到的平台差异。

这正是它重要的原因。用户不该为这些事费心。他们只管复制、粘贴,然后继续做自己的事。

如果你正在做聊天应用、社交平台、笔记应用、AI 应用,或者任何用户频繁分享媒体的工作流,不妨试试 expo-paste-input。如果你发现了我还没遇到的边界情况,我很想听听。

来源: Expo Blog← 返回首页