炊きたてのご飯が食べたい

定時に帰れるっていいね。自宅勤務できるっていいね。子どもと炊きたてのご飯が食べられる。アクトインディでは積極的にエンジニアを募集中です。

今更ですが iOS8 で実装された Share Extension を美味しくいただきました


どうも アクトインディ Advent Calendar 2015 の 23 日目の記事になります!本日の担当は namikata です。今日は 「今さら!」って思う人が多いかもしれませんが、iOS8 の新機能としてリリースされた Share Extension について記事を書こうと思います。

Share Extension に関する記事は、もう大分時間が経っていて、多く出ていると思うので、この記事では、自分が Share Extension 機能を利用して、どのような機能を実装したのかを、コードを交えて説明したいと思います。Share Extension の基本的な使い方や Share Extension でできることの内容は、他の人が書いた素晴らしい記事を参照いただくのが良いかと思います。

この記事で書く内容

App Extension の NSUserDefaults の共有機能を利用して、Safari や 写真などのアプリから、メインのアプリと情報を連携する方法

スクリーンショット 2015-12-22 00.38.40.png

はじめに

仕事とは関係なく、プライベートで マイメモランキング といったアプリを開発しています。利用していただいているユーザーさんは、1ヶ月に1000人程の小さいサービスですが、自分の中では「1000人もの人が利用してくれているのか!」と、感謝の気持ちと不安な気持ちでいっぱいになりながらいつもアップデートしています。自分が利用したいアプリ No.1 を10年後に実現するのを目指して、暇を見つけてはちょくちょくと開発を進めています。

このアプリは非常に単純で(単純じゃないと作れないしね )、自分でカテゴリーを作成して、作った料理とか、見た映画とか、他のなんちゃらをランキング形式で管理できるアプリです。メモにランキングの要素を追加したようなもんです。

自分の場合は、美味しかった料理とか、楽しかったスポットとか、面白かったマンガとか、近所のスーパーで買ったコスパの良いお惣菜とかをメモするのに使っているんですが、メモしたい時って色んなシーンがあるんですよね。Safari で見ているページだとか、クックパッドアプリでレシピ見ている時とか、写真アプリで画像を開いている時とか。Share Extension を導入する前は、

Safari からタイトルコピー -> マイメモアプリを起動してタイトルを貼り付け -> Safari からURLをコピー -> マイメモに戻って貼り付け

みたいな感じで、メモ一つ取るのも面倒な作業だったんですが、それらの面倒な操作を解消できる機能が、今日紹介する Share Extension になります。

Share Extension で実装した機能

  1. Safari からマイメモにアイテムを登録できるように
  2. 写真からマイメモにアイテムを登録できるように
  3. クックパッドアプリのように、Share Extension の機能をサポートしているアプリからマイメモにアイテムを登録できるように

アプリの大枠

マイメモランキング では、登録したアイテムは、それぞれの iPhone 端末に SQLite ファイルを作成して保存しています。サーバーで登録情報を管理している場合は、「Share Extension でデータを保存しているサーバーとやり取りをして情報を登録」とか言った処理になるかと思うんですが、今回、iPhone のローカルで処理を完結させる必要があるので、Share Extension の group 機能を利用したNSUserDefaults での情報のやり取りを実装することにしました。

mymemoShareExtensionで NSUserDefaults に情報を保存 -> mymemo(本体アプリ) で NSUserDefaults に保存された情報を SQLite に取り込み、NSuserDefaults を削除

といった流れになります。この機能を実装する為の大まかな流れは以下です。

  1. mymemo と mymemoShareExtension を同一のグループに所属させる
  2. 同一のグループに所属することで NSUserDefaults 、 CoreData 、ファイルの共有 が可能になる(今回は NSUserDefaults の紹介)
  3. マイメモアプリ起動時に、Share Extension で保存した情報( NSUserDefaults )を読み込み SQLite に情報を登録する

それでは、実装内容について紹介していきたいと思います。

Share Extension の実装内容

1. ShareExtension 用のプロジェクトをアプリに追加する

Xcode を起動し、メインアプリのプロジェクトを開きます。

File -> New -> Target -> Share Extension を選んで作成します。名前は何も考えずに mymemoShareExtension としました。

スクリーンショット 2015-12-22 00.09.15.png

作成が完了したら、 TARGETS に作成した mymemoShareExtension が出来、 mymemoShareExtension といったフォルダが作成されていると思います。メインアプリであるマイメモランキングは歴史があるため、まだコードは Objective-C ですが、ShareExtension の言語は Swift を選択しました(メインアプリと処理は独立していて NSUserDefaults で情報のやり取りをするだけなので)。

2. mymemo と mymemoShareExtension を同一のグループに所属させる

Share Extension と共通で利用できる NSUserDefaults を利用する為には、メインアプリと Extension を同一のグループにする必要があります。同一のグループに設定すると言っても Xcode 上から設定ができてしまうので、非常に簡単です。自分は以下のサイトを参考に作業しました。

http://www.gaprot.jp/pickup/ios8/vol3/

設定が正常に完了すると、以下のような画面になります。エラーなく、3つの項目にチェックマークが付いていればグループの設定は完了です。

スクリーンショット 2015-12-21 23.57.00.png

スクリーンショット 2015-12-21 23.57.27.png

3. ShareExtension 用の最初の設定を行う

mymemoShareExtension フォルダの中には、以下の3つのファイルが作成されているはずです。

  • ShareViewController.swift : Share Extension のプログラムソース
  • MainInterface.storyboard : Share Extension の storyboard
  • Info.plist : Share Extension で扱う情報を管理する設定ファイル

Share Extension では、どのような情報をShare Extension経由で取り扱うことを許可するかといった内容を mymemoShareExtension の Info.plist で管理します。デフォルトで作成した時は、開発をスムーズに行うために、全ての情報を許可するように設定されているが、そのままアプリを申請すると Reject されるようなので、申請前のテストを行う時には、必要な情報のみ許可した設定に変更することを忘れないようにしましょう。

●Share Extension 作成時のデフォルトの Info.plist

スクリーンショット 2015-12-22 00.20.34.png

  • NSExtension
    • NSExtensionAttributes
      • NSExtensionActivationRule(TRUEPREDICATE) : 全ての情報を許可する(開発者モード)

●許可するコンテンツを制限した最終的な Info.plist

スクリーンショット 2015-12-22 00.21.08.png

  • NSExtension
    • NSExtensionAttributes
      • NSExtensionActivationRule
        • NSExtensionActivationSupportsImageWithMaxCount(1) : 画像は1点まで許可
        • NSExtensionActivationSupportsText(YES) : テキストを利用する
        • NSExtensionActivationSupportsWebURLWithMaxCount(1) : URLは1つまで許可

4. Share Extension で Safari から情報を取得する

ShareViewController.swift に実装していきます。デフォルトの状態では、以下の4つのメソッドがあるかと思います。

写真

  • isContentValid() -> Bool : タイトルが入力されていないと「保存」ボタンを非アクティブにする等のバリデーションを記述する
  • didSelectPost() : 「保存」ボタンタップ後の POST 処理を記述する
  • configurationItems() -> [AnyObject]! : 「カテゴリー」「評価」「一言メモ」といった、追加項目のリストを管理する

ここでは didSelectPost() を編集します。編集後のソースは以下になります。

override func didSelectPost() {

    let inputItem: NSExtensionItem = self.extensionContext?.inputItems[0] as! NSExtensionItem
    let itemProvider = inputItem.attachments![0] as! NSItemProvider

    // Safari 経由での shareExtension では URL を取得
    if (itemProvider.hasItemConformingToTypeIdentifier("public.url")) {
        itemProvider.loadItemForTypeIdentifier("public.url", options: nil, completionHandler: {
            (item, error) in

            // item に url が入っている
            let itemNSURL: NSURL = item as! NSURL

            // 行いたい処理を書く
        })
    }

    self.extensionContext!.completeRequestReturningItems([], completionHandler: nil)
}

5. Share Extension で 写真から情報を取得する

【4】との違いは public.url -> public.jpeg

override func didSelectPost() {

    let inputItem: NSExtensionItem = self.extensionContext?.inputItems[0] as! NSExtensionItem
    let itemProvider = inputItem.attachments![0] as! NSItemProvider

    // 写真アプリ経由での shareExtension では画像の URL を取得
    if (itemProvider.hasItemConformingToTypeIdentifier("public.jpeg")) {
        itemProvider.loadItemForTypeIdentifier("public.jpeg", options: nil, completionHandler: {
            (item, error) in

            // item に画像の url が入っている
            let photoNSURL: NSURL = item as! NSURL

            // 行いたい処理を書く
        })
    }

    self.extensionContext!.completeRequestReturningItems([], completionHandler: nil)
}

6. Share Extension で クックパッドから情報を取得する

【5】との違いは public.jpeg -> public.plain-text

override func didSelectPost() {

    let inputItem: NSExtensionItem = self.extensionContext?.inputItems[0] as! NSExtensionItem
    let itemProvider = inputItem.attachments![0] as! NSItemProvider

    // クックパッドアプリ経由での shareExtension ではテキストの取得に特別な処理はない
    if (itemProvider.hasItemConformingToTypeIdentifier("public.plain-text")) {
        itemProvider.loadItemForTypeIdentifier("public.plain-text", options: nil, completionHandler: {
            (item, error) in

            // 行いたい処理を書く
            // self.contentText に shareExtension のメインテキストエリアで記入したテキスト情報が入るので、
            // テキストを取得するだけであれば、特に追記はありません。
        })
    }

    self.extensionContext!.completeRequestReturningItems([], completionHandler: nil)
}

7. 取得した情報に加え、マイメモアプリ用の独自の項目を追加する

以上で、 Safari, 写真アプリ, クックパッドアプリから情報を取得する Share Extension の記述ができました。最後に自分用のアプリの仕様に合わせて Share Extension をカスタマイズしていきます。行なった処理は以下です(ソースが汚くて申し訳ない)

  • Share Extension のタイトルと投稿ボタン名(保存)を設定
  • configurationItems() メソッドを利用して「カテゴリー」「評価」「一言メモ」項目のリストを追加
  • isContentValid() メソッドを利用して、アイテム名が空の場合は、保存ボタンを押せなくする
  • メインアプリと共有できる NSUserDefaults に、Share Extension で取得した情報を保存

Share Extension のタイトルと投稿ボタン名(保存)を設定

Share Extension のタイトルの設定は、viewDidLoad() で行います。以下のURLを参考にさせていただきました。

SLSheetRootViewControllerによって管理されている為、通常時のタイトルの指定方法と異なる http://koze.hatenablog.jp/entry/2015/05/28/000000

override func viewDidLoad() {
    super.viewDidLoad()
    self.title = "マイメモ";

    let c: UIViewController = self.navigationController!.viewControllers[0]
    c.navigationItem.rightBarButtonItem!.title = "保存"
}

configurationItems() メソッドを利用して「カテゴリー」「評価」「一言メモ」項目のリストを追加

ここでは、ランキングの得点を選択する「評価」の項目を例に説明したいと思います。他の項目も基本的に設定は同じです。

SLComposeSheetConfigurationItem を利用して追加したい項目を準備します

lazy var ratingItem: SLComposeSheetConfigurationItem = {
    let item = SLComposeSheetConfigurationItem()
    item.title = "評価"
    item.value = "3"
    item.tapHandler = self.showListViewControllerOfRating
    return item
}()

configurationItems() で追加したい項目を設定します

override func configurationItems() -> [AnyObject]! {
    return [categoryItem, ratingItem, memoItem]
}

タップした時の遷移を実装します。delegate で画面間の値のやり取りをするので delegate = self をしています。

func showListViewControllerOfRating() {
    let controller = ListViewController(style: .Plain)
    controller.selectedValue = ratingItem.value
    controller.delegate  = self
    pushConfigurationViewController(controller)
}

ListViewController ページから戻ってくる処理です

func listViewController(sender: ListViewController, selectedValue: String) {
    ratingItem.value = selectedValue
    popConfigurationViewController()
}

delegate を追加します

class ShareViewController: SLComposeServiceViewController, ListViewControllerDelegate {

● ListViewController.swift

import UIKit

@objc(ListViewControllerDelegate)
protocol ListViewControllerDelegate {
    optional func listViewController(sender: ListViewController, selectedValue: String)
}

class ListViewController: UITableViewController {

    struct TableViewValues {
        static let identifier = "Cell"
    }

    var itemList: [String] = []
    var selectedValue: String = ""

    override init(style: UITableViewStyle) {
        super.init(style: style)
        tableView.registerClass(UITableViewCell.classForCoder(), forCellReuseIdentifier: TableViewValues.identifier)
        tableView.backgroundColor = UIColor.clearColor()

        self.itemList = ["1", "2", "3", "4", "5"]
    }

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)!
    }

    override func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.itemList.count
    }

    override func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCellWithIdentifier(TableViewValues.identifier, forIndexPath: indexPath) as UITableViewCell
        cell.backgroundColor = UIColor.clearColor()

        let text: String = self.itemList[indexPath.row]

        // 選択したアイテムにチェックマークをつける
        if text == selectedValue {
            cell.accessoryType = .Checkmark
        } else {
            cell.accessoryType = .None
        }

        cell.textLabel!.text = text

        return cell
    }

    var delegate: ListViewControllerDelegate?

    override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        if let theDelegate = delegate {
            theDelegate.listViewController!(self, selectedValue: self.itemList[indexPath.row])
        }
    }
}

isContentValid() メソッドを利用して、アイテム名が空の場合は、保存ボタンを押せなくする

override func isContentValid() -> Bool {
    self.charactersRemaining = self.contentText.characters.count

    let canPost: Bool = (self.contentText.characters.count > 0)
    if canPost {
        return true
    }

    return false
}

メインアプリと共有できる NSUserDefaults に、Share Extension で取得した情報を保存

ここでは Safari を利用した Extension の実装を例にしたいと思います。

override func didSelectPost() {
    // Safari経由でのshareExtensionではURLを取得し、登録
    let inputItem: NSExtensionItem = self.extensionContext?.inputItems[0] as! NSExtensionItem
    let itemProvider = inputItem.attachments![0] as! NSItemProvider

    if (itemProvider.hasItemConformingToTypeIdentifier("public.url")) {
        itemProvider.loadItemForTypeIdentifier("public.url", options: nil, completionHandler: {
            (item, error) in

            let itemNSURL: NSURL = item as! NSURL
            // UserDefaultsにはString型でデータを保存する為、absoluteStringでString型に変換する
            self.setUserDefaultToItem(itemNSURL.absoluteString)
        })
    }

    self.extensionContext!.completeRequestReturningItems([], completionHandler: nil)
}
private func setUserDefaultToItem(url: String = "") {
    //shareExtension経由で登録したアイテムを保管するUserDefaultsのファイル名
    let ud = NSUserDefaults(suiteName: "group.com.asobicocoro.mymemo")
    let name = "itemList"

    var itemList: [Dictionary<String, String>]! = []
    itemList.append(["name"          : self.contentText!, //アイテム名
                     "rating"        : ratingItem.value,  //得点
                     "url"           : url])              //Safari から取得した URL

    assert(itemList.count > 0)
    ud?.setObject(itemList, forKey: name)
    ud?.synchronize()
}

ここまでが、新規に実装した Share Extension の実装になります。以降は、Share Extension で保存した NSUserDefaults を本体アプリで読み込む方法を紹介します。

9. NSUserDefaults に保存した内容を、本体アプリで読み込む

以下の 2カ所で情報の取得を行うようにしています(なんかもっとスムーズな方法がありそうな)

  1. アプリ起動時
  2. バックグラウンドからアプリが起動された時

本体アプリは Objective-C なので、ソースも Objective-C になります。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    ・・・

    UserDefaultReplaceSQLite *ud = [[UserDefaultReplaceSQLite alloc] init];
    [ud insertItemsOfShareExtension];
}
- (void)applicationWillEnterForeground:(UIApplication *)application
{
    UserDefaultReplaceSQLite *ud = [[UserDefaultReplaceSQLite alloc] init];
    [ud insertItemsOfShareExtension];
}
// shareExtension用のNSUserDefaults
- (void)insertItemsOfShareExtension {
    NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.asobicocoro.mymemo"];
    NSArray *array = [ud objectForKey:@"itemList"];

    Item *item = [[Item alloc] init];

    @try {
        for (id element in array) {
            item.name = [element objectForKey:@"name"];
            item.rate = [[element objectForKey:@"rating"] integerValue];
            item.url = [element objectForKey:@"url"];

            // SQLite に登録する処理
        }
    }
    @catch (NSException *exception) {
        // エラー処理
    }
    @finally {
        // 登録に失敗しても、とりあえず問答無用で UserDefault は削除する
        if (array) {
            [ud removeObjectForKey:@"itemList"];
        }
    }
}

動作確認をする

シュミレーターにデフォルトで入っていないアプリでテストする場合は、実機を利用する必要があります。

今回は、Safari と 写真アプリはシュミレーターで動作確認を行えましたが、クックパッドアプリでの動作確認をする際は、実機にビルドして確認する。といった流れになります。

アップデート申請をする

Share Extension を追加実装しましたが、アップデート申請は特別にやらなければいけないことは一つもなく、いつものアップデート手順で申請を出します。

最後に

OS がアップデートされる度に、新しい機能が提供されて、楽しい限りですね!追いついていくのは非常に大変ですが、今よりも良い UX を提供できると思うと、きちんと勉強してモノにしていかないとなぁ、って思います。Share Extension も、前々から実装してみたいと思っていたんですが、あれよあれよと言う間に月日は流れてしまいましたが(心もモンハンやモンストに流れていってー)、なんとか今年中にリリースすることができました。去年の AdventCalender に書けたら良かったですねw

アクトインディ では 子どもとのお出かけをもっと楽しくするアプリを開発してくれるエンジニアを募集しています。是非、是非、一緒にアプリを作って、世の中の笑顔を増やして行きましょう♪