やりたいこと
Share Extension・Action Extensionを使って、共有もとのアプリから自分で作ったSwiftUIのアプリにデータを共有して表示する。
アプリ停止中から起動した場合やバックグラウンドで起動している場合など、状況に関わらず共有したデータを反映させたい。
この記事の内容
今回はContaining Appに情報を反映させる部分についてを中心に記載します。
App ExtensionとContaining Appのデータ共有は、UserDefaults
を介して行われます。
他の方法もありますが、今回はUserDefaults
を使ってデータを共有し、Containing Appで変更を検知し表示を変えます。
App ExtensionとContaining Appのデータ共有に関しては以下のページがわかりやすかったです(公式よりわかりやすいかも)。
iOS App Extensions: Data Sharing | topolog’s tech blog
特に、アプリ起動時にUserDefaults
を読み込んで、共有されたデータを表示することはすぐできたのですが、
アプリ起動中にいったんSafariを表示→Extensionを実行してデータを共有→アプリに戻ったらデータが反映されている、といった動きがなかなかできなかったので、それについて記載します。
※ 公式Docが古かったりまとまった記事があまりなく、正直とりあえずなんか動いたぞくらいの理解なので、記載する内容には間違いやアンチパターン的な箇所があると思います。
※ また、XcodeのShare Extension・Action Extensionテンプレートをベースに作っていきますが、テンプレートの作り方などの初期設定についてはこの記事では記載しません。このあたりの設定や、使い方については以下の記事がわかりやすかったです。
手順メモ
今回はAction Extensionで、選択した文字列を反転した文字列をContaining Appと共有するという例にしようと思います。
SwiftUIでViewとUserDefaultを読み込む部分を作る
UserDefaultを読み込む部分
※ あんまりちゃんと理解してません。。。
-
NSObject
を継承することで、自身を対象にaddObserver
を設定し、observeValue
で変更があったときの動作を定義できます。 -
ObservableObject
を継承することで、変更をUI側に適用できるようになります。 -
static let sharedUserInfo = SharedData()
とprivate override init()
でSharedData
をシングルトン?にしています。(init
をprivateにすることで、他のところからはこのクラスのインスタンスを作成できなくなるのでなんか良いという理解。) -
UserDefaults(suiteName: "group.com.example.helloExtension")
で指定したsuiteNameのUserDefaultsを呼び出しています。suiteNameは自身の設定に応じて変更してください。 -
ud!.addObserver()
でforKeyPath
に指定したキーの値の変更を監視しています。 -
override func observeValue()
で、変更があったときの動作を定義しています。 -
ud?.object(forKey: keySharedText)
でforKeyに指定したキーの値を取得しています。戻り値がAny?
型なので、(今回は)as? String
でキャストしています。どんな値を格納するかによって、キャストする型は変更する必要があります。 - 読み込み終了後に、
ud?.removeObject(forKey: keySharedText)
で現在設定されている値を消しています。 - (意味があるかわかりませんが、)
deinit
内でaddOvserver
した奴を消しています。
import Foundation
class SharedData: NSObject, ObservableObject {
static let sharedUserInfo = SharedData()
private let ud = UserDefaults(suiteName: "group.com.example.helloExtension")
private final var keySharedText = "text"
@Published var sharedTexts: [String] = ["Apple", "Banana", "Orange"]
private override init() {
super.init()
setSharedText()
// これで forKeyPath に指定したキーの変更を監視する
ud!.addObserver(self, forKeyPath: keySharedText, options: NSKeyValueObservingOptions.new, context: nil)
}
// 変更があったら observeValue関数が実行される
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
setSharedText()
}
func setSharedText() {
// UserDefault().object(forKey: String) で指定したキーに格納されている値を取ってくる
if let text = ud?.object(forKey: keySharedText) as? String {
sharedTexts.append(text)
// 読み込んだら現在の値を消す
ud?.removeObject(forKey: keySharedText)
}
}
deinit {
ud!.removeObserver(self, forKeyPath: keySharedText)
}
}
View
- ここでは、共有されたデータを一覧表示にするために
List
を使っています。 -
@EnvironmentObject
でsharedData
の変更を監視しています。 - SwiftUIの状態管理についてはあまり理解していませんが、他のViewでも共有されたデータを使うユースケースもあると思うので、
@EnvironmentObject
を使い、他のViewとデータを共有できるようにしています。(今回は1つのViewしか使いません)
import SwiftUI
struct ContentView: View {
@EnvironmentObject var sharedData: SharedData
var body: some View {
List(sharedData.sharedTexts, id: \.self) { text in
Text(text)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
- また、
@EnvironmentObject
を使うためには、SceneDelegate.swift
も一部書き換える必要があるので、以下のようにしました。
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// initをprivateにしたので、ShareData() ではNG
let contentView = ContentView().environmentObject(SharedData.sharedUserInfo)
~~
}
~~
}
現在の見た目はこんな感じ。
ちなみに、CoreDataやらUserDefaultやらを使うとPreview機能は動きません。(対処方法はあった気がしますが)
現在は指定したキーのUserDefaultsに何も入っていないので、
次はActionExtensionで選択したTextを反転させ、UserDefaultsに格納する処理を作成する。
Extensionの中身を書く
今回は文字列を選ぶだけなので、ActionExtensionのUIなしテンプレートを使います。
まずは、Info.plist
のNSExtension/NSExtensionAttributes/NSExtensionActivationRule/NSExtensionActivationSupportsText
をYES
に設定します。NO
のままだと、テキスト選択後の共有メニューに自分で作ったActionが表示されません。
テンプレートのデフォルトではJavaScriptが実行できるようになっていますが、今回はそれは使いません。
JavaScriptを使う設定を消す場合は、
NSExtension/NSExtensionAttributes/NSExtensionJavaScriptPreprocessingFile
を消します。
続いて、ActionRequestHandler.swiftの中身を書き換えて、選択した文字列を反転させたものをUserDefaultsに保存するようにします。
import UIKit
import MobileCoreServices
class ActionRequestHandler: NSObject, NSExtensionRequestHandling {
var extensionContext: NSExtensionContext?
func beginRequest(with context: NSExtensionContext) {
// Do not call super in an Action extension with no user interface
self.extensionContext = context
var found = false
outer:
// Extensionを使うときのおまじない
// 以下のfor文内の書き方は、一番ネストが深い部分が違うだけで、他はだいたいお決まりのパターンです。
// 他のExtensionでも使う気がします
for item in context.inputItems as! [NSExtensionItem] {
if let attachments = item.attachments {
for itemProvider in attachments {
// Extensionの呼び出し元のタイプを確認
// テキストから呼び出したらkUTTypeText,ブラウザからならkUTTypeURL,
// JavaScriptだったらkUTTypePropertyListといったようにいろいろ種類がある。
if itemProvider.hasItemConformingToTypeIdentifier(String(kUTTypeText)) {
// 確認した結果、意図したタイプだったらアイテムを読み込む
itemProvider.loadItem(forTypeIdentifier: String(kUTTypeText), options: nil, completionHandler: { (item, error) in
if let text = item as? String {
let reversedText = self.reverseText(text: text)
OperationQueue.main.addOperation {
self.saveText(text: reversedText)
self.done()
}
}
})
found = true
break outer
}
}
}
}
if !found {
self.done()
}
}
func reverseText(text: String) -> String {
return String(text.reversed())
}
func saveText(text: String) {
let ud = UserDefaults(suiteName: "group.com.example.helloExtension")
ud?.set(text, forKey: "text")
}
func done() {
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
self.extensionContext = nil
}
}
最終的な動作は以下の通りです。
SwiftUI x ActionExtension x UserDefaults pic.twitter.com/NGKQhZYqQw
— かーにゃ (@popy1017) June 19, 2020
感想
- 調査に時間がかかったわりに以外と簡単に実装できる
- 本記事の主題とは関係ないが、SwiftUIはFlutterと比べるとだいぶ不便に感じる。ドキュメントも少ないし古い。
- Flutter最高!!
(おまけ)調査中に知った豆知識
本来はToday Extension以外からはContaining Appを開くことはできないらしい
Flutterのreceive_sharing_intentパッケージではExtension実行後にContaining Appを起動することができたが、本当はToday Extension以外ではできないらしい。ハックな方法があり、それを使えばShare ExtensionやAction ExtensionからもContaining Appを起動することができるようだが、使い方としてはContaining Appに遷移させることなく完結できるような処理が望まれている(らしい)。
Extensionで許可するコンテンツの初期設定がデフォルトのままだとリジェクトされるらしい
デフォルトではInfo.plist
のNSExtensionActivationRule
がTRUEPREDICATE
になっているものがある。
【iOS】Action Extension(Share Extension)を使った時にブラウザ上のPDFページでシェアアイコンを押してもアプリが表示されない問題 - Qiita