SwiftUIアプリとActionExtensionとのデータ共有について

# やりたいこと
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](https://dmtopolog.com/ios-app-extensions-data-sharing/)

特に、アプリ起動時に`UserDefaults`を読み込んで、共有されたデータを表示することはすぐできたのですが、
**アプリ起動中にいったんSafariを表示→Extensionを実行してデータを共有→アプリに戻ったらデータが反映されている、といった動きがなかなかできなかったので、それについて記載します。**

※ 公式Docが古かったりまとまった記事があまりなく、正直**とりあえずなんか動いたぞくらいの理解**なので、記載する内容には間違いやアンチパターン的な箇所があると思います。
※ また、XcodeのShare Extension・Action Extensionテンプレートをベースに作っていきますが、テンプレートの作り方などの初期設定についてはこの記事では記載しません。このあたりの設定や、使い方については以下の記事がわかりやすかったです。

* [Share Extensionでデータを共有する - Qiita](https://qiita.com/KosukeQiita/items/994693da551a7101cc9c)
* [ios share extensionでjavascriptファイルを実行してwebサイト上の任意の情報を取得する - Qiita](https://qiita.com/MasatoraAtarashi/items/22705f23aef3a6492512)

# 手順メモ
今回は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`した奴を消しています。

```swift:SharedData.swift
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しか使いません)

```swift:ContentView.swift
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`も一部書き換える必要があるので、以下のようにしました。

```swift: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機能は動きません。(対処方法はあった気がしますが)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/526994/99ba16a7-f73b-4e8e-decc-610ddace8909.png" width="300" />

現在は指定したキーのUserDefaultsに何も入っていないので、
次はActionExtensionで選択したTextを反転させ、UserDefaultsに格納する処理を作成する。

## Extensionの中身を書く
今回は文字列を選ぶだけなので、ActionExtensionのUIなしテンプレートを使います。
まずは、`Info.plist`の`NSExtension/NSExtensionAttributes/NSExtensionActivationRule/NSExtensionActivationSupportsText`を`YES`に設定します。`NO`のままだと、テキスト選択後の共有メニューに自分で作ったActionが表示されません。
テンプレートのデフォルトではJavaScriptが実行できるようになっていますが、今回はそれは使いません。

>JavaScriptを使う設定を消す場合は、`NSExtension/NSExtensionAttributes/NSExtensionJavaScriptPreprocessingFile`を消します。

続いて、ActionRequestHandler.swiftの中身を書き換えて、選択した文字列を反転させたものをUserDefaultsに保存するようにします。

```swift:ActionRequestHandler.swift
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
  }
}
```

最終的な動作は以下の通りです。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">SwiftUI x ActionExtension x UserDefaults <a href="https://t.co/NGKQhZYqQw">pic.twitter.com/NGKQhZYqQw</a></p>&mdash; かーにゃ (@popy1017) <a href="https://twitter.com/popy1017/status/1273802501163106304?ref_src=twsrc%5Etfw">June 19, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 感想
* 調査に時間がかかったわりに以外と簡単に実装できる
* 本記事の主題とは関係ないが、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](https://qiita.com/shiz/items/d2ba8efa7318c7e70a5a)