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

本文是客座文章,作者是 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。如果你发现了我还没遇到的边界情况,我很想听听。