20200206のiOSに関する記事は6件です。

UnityでiOS実機とmacエディタでBLEを使う

UnityでiOS実機とmacエディタでBLEを使う

UnityでiOS実機とmacエディタで、CoreBluetoothを使って、Bluetooth Low Energyのデバイスと通信するnative pluginを作成しました。
Unity Packageを配布しています。
リポジトリはこちら→https://github.com/fuziki/UnityCoreBluetooth

目的

UnityにはBluetoothで通信する機能がないです。
iOSでdaydreamのコントローラを使いたかったので、プラグインを自作しました。
Unity Editorにも対応しており、editorで実際に接続して実装して、実機は動作確認だけという開発が可能です。

導入

組み込む

Daydreamのコントローラの生データを取得します

Daydreamコントローラについて

下記の条件のcharacteristicに接続すると、コントローラの生データを取得可能です。
BLEデバイスは複数のserviceを持っており、serviceは複数のcharacteristicを持っています。characteristicごとに決められた機能が提供されており、今回はcharacteristic uuidは同名のidが複数存在するので、notifyのusageのcharacteristicを使って生データを受け取ります。

| 項目 | 接続する端末の条件 |
|--|--|
| デバイス名 | Daydream controller |
| service uuid | FE55 |
| characteristic usage | notify |

UnityCoreBluetoothを使う

1. シングルトンインスタンスの生成

UnityCoreBluetoothはシングルトンで使用します。
bluetooth機能がpowerOnになったら、BLEデバイスのスキャンを開始させます。
コールバックの設定が全て終了したら、StartCoreBluetoothを呼び出して開始します。

        UnityCoreBluetooth.CreateSharedInstance();
        UnityCoreBluetooth.Shared.OnUpdateState((string state) =>
        {
            Debug.Log("state: " + state);
            if (state != "poweredOn") return;
            UnityCoreBluetooth.Shared.StartScan();
        });
        //~~中略~~
        UnityCoreBluetooth.Shared.StartCoreBluetooth();

2. 接続したいデバイス名のデバイスを見つけたら、接続する

        UnityCoreBluetooth.Shared.OnDiscoverPeripheral((UnityCBPeripheral peripheral) =>
        {
            if (peripheral.name != "")
                Debug.Log("discover peripheral name: " + peripheral.name);
            if (peripheral.name != "Daydream controller") return;

            UnityCoreBluetooth.Shared.StopScan();
            UnityCoreBluetooth.Shared.Connect(peripheral);
        });

3. デバイスに接続したら、サービスを探す。

        UnityCoreBluetooth.Shared.OnConnectPeripheral((UnityCBPeripheral peripheral) =>
        {
            Debug.Log("connected peripheral name: " + peripheral.name);
            peripheral.discoverServices();
        });

4. 対象のuuidのサービスが見つかったら、characteristicを探す。

        UnityCoreBluetooth.Shared.OnDiscoverService((UnityCBService service) =>
        {
            Debug.Log("discover service uuid: " + service.uuid);
            if (service.uuid != "FE55") return;
            service.discoverCharacteristics();
        });

5. usage がnotifyのcharacteristicが見つかったら、通知を有効にする

通知を有効にすることで、daydreamコントローラから連続して生データを受け取ることが可能になります。

        UnityCoreBluetooth.Shared.OnDiscoverCharacteristic((UnityCBCharacteristic characteristic) =>
        {
            string uuid = characteristic.uuid;
            string usage = characteristic.propertis[0];
            Debug.Log("discover characteristic uuid: " + uuid + ", usage: " + usage);
            if (usage != "notify") return;
            characteristic.setNotifyValue(true);
        });

6. characteristicから通知があったら、データを受け取る

※ リアルタイムで受け取れるのですが、メインスレッド保証ではないです。

        UnityCoreBluetooth.Shared.OnUpdateValue((UnityCBCharacteristic characteristic, byte[] data) =>
        {
            this.value = data;
            this.flag = true;
        });

7. シングルトンインスタンスの破棄

    void OnDestroy()
    {
        UnityCoreBluetooth.ReleaseSharedInstance();
    }

終わりに

unityエディタとiOS実機で動くBLEのプラグインを作成しました。
動作確認のたびにiOSの実機を起動するのは手間がかかるので、エディタで開発可能になることで開発が数倍楽になった気がします。
このプラグインの構造などはこちらの記事に書いてあります。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Instagram検索画面風レイアウトをCompositionalLayoutで爆速開発する(約150行の実装)

はじめに

本記事ではiOS13から利用可能になった UICollectionViewCompositionalLayout を利用して、インスタグラムの検索画面のようなパネルレイアウトを作成する方法を記載します。

UICollectionViewCompositionalLayout は、複雑なレイアウトを簡単で宣言的な記述で実現できるレイアウトクラスで、今回のデモに関しては約150行足らずですべての実装を完了することができました。

実際のAppの挙動は下記にgif動画を添付しています。

成果物
image

サンプルコード
https://github.com/chocoyama/InstaLikeLayout

ViewControllerの実装は↓だけです。(約35行)
https://github.com/chocoyama/InstaLikeLayout/blob/master/InstaLikeLayout/ViewController.swift

UICollectionViewCompositionalLayoutとは

まずは簡単にこのクラスの説明をしておきます。
これは、iOS13で提供されたレイアウトのクラスで、以下の特徴を持ちます。

  • 宣言型タイプのAPIでレイアウトを組むことができる
  • 小さなレイアウトをつなぎ合わせていく形でレイアウトを組んでいく
  • サブクラス化は必要ない
  • レイアウトが複雑になっても、それに比例してコードの量が増えない
  • セクションごとにレイアウトを変更するといったことができる

関連クラス

関連クラスとしては以下のものがあり、それぞれがレイアウトされるViewを抽象化しています。
これらを組み合わせながら具体的なサイズや位置を決めていきます。

  • NSCollectionLayoutSection
    • セクションひとつ分を表すもので、NSCollectionLayoutGroupを渡して初期化する
  • NSCollectionLayoutGroup
    • セルをグルーピングして細かくレイアウトを制御するための集合
  • NSCollectionLayoutItem
    • レイアウトの最小単位でセル1つ分にあたるもの

詳細な実装は後述します。

サイズの指定

サイズ指定は上記の関連クラスに対して NSCollectionLayoutSize を受け渡して指定します。
これはGroupやItemのサイズを指定するもので以下のような形で指定します。

NSCollectionLayoutItem(
    layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(2/3),
                                       heightDimension: .fractionalHeight(1.0))
)

さらに、引数に受け渡す NSCollectionLayoutDimension は以下のような種類があり、実現したいレイアウトによってそれぞれ使い分けます。

open class NSCollectionLayoutDimension : NSObject, NSCopying {
    // 親のwidthとの相対値
    open class func fractionalWidth(_ fractionalWidth: CGFloat) -> Self
    // 親のheightとの相対値
    open class func fractionalHeight(_ fractionalHeight: CGFloat) -> Self
    // 絶対値指定
    open class func absolute(_ absoluteDimension: CGFloat) -> Self
    // 推定サイズを指定して、自動計算させる
    open class func estimated(_ estimatedDimension: CGFloat) -> Self
    // ...

これらを指定することで親からの相対値や絶対値指定などでセルのサイズを決定していきます。
ルートのGroupはCollectionViewを基準としてサイズが決定され、子Groupは親Groupを基準に、Itemは属しているGroupを基準にサイズが決定されていきます。

実装

ここからは今回作成するレイアウトの実装の説明に入ります。
全体的な方針としては以下のような感じになります。

  • セクションごとに柔軟にレイアウトを変えられるCompositionalLayoutの特徴を活用し、3パターンのレイアウトパターンをセクションごとに適用していく
    1. 左側のセルが大きく、右側には2x1の小さいセルが表示されるレイアウト
    2. 右側のセルが大きく、左側には2x1の小さいセルが表示されるレイアウト
    3. 全て同じサイズのセルが2x3で敷き詰められているレイアウト
  • セクションごとにレイアウトを決めるので、扱いやすいようにデータもセクションごとにまとめて持っておく
    • これをCompositionalLayoutに与えてレイアウトさせる

レイアウトパターンの定義

まず、CompositionalLayoutを作るにあたって、レイアウトを大きく3パターンに分けました。

  1. 左側が大きいアイテムで、右側2行に小さいアイテムを配置したレイアウト(leadingLarge)
  2. 右側が大きいアイテムで、左側2行に小さいアイテムを配置したレイアウト (trailingLarge)
  3. 全て同じサイズのアイテムで2x3のパネルにアイテムを配置したレイアウト(spread)

これらのレイアウトパターンに応じて、それぞれを1セクションとして扱っていくことにします。
これをコードで定義すると以下のようになります。

enum Kind: Int, CaseIterable {
    // ※ spreadが2つに分かれているのは、後述する処理で、この定義順でレイアウトを作っていくためです
    case leadingLarge, spread1, trailingLarge, spread2

    // 各レイアウトパターンが表示するアイテムの件数を返却します
    var numberOfItemsInSection: Int {
        switch self {
        case .leadingLarge, .trailingLarge: return 3
        case .spread1, .spread2: return 6
        }
    }
}

Sectionのモデルを定義

次に、作成するレイアウトがデータを扱いやすくするために、レイアウト用のモデル(Section)を作成します。
各セクションに表示するセル1つ分にあたるモデル(Item)は画面によって変わるため、Genericsで抽象化しておきます。
Sectionモデルでは、このItemの配列とレイアウトパターン(Kind)を保持します。

また、合わせてItemの配列をSectionの形式に変換するbuild関数も定義しました。
変換ロジックはprotocolを用いてDI可能にし、要件によって柔軟に表示をコントロールできるようにしています。
(今回のサンプルではKindで定義されているレイアウトの順番で順々に表示していくだけです。)

struct Section<Item: Hashable>: Hashable {
    let id = UUID()
    let kind: Kind
    let items: [Item]

    static func build(_ items: [Item], with strategy: LayoutStrategy) -> [Section<Item>] {
        strategy.buildSections(for: items)
    }
}

protocol LayoutStrategy {
    func buildSections<Item>(for items: [Item]) -> [Layout.Section<Item>]
}

Itemの配列をSectionの配列に変換

ここで、実際にSectionを作っていく実装を行います。
内容について詳細は書きませんが、Kindのenumで定義されている順番で、それぞれのレイアウト用の件数分Itemを取り出してSectionにしています。

/// Layout.Kindで定義されている1セクションに表示するセルの数ごとに分割していく
struct RegularOrderLayoutStrategy: LayoutStrategy {
    func buildSections<Item>(for items: [Item]) -> [Layout.Section<Item>] {
        var sections = [Layout.Section<Item>]()

        var kind: Layout.Kind = .leadingLarge
        var tmpItems: [Item] = []
        for item in items {
            if tmpItems.count == kind.numberOfItemsInSection {
                sections.append(.init(kind: kind, items: tmpItems))
                kind = next(from: kind)
                tmpItems = []
            }
            tmpItems.append(item)
        }
        sections.append(.init(kind: kind, items: tmpItems))

        return sections
    }

    private func next(from kind: Layout.Kind) -> Layout.Kind {
        // 定義されている次の値を返却する
        Layout.Kind(rawValue: kind.rawValue + 1 == Layout.Kind.allCases.count ? 0 : kind.rawValue + 1)!
    }
}

Layoutの作成

これで準備が整いました。
あとは、UICollectionViewCompositionalLayoutを作成して実際のレイアウトの記述をしていくだけです。
順番に見ていきます。

UICollectionViewCompositionalLayoutの初期化

まずは、最初に UICollectionViewCompositionalLayout のイニシャライザを呼び出します。
該当のクラスはいくつかイニシャライザが提供されていますが、今回利用しているのは下記のもので、セクションごとに異なるレイアウトを設定できます。
UICollectionViewCompositionalLayoutSectionProvider からセクションのインデックスを取得できるので、これを用いて分岐を行います。

public typealias UICollectionViewCompositionalLayoutSectionProvider = (Int, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection?
open class UICollectionViewCompositionalLayout : UICollectionViewLayout {
    // ...    
    public init(sectionProvider: @escaping UICollectionViewCompositionalLayoutSectionProvider)
    // ...
}

レイアウトの構造

今回は大きく分けて3パターンのレイアウトがあるので、それぞれを構造化すると以下のようになります。
具体的にどのようにサイズを指定してるかは後述している実装コードに記載があります。

  • leadingLarge
    • NSCollectionLayoutSection
      • NSCollectionLayoutGroup
        • NSCollectionLayoutItem(左側の大きなセル、幅は)
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(右側の小さなセル)
          • NSCollectionLayoutItem(右側の小さなセル)
  • spread
    • NSCollectionLayoutSection
      • NSCollectionLayoutGroup
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(左側の小さなセル)
          • NSCollectionLayoutItem(左側の小さなセル)
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(中央の小さなセル)
          • NSCollectionLayoutItem(中央の小さなセル)
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(右側の小さなセル)
          • NSCollectionLayoutItem(右側の小さなセル)
  • trailingLarge
    • NSCollectionLayoutSection
      • NSCollectionLayoutGroup
        • NSCollectionLayoutGroup
          • NSCollectionLayoutItem(左側の小さなセル)
          • NSCollectionLayoutItem(左側の小さなセル)
        • NSCollectionLayoutItem(右側の大きなセル)

(汚いですが)これを図にすると以下のようになります。

image

実装コード

上記に示した構造は下記のようなコードで宣言されます。

static func build<Item>(for sections: [Section<Item>]) -> UICollectionViewCompositionalLayout {
    UICollectionViewCompositionalLayout { (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
        let largeItem = NSCollectionLayoutItem(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(2/3), // 1. 幅:親Groupの幅(CollectionViewの幅)の2/3
                                               heightDimension: .fractionalHeight(1.0)) // 2. 高さ:親Groupの高さ(CollectionViewの高さの4/10)に合わせる
        )

        let smallGroup = NSCollectionLayoutGroup.vertical(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/3), // 3. 幅:CollectionViewの幅の1/3
                                               heightDimension: .fractionalHeight(1.0)), // 4. 高さ:親Groupの高さ(CollectionViewの高さの4/10)に合わせる
            subitem: NSCollectionLayoutItem(
                layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), // 5. 幅:親Groupの幅(CollectionViewの幅の1/3)に合わせる
                                                   heightDimension: .fractionalHeight(1.0)) // 6. 高さ: 親Groupの高さ(CollectionViewの高さの4/10)をcountで割った値にする
            ),
            count: 2 // 2件分表示されるように自動計算させる
        )

        let nestedGroup = NSCollectionLayoutGroup.horizontal(
            layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), // 7. CollectionViewの幅に合わせる
                                               heightDimension: .fractionalHeight(4/10)), // 8. CollectionViewの高さの4/10
            subitems: {
                switch sections[sectionIndex].kind {
                case .leadingLarge: return [largeItem, smallGroup]
                case .spread1, .spread2: return [smallGroup, smallGroup, smallGroup]
                case .trailingLarge: return [smallGroup, largeItem]
                }
            }()
        )
        return NSCollectionLayoutSection(group: nestedGroup)
    }
}

非常にシンプルで宣言的な形で、かつ少ない行数でレイアウトの記述ができていることがわかると思います。

(また汚いですが、一応図との対応も書きました)
image

ViewControllerでの適合

以上でレイアウトの作成は完了したので、最後に作成したレイアウトをCollectionViewにセットすれば終了です。

let sections: [Layout.Section<UIColor>] = {
    let colors = (0..<1000).map { _ in
        UIColor(red: (CGFloat(arc4random_uniform(255)) + 1) / 255,
                green: (CGFloat(arc4random_uniform(255)) + 1) / 255,
                blue: (CGFloat(arc4random_uniform(255)) + 1) / 255,
                alpha: 1.0)
    }
    return Layout.Section.build(colors, with: RegularOrderLayoutStrategy())
}()

collectionView.setCollectionViewLayout(Layout.build(for: sections), animated: false)

最後に

長々と書いてしまいましたが、全体を見るととてもシンプルな実装で複雑なレイアウトが組めていることがわかると思うので、下記にサンプルコードも載せています。

https://github.com/chocoyama/InstaLikeLayout

CompositionalLayoutを使うと、AppStoreのようなレイアウトも簡単に作れるので、iOS13以降をサポートするアプリは積極的に使っていきましょう〜 :thumbsup:

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【iOS】ビューを凹んだように見せて「押した」感を演出する

iOS アプリで、ボタン以外にタップ可能なビューを置きたいケースがあります。例えば、マンガ向け・ストーリー構成力養成ドリル drill というアプリで以下のようなカードっぽいビューを作る機会がありました。

このカードはタップすると次の画面に遷移しますが、そのままだとフィードバックが何もなく、「ちゃんとタップされた」かどうか不安になりそうでした。

そこで、ビューを視覚的に凹んだように見せることで、「押した」感のあるフィードバックを実現しました。意外と情報がなかったのでメモしておきます。

ガイドラインを調べる

早速実装…といきたいことろですが、もし「押した」感の演出で公式で推されている表現があるならそれに従っておきたかったので、最初に簡単にガイドラインを調べました。

Human Interface Guidelineでは?

Apple 公式の UI デザインのガイドラインである Human Interface Guidelines をざっと眺めてみましたが、タップに対する視覚的なフィードバックの記述が見つかりません… ありそうなものですが。見逃しているだけ?:thinking:

Material Designでは?

Android アプリの UI ガイドラインである Material Design も調べてみました。Material Design のコンポーネントは、押した部分から波紋が広がるようなエフェクトがついています。このエフェクトには Ripple という名前がついているようです。

ただ、なぜか Material Design のページからは Ripple の記述が削除されてしまっていました。obsolete になった…?:thinking:
https://material.io/go/design-ripple/ 1

実は、Ripple も試してみたのですが、非常に「Android 感」が出てしまったので、iOS 版では違う表現を使うことにしました。

公式アプリを参考にする

ガイドラインにはあまり情報が無さそうだったので、実際のアプリを見て参考にしてみます。

Apple 公式の「App Store」アプリなどでは、カードっぽいものを押すとわずかにサイズが小さくなって、「凹んだ」ような感じになって押したことが視覚的にわかりやすくなっています。

7edc9634-c6db-1b9e-a734-f5865f71b8ad.gif

確かにこんな感じにすれば、押した感を出せそうです。今回はこの表現を真似します。

実装

まさにな記事があったのでほぼこちらを参考にしました:pray:

SwiftでUIButtonのタップ時に押された感のアニメーション - Qiita

ただ、書き方が古くてそのままではコンパイルできなかったので、最新の UIKit のメソッドやプロパティに合わせて書き換えました。Xcode 10.2.1、Swift 5 で試しています。

以下のようなクラスを作って、これを継承すると、押した感のあるフィードバックがあるビューが作れます。

import UIKit

class TouchFeedbackView: UIView {
    // タップ開始時の処理
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        self.touchStartAnimation()
    }

    // タップキャンセル時の処理
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesCancelled(touches, with: event)
        self.touchEndAnimation()
    }

    // タップキャンセル時の処理
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        self.touchEndAnimation()
    }

    // ビューを凹んだように見せるアニメーション
    private func touchStartAnimation(){
        UIView.animate(withDuration: 0.1,
                       delay: 0.0,
                       options: UIView.AnimationOptions.curveEaseIn,
                       animations: {
                        // 少しだけビューを小さく縮めて、奥に行ったような「凹み」を演出する
                        self.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
        },
                       completion: nil
        )
    }

    // 凹みを元に戻すアニメーション
    private func touchEndAnimation(){
        UIView.animate(withDuration: 0.1,
                       delay: 0.0,
                       options: UIView.AnimationOptions.curveEaseIn,
                       animations: {
                        // 元の倍率に戻す
                        self.transform = CGAffineTransform(scaleX: 1.0, y: 1.0)
        },
                       completion: nil
        )
    }
}

// 使用例
class LessonCardView: TouchFeedbackView {
    // ...
}

課題: UIButton など UIView 以外の親クラスを持ちたいビューではこれを使えない

こんな感じの表示になります:raised_hands:2

まとめ

ビューを視覚的に凹んだように見せることで、「押した」感を演出する表現を紹介しました。実際に触ってみるとわかるのですが、これがあるのとないのとでは大きく体験が違います。ちょっとした工夫でユーザー体験が向上するので、ぜひ参考にしてみてください:pray:


  1. Material Design の404ページはメタくて面白い。 

  2. この例は効果をわかりやすくするために、やや大げさに縮小率を設定しています。実際のアプリはもう少し控えめです。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[iOS]今からはじめるドキュメントベースApp

こんにちは。
@gachalatteです。

今回はドキュメントベースAppのお話です。

iOS 11のトピックですので目新しさはありませんが、Firevaultの開発で得られた知見を共有したいと思います。

ドキュメントベースApp

ユーザーが対象を選択し、内容を編集、名前を付けて保存する。iOSでこのようなAppを開発するなら、ドキュメントベースApp(Document-Based App)が最適です。

UIDocument、Open in Place、Document Provider Extension、iCloud Driveなど、Appleの主要なテクノロジーをドキュメントベースAppに組み込むことで、ユーザーの生産性を高め、素晴らしいユーザー体験を提供することができます。

ドキュメントベースApp - Apple Developer

ドキュメントベースAppを採用すれば、Appは様々な能力を手に入れることができます。

  • 共通のユーザーインターフェースによるファイル操作

    • ユーザーは、ファイル.appと同様のインターフェースでファイルを操作することができます。
  • フォルダ、タグによるファイル管理

    • ユーザーが自身にとって最適な方法でファイルを整理することができます。
  • iCloud Driveによるファイル共有

    • ファイルをチームで共同編集したり、読み取り専用での一般公開したりすることができます。
  • DropboxGoogle Driveなどの外部プロバイダのサポート

    • ローカルディレクトリと同じ要領で外部プロバイダのコンテナにアクセスすることができます。フレームワークやAPIの呼び出しは不要です。
  • シームレスなデバイス間同期

    • ファイルの変更は、即座にユーザーインターフェースに反映されます。これは、ユーザー体験を大幅に向上させます。

この記事では、ファイルパッケージを使ったドキュメントベースAppの開発手順を紹介します。

ファイルパッケージ

ドキュメントベースAppを開発する前に、どのような形式でファイルを書き出すかを設計する必要があります。もし、ドキュメントに複数のコンテンツが含まれる場合は、ファイルパッケージ形式がおすすめです。

ファイルパッケージの実体は単なるディレクトリですが、iOSやmacOSは、ファイルパッケージを単一のファイルとして扱います。これによって、内包するファイルの整合性が確保されます。クラウド上のファイルは、Appが実行中に変更される可能性があります。一部のファイルだけが更新された瞬間を読み込んでしまうと誤作動を起こすのは避けられません。整合性が確保されているということは、とても重要な要素です。

ファイルパッケージは、非常に扱いやすいという特徴もあります。プログラムでは、通常のディレクトリと同じように操作することができ、MacのFinderでは、右クリック > パッケージの内容を表示で、中を開くこともできます。

また、更新されたファイルだけが送受信の対象となるため、クラウド上のファイルを効率良く転送することができるのもファイルパッケージの特徴です。

FileWrapper

ファイルパッケージを読み書きするにはFileWrapperを使用します。FileWrapperを使わず、直接ファイルを読み書きすることもできますが、FileWrapperを使用することで以下のメリットを享受することができます。

  • 一括操作

    • パッケージの読み書きは一括で行います。個別にファイルを読み書きする必要はありません。UIDocumentを使用する場合は、読み書きの手続きすら不要です。
  • 差分書き出し

    • 変更されたファイルだけを書き出すことができます。これによって書き込み時のパフォーマンス向上が期待できます。
  • 遅延読み込み

    • パッケージ内のファイルが必要になった時に読み込むことができます。これによって読み込み時のパフォーマンス向上やメモリの節約が期待できます。
  • ファイルマッピング

    • パッケージ内のファイルをメモリマップトファイルとして開くことができます。これによって読み込み時のパフォーマンス向上やメモリの節約が期待できます。

UIDocument

UIDocumentはドキュメントを表すモデルであると同時に、ファイルの読み書きを行うコントローラの役割を持つクラスです。UIDocumentには以下の機能があり、ドキュメントベースAppを最小限のコードで開発することができます。

  • 協調読み書き

    • ファイルは外部のプロセスによって常に更新される可能性があります。そのため、ファイルの読み書きにはNSFilePresenterNSFileCoordinatorを使用した協調読み書きの手続きが必要になります。UIDocumentは、これらを適切に使用し、ファイルの協調読み書き行います。
  • 非同期の読み書き

    • ファイルを同期的に読み書きすると、その間Appが応答しなくなる可能性があります。UIDocumentは、バックグラウンドのキューを使い、ファイルの読み書きを非同期で行います。
  • 更新の監視

    • UIDocumentは、ファイルの更新を監視し、自動的に再読み込みを行います。また、ファイルが別の場所に移動された場合でも安全に動作します。
  • 安全な書き込み

    • UIDocumentは、ファイルを一時ディレクトリに書き出し、元のファイルを置き換えます。保存中にクラッシュしてもファイルの整合性が失われることはありません。
  • 自動保存

    • UIDocumentは、ファイルを自動的に保存します。Appを閉じてもそれまでの変更は失われません。
  • エラーやコンフリクトの通知

    • UIDocumentは、エラーやコンフリクトなどの状態を保ち、変化があった時に通知します。Appはこれを監視して適切な処理を行なうことができます。
  • サンドボックス外のファイルアクセス

    • ドキュメントベースAppでは、サンドボックス外のファイルを開くことができます。サンドボックス外のファイルのURLはSecurity-scoped URLと呼ばれ、読み書きする前にアクセス権を取得する必要があります。UIDocumentは、Security-scoped URLに対するアクセス権の取得/解放を自動的に行います。

UIDocumentBrowserViewController

UIDocumentBrowserViewControllerはコンテナに含まれるファイルの一覧を表示し、それぞれのファイルを操作するインターフェースを提供するクラスです。これはファイル.appとほぼ同等の機能を持ちます。

サンプルプロジェクト

Xcode 11では、ドキュメントベースAppのテンプレートが提供されています。これを使ってプロジェクトを作成します。なお、サンプルプロジェクトではSwiftUIを使用します。

https://github.com/gachalatte/DocumentBasedApp

プロジェクト設定

テンプレートの実装は、サポートするドキュメント形式に画像(public.image)ファイルが定義されています。これをカスタムドキュメントに変更します。

プロジェクト設定 > Infoを開き、定義を変更します。

プロジェクト設定

Document Types

Key Value
Name My Document
Types net.gacha.mydoc

Nameには、ファイルの種類として画面上に表示されるテキストを指定します。
Typesには、カスタムドキュメントのUTI(Uniform Type Identifier)を定義します。サンプルプロジェクトではnet.gacha.mydocとしましたが、ユニークな文字列であれば何でも構いません。

Additional document type properties

Key Type Value
CFBundleTypeRole String Editor
LSHandlerRank String Owner
LSTypeIsPackage Boolean YES

この部分はAPIドキュメントにも詳細が記されておらず、きちんと説明できるだけの理解が得られませんでした。ただ、今回のケースではこの設定で動作することを確認しています。詳しく知りたい場合は、CFBundleDocumentTypesを調べてみてください。

Exported UTIs

テンプレートの初期状態は空なので、行を追加します。

Key Value
Description My Document
Identifier net.gacha.mydoc
Conforms To com.apple.package, public.composite-content

Identifierには、Document Typesで定義したUTIを設定します。
Conforms Toは、カスタムドキュメントが適合するUTIを表します。com.apple.packageはファイルパッケージを表し、public.composite-contentは複数の内容物で構成されていることを表します。

Additional exported UTI properties

Key Type Value
UTTypeTagSpecification Dictionary
    public.filename-extension Array
        Item 0 String mydoc

UTTypeTagSpecificationのpublic.filename-extensionにはファイルの拡張子を定義します。

実装

プロジェクトの設定が終わったら、テンプレートで用意された3つのクラスを実装していきます。

Document

まずは、UIDocumentのサブクラスであるDocumentを実装します。

Document.swift
import UIKit

class Document: UIDocument, ObservableObject {

    @Published var image: UIImage?
    @Published var text: String?

    override func contents(forType typeName: String) throws -> Any {
        return FileWrapper(directoryWithFileWrappers: Dictionary(uniqueKeysWithValues: FileID.allCases.compactMap(fileWrapper(for:)).map({ ($0.preferredFilename!, $0) })))
    }

    override func load(fromContents contents: Any, ofType typeName: String?) throws {
        guard let fileWrapper = contents as? FileWrapper, fileWrapper.isDirectory else { return }
        FileID.allCases.compactMap({ (fileID) -> (FileID, Data?)? in
            guard let child = fileWrapper.fileWrappers?[filename(for: fileID)] else { return nil }
            return (fileID, child.regularFileContents)
        }).forEach({ (fileID, data) in
            setData(data, for: fileID)
        })
    }

}

extension Document {

    private enum FileID: CaseIterable {
        case image
        case text
    }

    private func filename(for fileID: FileID) -> String {
        switch fileID {
        case .image:
            return "image.png"
        case .text:
            return "text.txt"
        }
    }

    private func data(for fileID: FileID) -> Data? {
        switch fileID {
        case .image:
            return image?.pngData()
        case .text:
            return text?.data(using: .utf8)
        }
    }

    private func setData(_ data: Data?, for fileID: FileID) {
        switch fileID {
        case .image:
            image = {
                guard let data = data else { return nil }
                return UIImage(data: data)
            }()
        case .text:
            text = {
                guard let data = data else { return nil }
                return String(data: data, encoding: .utf8)!
            }()
        }
    }

    private func fileWrapper(for fileID: FileID) -> FileWrapper? {
        guard let data = data(for: fileID) else { return nil }
        let fileWrapper = FileWrapper(regularFileWithContents: data)
        fileWrapper.preferredFilename = filename(for: fileID)
        return fileWrapper
    }

}

SwiftUIで使用するため、DocumentをObservableObjectに適合し、各プロパティには@Published属性をつけて変更を通知するようにしています。これは、プロパティが変更されたら画面を更新するということを実現するためのもので、SwiftUIを使わない場合は、@objc属性をつけてKVOで監視する方法でも構いません。また、オプショナルにしているのは、ファイルが存在しないケースを考慮しています。

プロパティのimagetextは、それぞれimage.pngtext.txtの内容を保持します。プロパティとファイル、どちらか一方が変更されれば、他方に反映されるようにします。

注目すべきは、func contents(forType: String) -> Anyfunc load(fromContents: Any, ofType: String?)です。UIDocumentは、適切なタイミングでこれらのメソッドを呼び出し、ファイルの読み書きを行います。contentsDataFileWrapperに対応しているため、これらの形式の値をやり取りするだけで、ファイルの読み書きが実現できます。

サンプルコードの後半、extensionの部分は、上記の実装を効率よく行うためのヘルパーです。

DocumentBrowserViewController

DocumentBrowserViewControllerUIDocumentBrowserViewControllerのサブクラスで、アプリ起動時の初期画面となります。

テンプレートの初期状態では、ドキュメントを開くことはできますが、新規作成ができません。ドキュメントの新規作成に対応するために、func documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler: @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Void)を実装します。

DocumentBrowserViewController.swift
    func documentBrowser(_ controller: UIDocumentBrowserViewController, didRequestDocumentCreationWithHandler importHandler: @escaping (URL?, UIDocumentBrowserViewController.ImportMode) -> Void) {
        let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent("Document.mydoc")
        let document = Document(fileURL: temporaryURL)
        document.save(to: temporaryURL, for: .forCreating) { (success) in
            if success {
                importHandler(temporaryURL, .move)
            } else {
                importHandler(nil, .none)
            }
        }
    }

まず、Documentオブジェクトを生成し、一時ファイルとして保存します。保存処理が完了したら、importHandlerを呼び出します。ImportModeとして.moveを指定しているので、一時ファイルを削除する必要はありません。

一時ファイルのファイル名は、作成するドキュメントのファイル名になります。拡張子は、プロジェクト設定で定義したものにします。今回は、ファイル名をDocument.mydocと固定にしていますが、同じ名前のファイルが存在する場合は、Document 2.mydocのように自動的にサフィックスが付与されますので心配はいりません。

なお、このメソッドは非同期でデザインされているため、ファイル名を入力するダイアログボックスや、テンプレートを選択する画面を表示することもできます。最後にimportHandlerを呼び出すのを忘れないように気をつけてください。

DocumentView

最後に、DocumentViewを実装します。 DocumentViewは、Documentの表示、更新を行うユーザーインターフェースを提供します。

DocumentView.swift
import SwiftUI
import Combine

struct DocumentView: View {

    @ObservedObject var document: Document

    @State private var showImagePicker: Bool = false

    var dismiss: () -> Void

    var body: some View {

        return VStack(spacing: 30) {

            Text(document.localizedName)
                .font(.title)

            Group {
                if document.image == nil {
                    Button(action: {
                        self.showImagePicker = true
                    }) {
                        Image(systemName: "camera.on.rectangle").imageScale(.large).background(RoundedRectangle(cornerRadius: 6).foregroundColor(Color.secondary.opacity(0.1)))
                    }
                } else {
                    Image(uiImage: document.image!).resizable().aspectRatio(contentMode: .fit).frame(width: 240, height: 240)
                        .onTapGesture {
                            self.showImagePicker = true
                    }
                }
            }

            TextView(text: bind(\.text))
                .frame(width: 240, height: 80)
                .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary, lineWidth: 1))

            Button("Done", action: dismiss)

        }.sheet(isPresented: $showImagePicker) {
            ImagePicker(image: self.bind(\.image))
        }
    }

}

DocumentViewは、以下の4つのコンポーネントで構成されています。

  • Text(document.localizedName)

    • ドキュメント名を表示します。
  • Group

    • document.imageの内容を表示、編集します。タップで、ImagePickerを表示します。
  • TextView(text: text)

    • document.textの内容を表示、編集します。
  • Button("Done")

    • ドキュメントを閉じます。

基本的にはこれだけですが、UIDocumentを更新するUIの実装で考慮しなければならないことがあります。それは変更の通知です。UIDocumentは適切なタイミングでドキュメントを保存しますが、それにはUIDocument自身が変更されたことを知っている必要があります。document.hasUnsavedChangesがそれに当たり、hasUnsavedChangestrueの時、ドキュメントは自動保存されます。ただし、このプロパティはreadonlyのため、直接設定することはできません。func updateChangeCount(_ change: UIDocument.ChangeKind)hasUnsavedChangesを更新する手段のひとつですが、よりよい実装として、UndoManagerを使う方法があります。

UndoManagerは、操作の取り消し、やり直しを実現するクラスです。UIDocumentUndoManagerのインスタンスを保持しており、これを利用することができます。プロパティを変更した時にdocument.undoManagerに変更前の値に戻す処理を登録することで、AppがUndo/Redoの能力を手に入れるのと同時に、UIDocumentに変更を通知することができます。

以下はSwiftUIの話になりますが、サンプルコードでは、値の変更をハンドリングするために、下記のメソッドを定義して動的にBindingオブジェクトをViewコンポーネントに渡すようにしています。ただし、この部分に関しては、あまり自信がありません。もっと良い方法があれば教えてください。

DocumentView.swift
extension DocumentView {

    private func bind<Value>(_ keyPath: ReferenceWritableKeyPath<Document, Value>) -> Binding<Value> {
        let document = self.document
        return Binding<Value>(get: { () -> Value in
            return document[keyPath: keyPath]
        }, set: { (value) in
            let oldValue = document[keyPath: keyPath]
            document.undoManager.registerUndo(withTarget: document) { $0[keyPath: keyPath] = oldValue }
            document[keyPath: keyPath] = value
        })
    }

}

ここまでできたら、プロジェクトを実行してみましょう。1

iCloud Driveにファイルを作成して、デバイス間で相互に変更が反映されることを確認してみてください。ホーム画面でAppアイコンを長押ししてファイル選択をショートカットしたり、ファイル.appからAppが起動することも確認してみてください。

課題

世の中そんなに甘くはありません。うまい話には裏があります。

実際のプロダクトでドキュメントベースAppを開発するに当たっての課題を紹介します。

ファイル数の限界

UIDocumentFileWrapperは、ファイルパッケージを一括で操作するため、含まれるファイル数が増えるにしたがってパフォーマンスが低下します。やむを得ずこれらの使用を断念する場合は、NSFilePresenterによる変更監視、NSFileCoordinatorによる協調読み書き、バックグランドキューによる非同期のファイルアクセス、サンドボックス外のファイルに対するアクセス権の取得など、すべてを自前で行う必要があります。また、一時ディレクトリを使わずにファイルを直接書き出す場合は、整合性が保証されないことを考慮した設計が必要になり、開発の難易度は一気に上昇します。

ドキュメントベースAppの開発を始める前に、想定する最大数のダミーファイルを用意してパフォーマンスを計測するのをおすすめします。

ファイルパッケージの越えられない壁

ファイルパッケージは、iOSおよびmacOSでのみ有効です。外の世界に出た瞬間、それは普通のディレクトリとして扱われます。そのため、DropboxGoogle Driveなどの外部プロバイダにファイルパッケージを保存することができません。また、メールなどで送信した場合も期待通りの結果にはなりません。実際のプロダクトでファイルパッケージを採用する場合は、この点が課題となるでしょう。

AppleのGarageBandでは、サポートページに次のような案内があります。

iOS 用 GarageBand 2.3 では、iOS 用 GarageBand の曲を iPhone、iPad、iPod touch にローカルに、または iCloud Drive にだけ保存できます。

iOS 11 では、iOS 用 GarageBand 2.3 とファイル App を連係させて、GarageBand プロジェクトを管理できます。ファイル App は他社のクラウドストレージサービスに対応していますが、GarageBand のプロジェクトを以下のクラウドストレージサービスに保存することはできません。

  • DropBox
  • Google Drive
  • Box
  • Microsoft OneDrive

iOS 11 における iOS 用 GarageBand 2.3 と他社のクラウドストレージ App について - Apple サポート

ファイルの競合(コンフリクト)

ファイルが同時に更新された場合、ファイルが競合状態になることがあります。競合状態は解決しなければなりませんが、その方法は様々です。最新の変更ですべてを上書きすることもできますし、プログラムで判断して自動的にマージすることもできます。ユーザーに選択肢を提示することもできます。

URLの保持

次回の起動に備えて、最後に開いたファイルのURLを記憶しておきたいと思うことがあるかも知れません。このような場合、ファイルのURLを直接記録してはいけません。なぜなら、ユーザーは次にAppを開く前にファイル名を変更したり、場所を移動したりする可能性があるからです。かわりにURLからブックマークを生成して、それを記録するようにします。

読み取り専用での共有

iOSではファイルを読み取り専用にすることはできませんが、iCloud Drive上のファイルは、読み取り専用で他のユーザーと共有することができます。ユーザーが読み取り専用のファイルを変更しても、自動的に元の状態に戻されるので大きな問題にはなりませんが、編集ボタンをロックするなどして、ユーザーがドキュメントを変更できないようにする方がよいでしょう。読み取り専用かどうかはURLResourceKey.ubiquitousSharedItemCurrentUserPermissionsKeyで判定することができます。

さいごに

ドキュメントベースAppを採用することで、とても簡単にリッチで安全なAppが開発できることがおわかりいただけたでしょうか?

実際にプロダクトとして完成させるまでには様々な困難を乗り越えなければなりませんが、土台としてこれほど有用なものはありません。

クラウドサービス全盛の時代、このようなスタンドアロンAppを開発する機会はあまりないかも知れませんが、ドキュメントベースAppというワードだけでも覚えておいて損はないと思います。

最後までお読みいただきありがとうございました。


  1. Xcode 11.3付属のiOS 13シミュレータは、macOS Catalinaより前の環境ではUIDocumentBrowserViewControllerが正常に動作しないという問題があります。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Flutter】画面下から出てくるピッカー(ドラムロール)を実装する

picker.gif

Flutter で画面下から出てくる iOS 風ピッカー(ドラムロール)を実装します。

  • Flutter 1.12.13+hotfix.5
  • Dart 2.7.1

コード

import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Modal Picker',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Modal Picker'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key key, this.title}) : super(key: key);

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            RaisedButton(
              onPressed: () {
                _showModalPicker(context);
              },
              child: const Text('Show Picker'),
            ),
            Text(_selectedItem)
          ],
        ),
      ),
    );
  }

  void _showModalPicker(BuildContext context) {
    showModalBottomSheet<void>(
      context: context,
      builder: (BuildContext context) {
        return Container(
          height: MediaQuery.of(context).size.height / 3,
          child: GestureDetector(
            onTap: () {
              Navigator.pop(context);
            },
            child: CupertinoPicker(
              itemExtent: 40,
              children: _items.map(_pickerItem).toList(),
              onSelectedItemChanged: _onSelectedItemChanged,
            ),
          ),
        );
      },
    );
  }

  String _selectedItem = 'none';

  final List<String> _items = [
    'item1',
    'item2',
    'item3',
    'item4',
    'item5',
    'item6',
  ];

  Widget _pickerItem(String str) {
    return Text(
      str,
      style: const TextStyle(fontSize: 32),
    );
  }

  void _onSelectedItemChanged(int index) {
    setState(() {
      _selectedItem = _items[index];
    });
  }
}

解説

CupertinoPicker を使用するため、 cupertino.dart をインポートします。

import 'package:flutter/cupertino.dart';

ボタンをタップするとモーダル画面出てくるようにします。
モーダルの高さは画面の 3 分の 1 のサイズにしています。
showModalBottomSheet function - material library - Dart API

RaisedButton(
  onPressed: () {
    _showModalPicker(context);
  },
  // ...
),

// ...

void _showModalPicker(BuildContext context) {
    showModalBottomSheet<void>(
      context: context,
      builder: (BuildContext context) {
        return Container(
          height: MediaQuery.of(context).size.height / 3,
          // ...
        );
      },
    );
  }

CupertinoPickerGestureDetector でラップし、ピッカー部分をタップするとモーダルが閉じるようにしています。

GestureDetector(
  onTap: () {
    Navigator.pop(context);
  },
  child: CupertinoPicker(
    // ...
  )
)

itemExtent :アイテムの高さを指定します。
children :表示するアイテムを List<Widget> で渡します。
onSelectedItemChanged :アイテムが選択された際に実行する関数を指定します。

// ...
{
  child: CupertinoPicker(
    itemExtent: 40,
    children: _items.map(_pickerItem).toList(),
    onSelectedItemChanged: _onSelectedItemChanged,
  ),
}
// ...

String _selectedItem = 'none';

final List<String> _items = [
  'item1',
  'item2',
  'item3',
  'item4',
  'item5',
  'item6',
];

Widget _pickerItem(String str) {
  return Text(
    str,
    style: const TextStyle(fontSize: 32),
  );
}

void _onSelectedItemChanged(int index) {
  setState(() {
    _selectedItem = _items[index];
  });
}

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UIViewのExtensionでフェードイン・アウトアニメーションを実装する

UIViewでフェードイン・アウトをExtensionとして実装しました。

環境

  • Swift 5
  • Xcode 11.2.1

フェードイン・アウトを行うExtension

UIViewを継承しているクラスなら利用できるので案外便利

extension UIView {

  // MARK: Animation 

  enum Fade {
    case `in`
    case out
  }

  /// フェードインアウトを行う
  /// - Parameters:
  ///   - type: fadeタイプ
  ///   - animation: アニメーションを実行するか
  func fade(type: Fade, animation: Bool = true) {
    switch type {
    case .in:
      isHidden = false
      UIView.animate(withDuration: 0.3) {
        self.alpha = 1.0
      }
    case .out:
      if animation {
        isHidden = true
        UIView.animate(withDuration: 0.3) {
          self.alpha = 0.0
        }
      } else {
        alpha = 0.0
        isHidden = true
      }
    }
  }

}

あとがき

簡潔に分岐処理を書けてない気がするのでもっとこうしたらスマートになるよ!とかあったら教えてください

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む