iOS でメモアプリを作っていて、「Safari の記事を共有シートから自分のアプリに送れるようにしたい」と思ったことはないでしょうか。私は最初の頃、これが React Native だけで完結すると思い込んで丸2日溶かしました。結論から言うと、Share Extension は別ターゲットのネイティブ実装が必須で、JavaScript 側からは差し込めません。
ただし、Rork Max の SwiftUI ネイティブ生成を組み合わせると、この壁はぐっと下がります。ここで扱うのはRork Max で生成したベースに対して Share Extension ターゲットを追加し、App Group でデータを受け渡し、メインアプリを Deep Link で起動するところまで、実際にビルドして審査に通る構成で解説します。Pocket・Instapaper・Notion のような「共有シートから取り込む」アプリを作りたい方は、ここから先を読み進めてみてください。
なぜ Share Extension は React Native だけで作れないのか
技術的な前提を最初に整理しておきます。Share Extension は iOS の「App Extension」という仕組みで、メインアプリとは別プロセス・別バイナリとして動作します。共有シートに表示されるあの小さなパネルは、メインアプリとは独立した小さなアプリだと考えてください。
そして App Extension は、現時点では Swift / Objective-C で書く必要があります。React Native や Expo は、メインアプリのバンドルを差し替えるための仕組みであって、別ターゲットの Extension までは管理しません。Expo Modules や react-native-share-extension のような OSS は存在しますが、メンテナンスが止まっていたり、UI を React 側で書こうとして起動が遅くなったりと、本番運用でハマりやすい構成です。
私は実際のプロダクトでは、メインアプリ= Rork(React Native)または Rork Max(SwiftUI)/ Share Extension = SwiftUI ネイティブ で分離するのが最も安定すると感じています。Rork Max を使えば、その両方を一つのプロジェクトとして扱えるのが強みです。
Extension の種類と役割の整理
iOS には Share Extension のほかに Action Extension、Document Provider、File Provider など多くの拡張があり、最初は混乱します。共有シートに「あなたのアプリへ送る」を出したいなら、選ぶべきは「Share Extension」一択です。
Share Extension : 共有シートからコンテンツを受け取り、メインアプリに保存・送信する用途
Action Extension : 受け取ったコンテンツを変換して呼び出し元に返す用途(翻訳・要約など)
Document Provider : ファイルアプリのプロバイダとして自分のアプリのファイルを公開する用途
メモアプリ・リーディングリスト・SNSクライアント・あとで読むサービスは、すべて Share Extension が正解です。
ステップ 1: Rork Max でベースプロジェクトを作る
まず Rork Max でメインアプリの土台を作ります。プロンプトには「Apple Watch 連携」や「ウィジェット」を最初から含めずに、Share Extension で受け取った URL とタイトルを表示するシンプルな構成にしておくと、後段の差分が見やすくなります。
私が実際に使うプロンプトはこんな形です。
Rork Max で iOS 専用のリーディングリストアプリを作ってください。
- SwiftUI ネイティブ
- 最小ターゲット iOS 17
- 保存した URL とタイトルの一覧を表示
- 起動時に App Group "group.net.rorklab.readlater.shared" の UserDefaults を読む
- URL Scheme "readlater://" で起動された時、URL クエリ "?url=...&title=..." をリストに追記
- データは SwiftData で永続化
Rork Max が生成したプロジェクトを Xcode で開いたら、Bundle Identifier・チーム・Signing を自分のものに差し替えます。ここで動作確認まで終わらせてから次に進むのが鉄則です。Extension を後から足すと、署名関係のエラーが Extension 側で起きているのか、本体側で起きているのか切り分けにくくなります。
ステップ 2: Share Extension ターゲットの追加
Xcode で File → New → Target → Share Extension を選択し、Product Name を ShareExt、Language を Swift にして追加します。テンプレートで生成される ShareViewController.swift は UIKit ベースの古いコードですが、私は最初に SwiftUI に書き換えるところから始めます。理由は、メインアプリと UI コードを共有しやすくするためです。
ShareExt/ShareViewController.swift を以下のように書き換えてください。
import UIKit
import SwiftUI
import UniformTypeIdentifiers
class ShareViewController : UIViewController {
override func viewDidLoad () {
super . viewDidLoad ()
// SwiftUI ビューを埋め込む
let extensionContext = self .extensionContext
let host = UIHostingController (
rootView : ShareView (
onSave : { [ weak self ] payload in
self ? . savePayload (payload)
},
onCancel : { [ weak self ] in
self ? . cancel ()
}
)
)
addChild (host)
host. view .frame = view.bounds
host. view .autoresizingMask = [.flexibleWidth, .flexibleHeight]
view. addSubview (host. view )
host. didMove ( toParent : self )
}
private func savePayload ( _ payload: SharePayload) {
// App Group の UserDefaults に保存
let defaults = UserDefaults (
suiteName : "group.net.rorklab.readlater.shared"
)
var queue = defaults ? . array ( forKey : "pendingItems" ) as? [[ String : String ]] ?? []
queue. append ([
"url" : payload. url .absoluteString,
"title" : payload.title,
"savedAt" : ISO8601DateFormatter (). string ( from : Date ())
])
defaults ? . set (queue, forKey : "pendingItems" )
// メインアプリを起動(共有完了後)
let scheme = "readlater://import?count= \( queue. count ) "
if let url = URL ( string : scheme) {
openURL (url)
}
extensionContext ? . completeRequest ( returningItems : nil )
}
private func cancel () {
let error = NSError ( domain : "ShareExt" , code : 0 , userInfo : nil )
extensionContext ? . cancelRequest ( withError : error)
}
// App Extension から URL Scheme を開くためのヘルパー
@objc private func openURL ( _ url: URL) -> Bool {
var responder: UIResponder ? = self
while responder != nil {
if let app = responder as? UIApplication {
return app. perform ( #selector ( openURL (_:)), with : url) != nil
}
responder = responder ? .next
}
return false
}
}
ShareView 本体は SwiftUI で記述します。最初は URL と タイトル の取り出しだけ正しく動くことを最優先にしてください。
struct SharePayload {
let url: URL
let title: String
}
struct ShareView : View {
let onSave: (SharePayload) -> Void
let onCancel: () -> Void
@State private var payload: SharePayload ?
@State private var loadError: String ?
var body: some View {
NavigationStack {
VStack ( spacing : 16 ) {
if let p = payload {
Text (p.title)
. font (.headline)
. lineLimit ( 2 )
Text (p. url .absoluteString)
. font (.caption)
. foregroundStyle (.secondary)
. lineLimit ( 1 )
} else if let err = loadError {
Text (err). foregroundStyle (.red)
} else {
ProgressView ( "読み込み中…" )
}
}
. padding ()
. toolbar {
ToolbarItem ( placement : .cancellationAction) {
Button ( "キャンセル" ) { onCancel () }
}
ToolbarItem ( placement : .confirmationAction) {
Button ( "保存" ) {
if let p = payload { onSave (p) }
}
. disabled (payload == nil )
}
}
. task { await loadShared () }
}
}
private func loadShared () async {
// ShareViewController から渡される extensionContext を取得する別経路を使う
// ここでは Notification 経由で取得する実装にしている例
}
}
ここで重要なのは、SwiftUI の View からは extensionContext に直接触れない 点です。ShareViewController 側で取り出した NSExtensionItem を ShareView に渡すなり、@Environment 経由で渡すなりする必要があります。次のステップで具体的に解説します。
ステップ 3: 共有された URL を取り出す(NSItemProvider)
iOS の共有シートは、選択されたコンテンツを NSItemProvider としてエクステンションに渡します。Safari なら URL、写真アプリなら画像、メールアプリならテキストです。リーディングリストでは URL とタイトルだけ取れれば充分なので、以下の実装に絞ります。
extension ShareViewController {
func extractURLAndTitle ( completion : @escaping (SharePayload ? ) -> Void ) {
guard let item = extensionContext ? .inputItems. first as? NSExtensionItem,
let provider = item.attachments ? . first else {
completion ( nil )
return
}
let urlType = UTType. url .identifier
if provider. hasItemConformingToTypeIdentifier (urlType) {
provider. loadItem ( forTypeIdentifier : urlType, options : nil ) { (data, error) in
let url: URL ? = (data as? URL) ??
(data as? String ). flatMap { URL ( string : $0 ) }
let title = item.attributedContentText ? .string ?? url ? .absoluteString ?? ""
if let u = url {
DispatchQueue.main. async {
completion ( SharePayload ( url : u, title : title))
}
} else {
DispatchQueue.main. async { completion ( nil ) }
}
}
} else {
completion ( nil )
}
}
}
このコードで、Safari の「共有 → ReadLater」をタップした時に URL と記事タイトルが取れます。期待される出力は次のようなログになります。
[ShareExt] Loaded payload — url: https://example.com/article-title, title: "Article Title - Example"
[ShareExt] Saved to app group queue — pending count: 1
[ShareExt] Opened readlater://import?count=1
「タイトルが空文字になる」「URL が nil で返る」というハマりポイントは、Safari 以外のアプリ(Twitter / X 公式・LINE など)が異なる UTType を使うためです。私は実装初期に public.url だけを見て YouTube アプリからの共有が落ちる事故を起こしました。本番運用では UTType.plainText・UTType.url・UTType.text を順にフォールバックしてください。
ステップ 4: App Group でメインアプリにデータを渡す
Extension とメインアプリは別プロセスなので、UserDefaults.standard を共有できません。代わりに App Group を有効化し、UserDefaults(suiteName:) で共有領域にアクセスします。
設定手順は次のとおりです。
Apple Developer サイトで App Group group.net.rorklab.readlater.shared を作成する
Xcode の Signing & Capabilities で メインアプリと Share Extension の両方 に App Group を追加する(片方だけだと永遠に動きません)
両ターゲットの Bundle Identifier がチーム ID で共通の Prefix を持つことを確認する
App Group が有効になれば、メインアプリ側で同じ suite を読み込むだけでキューを取り出せます。
import SwiftUI
import SwiftData
@MainActor
final class PendingImporter : ObservableObject {
@Published var importedCount: Int = 0
private let suite = "group.net.rorklab.readlater.shared"
func drainPending ( into context: ModelContext) {
let defaults = UserDefaults ( suiteName : suite)
let queue = defaults ? . array ( forKey : "pendingItems" ) as? [[ String : String ]] ?? []
var importedURLs: [ String ] = []
for entry in queue {
guard let urlStr = entry[ "url" ], let url = URL ( string : urlStr) else { continue }
let title = entry[ "title" ] ?? url.absoluteString
// 重複は弾く(URL ベース)
if ! importedURLs. contains (urlStr) {
let item = ReadingItem ( url : url, title : title, savedAt : .now)
context. insert (item)
importedURLs. append (urlStr)
}
}
try? context. save ()
defaults ? . removeObject ( forKey : "pendingItems" )
importedCount = importedURLs. count
}
}
このコードは「メインアプリが起動されたら App Group のキューを取り込む」設計です。Extension 側はキューに積むだけなので軽量で、メインアプリの起動タイミングで一気にデータベースに反映します。Extension 内で SwiftData に直接書き込もうとするとロックや権限エラーで詰まりやすいので、私はこの「キュー → メインアプリで処理」パターンを強く推奨します。
ステップ 5: URL Scheme でメインアプリを呼び出す
保存後にメインアプリを起動して取り込みを走らせるために、URL Scheme を使います。Info.plist の CFBundleURLTypes に readlater を登録し、onOpenURL で受け取ります。
@main
struct ReadLaterApp : App {
@StateObject private var importer = PendingImporter ()
var body: some Scene {
WindowGroup {
ContentView ()
. environmentObject (importer)
. onOpenURL { url in
guard url.scheme == "readlater" else { return }
Task { @MainActor in
importer. drainPending ( into : modelContext)
}
}
}
. modelContainer ( for : ReadingItem. self )
}
}
Universal Link のほうがセキュリティ・UX で優れていますが、Extension からの起動は URL Scheme のほうがシンプルで、ホスト側の apple-app-site-association の設定不要で動きます。私は最初の MVP は URL Scheme で出して、ユーザーが付いたら Universal Link に切り替える二段階リリースをしています。
ステップ 6: 共有シートに表示するファイルタイプを絞る
このまま実機ビルドすると、共有シートにアプリ名は表示されるものの、写真や PDF を共有しようとすると現れない、または逆に対象外のアプリでまで現れる、という現象が起きます。これは Info.plist の NSExtensionAttributes の設定不足が原因です。
ShareExt/Info.plist を以下のように調整します。
< key >NSExtension</ key >
< dict >
< key >NSExtensionAttributes</ key >
< dict >
< key >NSExtensionActivationRule</ key >
< dict >
< key >NSExtensionActivationSupportsWebURLWithMaxCount</ key >
< integer >1</ integer >
< key >NSExtensionActivationSupportsWebPageWithMaxCount</ key >
< integer >1</ integer >
< key >NSExtensionActivationSupportsText</ key >
< true />
</ dict >
</ dict >
< key >NSExtensionMainStoryboard</ key >
< string >MainInterface</ string >
< key >NSExtensionPointIdentifier</ key >
< string >com.apple.share-services</ string >
</ dict >
この設定で、URL(SupportsWebURL)と Web ページ全体(SupportsWebPage)の共有時のみ ReadLater が表示されます。テキスト共有もサポートしたい場合は SupportsText を true にします。画像・動画は除外することで、ユーザーの混乱を減らせます。
よくある落とし穴と対処法
実装を進めると、ここで紹介する 5 つの罠にほぼ必ずぶつかります。私自身が公開直後にユーザーから報告を受けて修正した実体験です。
① App Group が片側にしか追加されていない
メインアプリだけ、または Extension だけに App Group を追加するケースです。Capabilities 画面で両方のターゲットを順番に開き、同じ ID にチェックが入っているか目視確認してください。データが取れない時は、まずここを疑います。
② Universal Link と URL Scheme を混同する
URL Scheme(readlater://...)は端末内のアプリを起動できます。Universal Link(https://readlater.app/import...)はサーバー側の apple-app-site-association ファイルが必要で、初期設定でつまずきやすいです。Extension からは URL Scheme で確実に起動するパターンを推奨します。
③ ShareViewController のレイアウトが切れる
共有シート内のパネルは高さが制限されます。SwiftUI で .padding(40) などを大きく取ると、保存ボタンが画面外に押し出されます。presentationDetents([.medium]) 相当の制約で動くと考えて、UI は最小限に留めてください。
④ Extension 内で重い処理を走らせる
Extension は数秒で OS にキルされる前提のサンドボックスです。Extension 内で記事本文をフェッチしたり、サムネイル画像を生成したりすると、保存に失敗します。Extension は「キューに積むだけ」、後続処理はメインアプリ起動後に回すのが鉄則です。
⑤ App Store 審査でリジェクトされる
Apple は Extension に対してメインアプリ単体で「自然な機能の延長」であることを求めます。たとえばメインアプリの主機能と無関係な共有エクステンション(例: 翻訳アプリなのに位置情報を保存する Extension)はリジェクトされます。Extension の説明をリリースノートに 1 行入れるだけで通過率が上がります。
実プロダクトでの応用パターン
ここまでの設計は「URL を一度キューに積んでメインアプリで処理する」最小構成ですが、実プロダクトではいくつか拡張が必要になります。
SNS クライアントでテキスト共有を受ける場合 : SupportsText を true にし、Extension の UI で投稿先アカウントを選ばせる UI を追加します。投稿 API はメインアプリのキー保管領域(Keychain Sharing)にアクセスして取り出すと、Extension にも認証情報を渡せます。
画像保存アプリ(写真整理・コラージュなど)の場合 : NSItemProvider から UIImage を取り出し、サイズが大きい場合は元データへのファイル URL を App Group の共有コンテナにコピーしてからメインアプリで処理します。Extension 内でリサイズすると確実にメモリ上限に引っかかります。
有料機能との連携 : Extension からも StoreKit のレシートを参照できますが、Extension 自体で課金 UI を出すことはできません。「保存はできるが、メインアプリで Pro プランをアクティベートしてください」のような誘導を Extension 完了画面に入れるのが現実的です。
個人開発者の視点から(実体験メモ)
全体を振り返って
Share Extension は「あったら便利」ではなく、コンテンツ保存・取り込み系プロダクトでは「無いと選ばれない」機能です。Rork Max のネイティブ生成と組み合わせれば、React Native だけでは越えられなかった壁が一気に下がります。
最初の一歩として、本記事の最小構成を Rork Max のサンプルプロジェクトに足してビルドしてみてください。共有シートに自分のアプリ名が出た瞬間、プロダクトの完成度が一段上がるのを実感できるはずです。書籍で SwiftUI 全体の
関連する実装はRork Max ウィジェット完全ガイド、ASOまわりはRork ASO 初心者ガイド も参考になります。