個人開発で運用している壁紙アプリに招待コードの引き換え画面を足したとき、画面を開くたびに「○○からペーストしました」という小さな通知が画面上部に出てしまい、テスターから「これは大丈夫なものなんですか」と何度も尋ねられました。原因はすぐに分かりました。親切心のつもりで、画面が表示されると同時にクリップボードを読み、コード欄へ自動入力していたのです。読んだ瞬間にあのバナーが出ます。私自身、App Store に出している複数のアプリで同じ落とし穴を踏んでおり、これは一度きちんと整理しておく価値のある題材だと考えています。
クリップボード連携は、コードを1行コピーするだけの単純な機能に見えて、iOS の挙動を知らないと「うるさいアプリ」になってしまいます。ここでは expo-clipboard を使いながら、ペースト許可バナーを必要以上に出さず、それでいてコピーと貼り付けが気持ちよく成立する設計を整理します。
ペースト許可バナーはどこで発生するのか
混乱しやすいのですが、コピー(書き込み)とペースト(読み取り)では扱いがまったく違います。
書き込み、つまり Clipboard.setStringAsync() は何の通知も出しません。ユーザーがコピーボタンを押した結果なので、当然といえば当然です。問題は読み取り側です。iOS 16 以降、別アプリがコピーした内容を Clipboard.getStringAsync() で読むと、「○○からペーストしました」というバナーが表示されます。これはユーザーに「いま勝手にクリップボードを覗きましたよ」と知らせるための仕様で、無効化できません。
ここで効いてくるのが「いつ読むか」です。私の失敗は、ユーザーが何も操作していないのに画面表示と同時に読んでしまったことでした。ユーザー自身が「貼り付け」を押した直後の読み取りなら、バナーが出ても文脈と一致しているので不快になりません。逆に、開いた瞬間や数秒おきのポーリングで読むと、身に覚えのないバナーが連発し、不信感につながります。
読むタイミングを決めるときは、次の順序で考えると迷いません。
クリップボードに何か入っているかは、内容を読まずに確認する
「貼り付け」ボタンは、入っているときだけ押せるようにする
実際に中身を読むのは、ユーザーがそのボタンを押した直後だけにする
この3点を守るだけで、不要なバナーはほぼ回避できます。
操作 API iOS のバナー
コピー(書き込み) setStringAsync()出ない
内容を読む(貼り付け) getStringAsync()出る(iOS 16+)
有無だけ確認 hasStringAsync()出ない
hasStringAsync で内容を読まずにボタンを出し分ける
最後の行が鍵です。hasStringAsync() は「クリップボードに文字列があるか」を真偽値で返すだけで、中身そのものは読みません。そのためバナーも出ません。これを使えば、クリップボードが空のときは「貼り付け」ボタンを無効化し、何か入っているときだけ押せるようにできます。実際に読むのはユーザーがそのボタンを押した瞬間だけです。
import { useEffect, useState } from "react" ;
import * as Clipboard from "expo-clipboard" ;
function RedeemCodeField ({ onPaste } : { onPaste : ( value : string ) => void }) {
const [ canPaste , setCanPaste ] = useState ( false );
// 中身は読まず「有無」だけ確認するのでバナーは出ない
useEffect (() => {
let mounted = true ;
Clipboard. hasStringAsync (). then (( has ) => {
if (mounted) setCanPaste (has);
});
return () => {
mounted = false ;
};
}, []);
// 実際に読むのはユーザーがボタンを押した瞬間だけ
const handlePaste = async () => {
const text = await Clipboard. getStringAsync ();
if (text) onPaste (text. trim ());
};
return (
< PasteButton disabled = { ! canPaste } onPress = { handlePaste } />
);
}
hasStringAsync() を画面表示時に一度呼ぶだけなら、バナーは出ません。アプリがフォアグラウンドに戻ったタイミングでも確認したい場合は、AppState の change イベントで active になったときに再度 hasStringAsync() を呼びます。ここでも読むのは有無だけなので安全です。
招待コードのように形式が決まっているなら、貼り付けた文字列をそのまま入れず、text.trim() で前後の空白を落としたうえで、想定する形式に合うかを軽く検証してから反映すると、ユーザーが余計な文字ごとコピーしてしまったときにも破綻しません。
コピー側のフィードバックを確実にする
書き込みはバナーが出ない代わりに、成功したのかどうかが分かりにくいという別の課題があります。setStringAsync() は成功すると true を返すので、これを待ってから視覚的なフィードバックを出すのが確実です。
import * as Clipboard from "expo-clipboard" ;
import * as Haptics from "expo-haptics" ;
async function copyInviteCode ( code : string , showToast : ( msg : string ) => void ) {
const ok = await Clipboard. setStringAsync (code);
if (ok) {
await Haptics. notificationAsync (Haptics.NotificationFeedbackType.Success);
showToast ( "コードをコピーしました" );
} else {
showToast ( "コピーできませんでした。もう一度お試しください" );
}
}
ここでのポイントは、setStringAsync() の戻り値を待たずにトーストを出さないことです。書き込みは一瞬で終わるとはいえ、失敗を握り潰すと「コピーしたつもりが空だった」という最悪の体験を生みます。触覚フィードバックは成功時だけにとどめ、押すたびに強く震えるような演出は避けると上品にまとまります。
コピー直後にボタンのラベルを「コピー済み」へ一時的に切り替え、数秒後に元へ戻す小さな演出も効果的です。トーストと併用すると過剰になりがちなので、どちらか一方に絞るのが私の好みです。
iOS と Android で挙動が違う点
クロスプラットフォームで作る以上、両 OS の差は把握しておきたいところです。
項目 iOS Android
読み取り時の通知 バナー(16+・無効化不可) トースト(12+・「貼り付けました」)
有無確認でのバナー 出ない 出ない
機密フラグ 期限付き貼り付けは標準APIに無し 13+ でセンシティブ指定の概念あり
どちらの OS でも hasStringAsync() での出し分けは有効です。読み取り時の通知文言や見た目は OS 任せなので、アプリ側で消そうとせず、「ユーザーの操作と一致したタイミングでのみ読む」という原則を守るのが結局いちばん効きます。
機密データをクリップボードに置くときの後始末
ワンタイムコードやトークンのような一時的な秘密情報をコピーさせる場面では、使い終わったあとの後始末も考えておきます。クリップボードはアプリをまたいで共有される領域なので、機密情報を置きっぱなしにすると、別アプリからも読める状態が続きます。
expo-clipboard には有効期限付きでコピーする標準APIはありません。そのため、用が済んだ段階で setStringAsync("") を呼んで明示的に空へ戻すか、そもそも機密度の高い値はクリップボードを経由させず、アプリ内で直接入力させる導線にするのが安全です。Android 13 以降ではセンシティブな内容を区別する仕組みがありますが、expo-clipboard から細かく指定できないことも多いため、最も確実なのは「機密情報は極力クリップボードに載せない」という割り切りだと考えています。個人的には、トークンのような値はコピーさせず直接入力させる導線を推奨します。
招待コードのように漏れても実害が小さい値はクリップボード経由で問題ありませんが、決済やログインに直結する値は別扱いにする、という線引きをアプリ全体で決めておくと判断が早くなります。
再利用できるフックにまとめる
ここまでの方針を1つのフックに集約しておくと、画面ごとに同じ配慮を繰り返さずに済みます。
import { useCallback, useEffect, useState } from "react" ;
import { AppState } from "react-native" ;
import * as Clipboard from "expo-clipboard" ;
export function useClipboard () {
const [ canPaste , setCanPaste ] = useState ( false );
const refresh = useCallback ( async () => {
setCanPaste ( await Clipboard. hasStringAsync ());
}, []);
useEffect (() => {
refresh ();
const sub = AppState. addEventListener ( "change" , ( state ) => {
if (state === "active" ) refresh ();
});
return () => sub. remove ();
}, [refresh]);
const copy = useCallback ( async ( value : string ) => {
return Clipboard. setStringAsync (value);
}, []);
// 読むのは呼び出し側のユーザー操作からのみ
const paste = useCallback ( async () => {
const text = await Clipboard. getStringAsync ();
return text. trim ();
}, []);
const clear = useCallback ( async () => {
await Clipboard. setStringAsync ( "" );
setCanPaste ( false );
}, []);
return { canPaste, copy, paste, clear, refresh };
}
このフックは「有無の監視」「書き込み」「明示的な読み取り」「後始末」をそれぞれ独立した関数として提供します。paste() を useEffect の中で勝手に呼ばず、必ずボタンの onPress から呼ぶという約束さえ守れば、冒頭のような不要なバナーは起きません。
次の一歩として、いま自分のアプリでクリップボードを読んでいる箇所を getStringAsync で grep してみてください。画面表示時やフォーカス時に読んでいる箇所があれば、それがバナーの発生源です。その読み取りをユーザーの明示的な操作の後ろへ移すだけで、体験は静かに改善します。