AirDropでポケモンカードをシェアしてみた

# はじめに
この記事は[and factory.inc Advent Calendar 2021](https://qiita.com/advent-calendar/2021/andfactory) 7日目の記事です。
昨日は @nabetaro_jpさんの「[Flutterエンジニア採用面談で聞きそうなこと](https://qiita.com/nabetaro_jp/items/ab8d4d69f170ec9e4ed1)」でした。

and factory iOSエンジニアのy-okuderaです！
今回は、AirDropでiOSアプリのデータを共有してみようと思います。

# AirDrop
Apple製品のユーザーであれば説明するまでも無いかもしれませんが、念のためm(_ _)m

https://support.apple.com/ja-jp/HT204144

> AirDrop を使うと、近くにあるほかの Apple 製デバイスと写真や書類などのコンテンツを共有し、相手からも受け取ることができます。

写真やメモなどを共有するのに便利です！
アプリ開発をするときにも、iPhoneからMacに画像や動画を送るのに多用してます:relaxed:

今回は、自分で開発したアプリにAirDropでデータを共有してみようと思います。

# 作るもの
今回実装する機能は、シンプルに選択したカードをAirDropでシェアできるだけです！

まず、ポケモンカードの一覧画面があって

| dark | light |
|:--:|:--:|
|![IMG_1640.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/a3fd701f-d237-671a-3caf-fcdfeac07796.png)|![IMG_1643.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/c524b519-a3d4-9f0f-66c7-63ab199f0339.png)|

一覧からカードを選択すると、選択したカードの詳細画面をモーダル表示して

| dark | light |
|:--:|:--:|
|![IMG_1641.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/5b88879d-4c68-4782-56c6-070b28400df0.png)|![IMG_1644.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/91fce616-a4ac-2162-671e-9a9c049a4008.png)|

詳細画面のShareボタンをタップすると、AirDropのみのシェアシートが表示されます。
「AirDrop」のほかに「その他」も表示されていますが、「その他」の中も、AirDropのみになります。

| dark | light |
|:--:|:--:|
|![IMG_1642.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/72ecbd55-8eaa-506d-dfd3-0a450c85f973.png)|![IMG_1646.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/82e4c583-95f5-5728-5f90-3f5705377d70.png)|

あとはAirDropで共有ができるという感じです:upside_down:

実際に送受信する挙動は最後に載せます:joy_cat:

# 開発環境
今回は、以下の環境で開発を進めていきます。

|name|version|
|:--:|:--:|
|Xcode|13.0|
|Deployment Target|iOS 14.4 ~|

動作確認に利用したiPhoneのOSが15と14.4だったので、Deployment Targetも合わせましたが、もっと古いバージョンが含まれていても実装はできると思います:ok_hand:

# 実装
具体的な実装について見ていきましょう！

ポケモンカード一覧画面はTableViewを使用しているくらいで、ポケモンカード詳細画面もImageView使用しているくらいなので、UI実装については詳細は割愛させていただきます:bow:
最後にGitHub Repositoryのリンクを載せるので、UIはそちらをご確認いただければと思います。

データ取得と、今回の本題であるAirDropの送受信まわりを具体的に見ていきたいと思います。

## ポケモンカードデータの取得
ポケモンカード一覧画面のデータは、[Pokémon TCG API](https://pokemontcg.io/)を利用させていただいています。
ただ、APIリクエストに関しても、今回の本題ではないので、いろいろ省くためにAPIのレスポンスをJSONファイルに書き込んで、それをAssetsに入れるようにしました！
（いろいろサボりすぎて、ごめんなさいです:relaxed:）

AssetsからJSONデータをとってきて、それをDecodableなEntityにDecodeしていきます！

### Entity
APIのドキュメントに載っている仕様を確認して、今回使いそうなプロパティを並べてEntityを作成していきます。

- PokeImages
    - ポケモンカードの画像URLを持つEntity
    - smallとlargeの2種類のURLを持つ

<details><summary>コード</summary><div>

```swift:
import Foundation

struct PokeImages: Codable, Equatable {
    let small: String
    let large: String

    init(small: String, large: String) {
        self.small = small
        self.large = large
    }

    var smallImageUrl: URL? {
        return URL(string: small)
    }

    var largeImageUrl: URL? {
        return URL(string: large)
    }
}
```
</div></details>

- PokeCard
    - ポケモンカード1枚の情報を持つEntity
    - idやname, typesなどの情報とPokeImagesを持つ

<details><summary>コード</summary><div>

```swift:
import Foundation

struct PokeCard: Codable, Equatable {
    let id: String
    let name: String
    let supertype: String
    let subtypes: [String]
    let types: [String]
    let level: String?
    let hp: String?
    let nationalPokedexNumbers: [Int]
    let images: PokeImages

    init(
        id: String,
        name: String,
        supertype: String,
        subtypes: [String],
        types: [String],
        level: String?,
        hp: String?,
        nationalPokedexNumbers: [Int],
        images: PokeImages
    ) {
        self.id = id
        self.name = name
        self.supertype = supertype
        self.subtypes = subtypes
        self.types = types
        self.level = level
        self.hp = hp
        self.nationalPokedexNumbers = nationalPokedexNumbers
        self.images = images
    }
}
```
</div></details>

- PokeCards
    - ポケモンカード複数枚の情報を持つEntity
    - PokeCardのArrayを持つ
    - ポケモンカード取得APIのレスポンスJSONのデコード先の型になる

<details><summary>コード</summary><div>

```swift:
import Foundation

struct PokeCards: Decodable {
    let data: [PokeCard]
}
```
</div></details>

PokeImagesとPokeCardsは、Decodableに準拠していますが、PokeCardは、Decodable & EncodableなCodableに準拠しています。これは、後ほど説明しますが、**AirDropで送信するときにData型にする必要があるので、PokeCardのみEncodableにも準拠させています。**

### AssetLoader
Entityはできたので、AssetsからJSONデータをとってきて、それをDecodeしていきます！

今回は、Data.xcassetsというAssetsファイルにcards.jsonというJSONファイルを入れています。

![スクリーンショット 2021-12-06 22.58.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/c236dca7-9aa9-ca33-f69e-ccd47f3734cd.png)

このAssetsから、NSDataAssetクラスを使用してデータを取り出します。
Data型で取り出せるので、それをJSONDecoderでdecodeするだけで、APIのGETリクエストの代用品が完成します:yum:

ちなみに、NSDataAssetクラスは名前からは連想しにくいですが、UIKitのクラスなので、`import Foundation` ではなく、`import UIKit`してあげる必要があるので注意が必要です:sob:

Imageを取ってくるからUIKitなのかなとか勝手に思っていたのですが、NSDataAssetでは画像のデータは取れないらしいです:joy:

```objc:UIKit/NSDataAsset.h
/// NSDataAsset represents the contents of data entries in your asset catalog.
/// Data assets are not in the same class of stored content as images, so you cannot use a data asset to get image data for an image.
UIKIT_EXTERN API_AVAILABLE(ios(9.0), macos(10.11), tvos(9.0), watchos(2.0)) @interface NSDataAsset : NSObject<NSCopying>
```

> /// NSDataAsset represents the contents of data entries in your asset catalog.
/// Data assets are not in the same class of stored content as images, so you cannot use a data asset to get image data for an image.

<details><summary>実装したAssetLoader.swift</summary><div>

```swift:AssetLoader.swift
import UIKit

final class AssetLoader {
    static func loadWithDecode<T: Decodable>(
        name: String,
        decoder: () -> JSONDecoder = {
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            return decoder
        }
    ) -> T {
        guard let dataAsset = NSDataAsset(name: name, bundle: .init(for: self)) else {
            // サンプルではfatalErrorで落としてしまっていますが、throwしてあげた方が優しいかもです
            fatalError("Asset data loading failure.")
        }
        do {
            return try decoder().decode(T.self, from: dataAsset.data)
        } catch {
            // サンプルではfatalErrorで落としてしまっていますが、throwしてあげた方が優しいかもです
            fatalError("Decoding failure. error: \(error)")
        }
    }
}
```
</div></details>

これで、以下のような形でPokeCardsをJSONから取得できるようになりました！

```swift:
let pokeCards: PokeCards = AssetLoader.loadWithDecode(name: "cards")
```

データを取得できるようになったので、AirDropの処理を見ていきましょう！

## Info.plistにカスタムUTIを追加する
AirDropで送受信するデータの種類を識別するために、カスタムUTI（Uniform Type Identifiers）を定義します。

### Exported Type Identifiers
Exported Type Identifiersで、アプリが送信するファイルの形式を定義します。

Exported Type Identifiersに、Description, Identifier, Conforms Toを入力します。
それぞれ、UTIの説明、UTIの識別子、どのUTIを継承するかを指定します。
Identifierは、重複しないようにBundle identifierを含めたものが良さそうです:raised_hands:

![exported_type_identifiers.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/50479a56-7ace-3a79-6014-f3478b2b99d4.png)

### Document Types
Document Typesで、アプリが受け取るファイルの形式を定義します。

Document Typesに、Name, Typesを入力して、Handler Rankを指定します。
Typesは、Exported Type IdentifiersのIdentifierと同じ値にしておきます。
Handler Rankは、受信した情報を開ければいいので、Defaultにしています。

![document_types.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/82de4bf6-ef0a-d64d-dd89-a8f6e5956868.png)

### 参考

[Uniform Type Identifiers](https://developer.apple.com/documentation/uniformtypeidentifiers)

[Defining File and Data Types for Your App](https://developer.apple.com/documentation/uniformtypeidentifiers/defining_file_and_data_types_for_your_app)

[Setting Up a Document Browser App](https://developer.apple.com/documentation/uikit/view_controllers/adding_a_document_browser_to_your_app/setting_up_a_document_browser_app)


## AirDrop用のActivityItemSourceを実装する
UIActivityItemSourceは、共有するデータを表すためのプロトコルです。

今回は、シェアシートにAirDropのみを表示させるようにしてみます:point_up:

UIActivityItemSource Protocolに準拠したAirDropOnlyActivityItemSourceクラスを実装します。

`func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any`で、空のURLを指定することで、AirDrop以外の項目を表示させないようにするのですが、
`URL(string: "")!`にすると、クラッシュしてしまうので、`NSURL(string: "")!`にして回避しています。

```swift:AirDropOnlyActivityItemSource.swift
import UIKit
import LinkPresentation

final class AirDropOnlyActivityItemSource: NSObject {
    /// AirDropで送信するアイテム
    let item: Any

    init(item: Any) {
        self.item = item
    }

    /// 送信するポケモンカードを指定してイニシャライズ
    convenience init(pokeCard: PokeCard) {
        let data = Encoder.jsonData(from: pokeCard)
        self.init(item: data)
    }
}

extension AirDropOnlyActivityItemSource: UIActivityItemSource {
    func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
        //using NSURL here, since URL with an empty string would crash
        return NSURL(string: "")!
    }

    func activityViewController(
        _ activityViewController: UIActivityViewController,
        itemForActivityType activityType: UIActivity.ActivityType?
    ) -> Any? {
        return item
    }

    func activityViewController(
        _ activityViewController: UIActivityViewController,
        dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?
    ) -> String {
        // Info.plistで定義したUTIのIdentifierを指定
        return "jp.yuoku.PokeCardDropUTI.pokecard"
    }

    func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
        let linkMetadata = LPLinkMetadata()
        linkMetadata.title = "Share pokemon card"

        let fileUrl = AssetExtractor.createLocalUrl(forImageNamed: "pokemon_tcg")
        linkMetadata.iconProvider = NSItemProvider(contentsOf: fileUrl)

        return linkMetadata
    }
}
```

EncodableなPokeCard EntityをJSON dataにEncodeする処理も必要なので、実装しています。

```swift:Encoder.swift
import Foundation

enum Encoder {

    /// Json encoding
    static func jsonData<T: Encodable>(from encodableData: T) -> Data {
        let encoder = JSONEncoder()
        do {
            let data = try encoder.encode(encodableData)
#if DEBUG
            if let jsonString = String(data: data, encoding: .utf8) {
                print(jsonString)
            }
#endif
            return data
        } catch {
            // サンプルではfatalErrorで落としてしまっていますが、throwしてあげた方が優しいかもです
            fatalError("Encoding failure. error: \(error)")
        }
    }
}
```

### 参考

https://stackoverflow.com/questions/28607781/share-via-airdrop-only

## AirDropを受け取ったときの処理を実装する
AirDropを受け取ったときの処理は、アプリ起動済みかどうかでSceneDelegate.swiftのどの処理が呼ばれるか異なります。

### アプリ起動済みの場合
アプリが起動済みの状態でAirDropを受け取ったら、`func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>)`が呼び出されます。
通常、URLContexts.firstに受信した情報が格納されているので、そこからDataを取り出して使用します。

ちなみに今回は、受け取ったポケモンカードの詳細画面をモーダル表示して、Toastでメッセージを表示しています。
Toastの実装には、Toast-Swiftを使用してみました！

https://github.com/scalessec/Toast-Swift

```swift:SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
...

    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        open(urlContexts: URLContexts)
    }

    private func open(urlContexts: Set<UIOpenURLContext>) {
        guard let urlContext = urlContexts.first else {
            return
        }
        do {
            let receivedData = try Data(contentsOf: urlContext.url)
            // PokeCardにデコード
            let receivedPokeCard: PokeCard = Decoder.jsonDecode(from: receivedData)

            // 最前面のViewControllerを取得して、そこから受信したポケモンカードの詳細画面をモーダル表示する
            if let frontVC = UIViewController.getFrontViewController() {
                let vc = PokeCardDetailViewController.build(pokeCard: receivedPokeCard)
                frontVC.present(vc, animated: true, completion: {
                    // 遷移後に、Toastでメッセージを表示する
                    vc.view.makeToast("Received \(receivedPokeCard.name) via AirDrop.", position: .top)
                })
            }
        } catch {
            print("data is nil. fromUrl: \(urlContext.url.path)")
            return
        }
    }
}
```

### アプリ未起動の場合
アプリ未起動の状態でAirDropを受け取ったら、`func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>)`は**呼び出されません**。
通常のアプリ起動と同様に、`func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)`が呼び出されます。
そして、`connectionOptions.urlContexts`に受信した情報が格納されているので、そこからDataを取り出して使用します。

```swift:SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // アプリ未起動時にAirDrop受け取りから起動した場合
        if let urlContext = connectionOptions.urlContexts.first {
            do {
                let receivedData = try Data(contentsOf: urlContext.url)
                // PokeCardにデコード
                let receivedPokeCard: PokeCard = Decoder.jsonDecode(from: receivedData)
                // シングルトンに値を保持しておいて、最初のViewControllerのviewDidAppearで使用する
                let receivedPokeCardRepository = ReceivedPokeCardRepositoryProvider.provide()
                receivedPokeCardRepository.write(receivedPokeCard: receivedPokeCard)
            } catch {
                print("data is nil. fromUrl: \(urlContext.url.path)")
                return
            }
        }

        setupWindow(windowScene: (scene as? UIWindowScene))
    }

    private func setupWindow(windowScene: UIWindowScene?) {
        guard let windowScene = windowScene else {
            return
        }
        let window = UIWindow(windowScene: windowScene)
        self.window = window
        window.makeKeyAndVisible()

        let pokeCardsRepository = PokeCardsRepositoryProvider.provide()
        let vc = PokeCardListViewController.build(pokeCards: pokeCardsRepository.pokeCards())
        let navigation = UINavigationController(rootViewController: vc)

        window.rootViewController = navigation
    }
...
}
```

# 実装したアプリの動作

## AirDrop送信側
![sender.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/5ba68e07-d9a5-d797-c0d1-6652d7d93334.gif)

## AirDrop受信側
![result.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/103695/658ed493-da86-ddbe-697e-c1bd837b74ba.gif)

# その他参考情報

https://stackoverflow.com/questions/39309338/how-to-send-and-receive-custom-data-with-airdrop

https://developer.apple.com/library/archive/samplecode/sc2273/Introduction/Intro.html

https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/UTIRef/Introduction/Introduction.html#//apple_ref/doc/uid/TP40009258-SW1

https://stackoverflow.com/questions/58624786/method-applicationopenurloptions-is-not-called

https://newbedev.com/can-i-get-a-nsurl-from-an-xcassets-bundle

# さいごに
今回実装してみたソースコードは、GitHub [y-okudera/PokeCardDrop](https://github.com/y-okudera/PokeCardDrop)にPushしてあります:santa:
詳細説明できていないところなどはそちらもあわせて見ていただけると幸いです:bow:

[Advent Calendar](https://qiita.com/advent-calendar/2021/andfactory) まだまだ続きますので、次の記事もお楽しみに！