画像を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 では、アプリが早すぎるタイミングでクリップボードの中身を確認すると、クリップボードのプライバシー通知が出ることがある。つまり、ユーザーが入力欄にフォーカスするたびにクリップボードを読むようなことをすれば、体験は最悪になる。
だからこの wrapper は、ユーザーが明示的にペースト操作を行った後にだけ UIPasteboard を読む。
ペーストが起きると、ネイティブ層がクリップボードの中身を調べる。テキスト、画像、GIF、WebP、HEIC、その他役に立つものは何でも。メディアを検出すれば、ライブラリがそれを一時ファイルに書き出し、ローカルファイルの URI を JavaScript に返す。
payload は意図的に小さく保っている。
type PasteEventPayload =
| { type: "text"; value: string }
| { type: "images"; uris: string[] }
| { type: "unsupported" };
こうして最初から欲しかった API が手に入った。paste イベントが一つあり、次に何をするかはアプリが自分で決める。
Android のほうがはるかに難しい
Android のクリップボードとコンテンツの仕組みは iOS より断片化している。Android 12 以降で正しいやり方は OnReceiveContentListener で、これを使うとネイティブ view が画像やメディアといったリッチコンテンツを受け取れる。
だが、それだけでは足りない。
Android には挿入メニュー、選択メニュー、クリップボードマネージャーがあり、ペーストを引き起こす経路も複数ある。体験を安定して信頼できるものにするため、このライブラリは Android のテキスト編集 API を通じてネイティブのペースト操作にも対応している。
期待される動作は単純だ:
-
テキストをペースト → テキストが現れる
-
画像をペースト → 画像が添付になる
ただし、ネイティブらしく使えるようにするには手間がかかる。クリップボードの中身がテキストなら、Android は通常の動作を保つべきだ。ユーザーが標準のテキスト入力体験を失うべきではない。中身がメディアなら、wrapper が横取りしてキャッシュに保存し、ファイル URI を JavaScript に返す。
これが、ユーザーに見えるのが「画像を貼り付けできません」の一言か、それとも普通に動くチャット入力欄か、その差になる。
エッジケースは尽きない
基本機能が動くようになってから、本当の作業が始まる。最初のエッジケースはどれも複雑には見えなかった:
-
複数の画像
-
GIF
-
透過 PNG
-
システムのスクリーンショット画面から直接ペーストしたスクリーンショット
だが、それぞれの挙動は少しずつ違う。
GIF は GIF のまま保たなければならない。わけもなく静止画になってはいけない。透過画像は透過を保つ必要があるので、このライブラリはアルファチャンネルがある場合は 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 を試してみてほしい。私がまだ出会っていないエッジケースを見つけたら、ぜひ聞きたい。