利用者がアプリ内で受け取った請求書 PDF をタップしたとき、Safari に飛んで戻ってこられなくなる――個人開発でアプリを作っていて、この「外に出ていったきり」の体験をどうにかしたいと感じたことがあります。閲覧のためだけにアプリを離れさせると、せっかくの利用文脈が途切れてしまいます。
iOS には、まさにこの用途のために Quick Look(QLPreviewController)という仕組みが用意されています。PDF、画像、テキスト、CSV、Word や Excel などの Office 文書を、アプリの中で全画面プレビューとして開けます。標準の Rork アプリは React Native(Expo)で生成されるので、この native の Quick Look を薄い橋渡しモジュール経由で呼ぶ形になります。設計の勘所は、何を渡せて何を渡せないかを正確に押さえることでした。
実装の全体像を手順で押さえる
細かいコードに入る前に、本番で動かすまでの流れを手順として並べておきます。
表示したいファイルがリモートにあるなら、まず expo-file-system でキャッシュへダウンロードする
保存先のローカル URL(file://)と正しい拡張子を確保する
iOS なら QLPreviewController、Android なら FileProvider+ACTION_VIEW へ、同じ呼び出し口から渡す
共有・印刷ボタンを出すかを、ファイルの性質に応じて決める
閉じたあとのキャッシュを掃除する
この五手のうち、初学者がつまずくのは 1 と 2 です。私自身、最初はここを飛ばしてリモート URL を直接渡し、無言の空白に悩みました。順序を守れば残りは素直に組めます。
Quick Look が開けるもの・開けないもの
まず前提を整理します。Quick Look はローカルファイルのプレビュー機構です。ここを取り違えると最初の実装でつまずきます。
開けるのはローカルのファイル URL です(file:// で始まるパス)
https:// のリモート URL を直接渡しても開けません。先にダウンロードしてローカルに置く必要があります
対応形式は PDF・画像(JPEG/PNG/HEIC)・テキスト・CSV・RTF・iWork・Microsoft Office 文書・USDZ(3D)など幅広いです
拡張子が正しいことが型判定の手がかりになります。document.tmp のような拡張子なしでは、中身が PDF でも開けないことがあります
私が最初にハマった落とし穴は、サーバーから取得した署名付き URL をそのまま渡してしまった点でした。Quick Look は黙って空白を表示するだけで、エラーも出ません。リモートのものは必ずキャッシュに落としてから開く、という前提を最初に固めるのが、この落とし穴を回避する近道です。
リモートファイルを先にキャッシュへ落とす
Expo では expo-file-system でダウンロードし、その保存先のローカル URI を Quick Look に渡します。すでにダウンロード済みなら再取得せず使い回すと、大きな PDF でも待ち時間を抑えられます。
// downloadForPreview.ts
import * as FileSystem from 'expo-file-system' ;
// リモート URL をキャッシュへ落として file:// のローカルパスを返す
export async function ensureLocalCopy ( remoteUrl : string , filename : string ) {
const target = FileSystem.cacheDirectory + filename;
const info = await FileSystem. getInfoAsync (target);
if (info.exists) return target; // 既にあれば再ダウンロードしない
const { uri , status } = await FileSystem. downloadAsync (remoteUrl, target);
if (status !== 200 ) {
throw new Error ( `download failed: ${ status }` );
}
return uri;
}
ファイル名には必ず正しい拡張子を含めます。サーバーが拡張子を返さない場合は、Content-Type から自分で .pdf や .xlsx を付与しておくと、Quick Look の型判定が安定します。
iOS の Quick Look を橋渡しモジュールで呼ぶ
QLPreviewController は UIViewController なので、Expo の native module 側で現在のトップ画面の上に提示します。データソースはプレビュー対象を返すだけの薄いものです。
import QuickLook
final class PreviewBridge : NSObject , QLPreviewControllerDataSource {
private var url: URL !
func present ( path : String , from vc: UIViewController) {
self . url = URL ( fileURLWithPath : path)
let preview = QLPreviewController ()
preview.dataSource = self
vc. present (preview, animated : true )
}
func numberOfPreviewItems ( in controller: QLPreviewController) -> Int { 1 }
func previewController ( _ controller: QLPreviewController,
previewItemAt index: Int ) -> QLPreviewItem {
return url as QLPreviewItem
}
}
JavaScript 側からは、ダウンロード済みのローカルパスを渡すだけの呼び出し口にします。
import { ensureLocalCopy } from './downloadForPreview' ;
import QuickLookPreview from './QuickLookBridge' ; // native module
export async function openPreview ( remoteUrl : string , filename : string ) {
const localPath = await ensureLocalCopy (remoteUrl, filename);
// iOS は file:// を外したパスを渡す実装にしている
await QuickLookPreview. present (localPath. replace ( 'file://' , '' ));
}
Android では FileProvider と ACTION_VIEW で代替する
Android に Quick Look はありません。代わりに FileProvider で安全な content URI を作り、Intent.ACTION_VIEW で対応アプリに渡します。アプリ内の全画面ビューにこだわるなら PDF だけは内蔵ビューアを使い、それ以外は外部アプリに委ねる、という割り切りを私は推奨します。
fun openWithViewer (context: Context , file: File , mime: String ) {
val uri = FileProvider. getUriForFile (
context, "${context.packageName}.fileprovider" , file
)
val intent = Intent (Intent.ACTION_VIEW). apply {
setDataAndType (uri, mime)
addFlags (Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
// 対応アプリが無い場合に落ちないようガードする
if (intent. resolveActivity (context.packageManager) != null ) {
context. startActivity (intent)
} else {
// フォールバック: チューザー or 内蔵 PDF ビューアへ
}
}
JS 側の openPreview は同じ名前のまま、iOS では Quick Look、Android ではこの Intent を呼ぶよう分岐させます。呼び出し口を一つに畳んでおくと、画面側のコードはプラットフォーム差を意識せずに済みます。
共有ボタンと印刷をどう扱うか
Quick Look は標準で右上に共有・印刷ボタンを出します。ここは設計判断が要ります。
ファイルの性質 共有ボタンの扱い
利用者自身のデータ(領収書・自分の作成物) そのまま許可してよい
有料・限定配布のコンテンツ 共有・印刷を抑制したい(持ち出し防止)
一時的な確認用プレビュー 抑制してアプリ内完結にする
共有を抑えたい場合は、QLPreviewItem ではなく編集・共有の可否を制御できる QLPreviewController のデリゲートで対応します。完全な持ち出し防止は OS の仕組み上難しいので、「スクリーンショットは撮れる」前提で、過度な期待をしない設計にしておくのが無難でした。私の場合、無料の確認用プレビューは共有可、有料コンテンツは内蔵ビューアに切り替えて Quick Look を使わない、という線引きにしています。こうした出し分けは、App Store の審査でも素直に通りました。
大きなファイルと閉じたあとの後始末
数十 MB の PDF をダウンロードしてから開くと、無言の待ち時間が生まれます。本番では、ダウンロード中はプレビューを開かず、進捗を見せたほうが利用者は安心します。expo-file-system の createDownloadResumable を使えば進捗コールバックを取れます。
閉じたあとの後始末も忘れがちです。キャッシュに落としたファイルは放っておくと溜まります。私は「直近で開いたものは残し、一定期間アクセスのないキャッシュは起動時に掃除する」方針を推奨します。cacheDirectory 配下なら OS がいざというとき消してくれますが、自分でも上限を決めておくとストレージ圧迫を回避できます。
アプリの中でファイルが完結して見られると、利用者は文脈を切らさずに作業を続けられます。Quick Look 自体は数行で呼べますが、リモートの事前ダウンロード、Android の代替、共有の可否、後始末まで含めて初めて、本番で安心して使える閲覧導線になります。同じ実装に取り組む方の参考になれば幸いです。