20201218のiOSに関する記事は11件です。

NotificationCenterを用いて、iOSアプリのライフサイクルイベントを検知する

はじめに

iOSアプリがフォアグラウンド、ハックグラウンドに入った等のライフサイクルイベントを、NotificationCenterを用いて検知する方法について書いていきます。このNotificationCenterの使用例として、アプリがバックグランドに入って30分以上経過した状態でフォアグラウンドに戻った時に、API通信を行う方法をRxSwift、ViewModelを使用して説明します。

環境

  • Swift:5.3
  • Xcode:12.1

iOSアプリのライフサイクル

下記がiOSアプリのライフサイクルの図です。
Managing Your App's Life Cycleより引用。
iOS_Lifecycle.png

状態 内容
Unattached アプリが未起動の状態。
Background アプリがバックグラウンドで実行中。
Foreground Inactive アプリがフォアグラウンドで実行中だが、イベントは受信していない。別の状態に切り替わる時に、一瞬この状態になる。
Foreground Active アプリがフォアグラウンドで実行中。アプリを使用している時は基本的にこの状態。
Suspended アプリがバックグラウンドで未実行。

状態遷移時に呼ばれるメソッド

メソッド 呼ばれるタイミング
func application(_:willFinishLaunchingWithOptions) アプリ起動後
func application(_:didFinishLaunchingWithOptions:) アプリが画面を表示する直前
func sceneWillEnterForeground(UIScene) フォアグラウンドで実行を開始しようとする時
func sceneDidBecomeActive(UIScene) アクティブになり、イベントを受け取れる状態になった時
func sceneWillResignActive(UIScene) アクティブ状態から離れ、イベントの受信を止めようとしている時
func sceneDidEnterBackground(UIScene) バックグラウンドに入った時
func applicationWillTerminate(UIApplication) アプリが終了する時

アプリ全体で状態遷移のイベントを検知し、何らかのメソッドを実行したい場合はAppDelegate、SceneDelegateにある上記の各メソッド内に記述すれば問題ありません。ただ、場合によってはViewControllerで各状態遷移のイベントを検知したいことがあると思いますので、その方法について記述していきます。

ViewControllerで状態遷移イベントを検知する

状態遷移イベントを検知するNotificationCenterが用意されているので、それを使用します。
他にもありますが、主に下記4つがあります。

Notification 通知するタイミング
willEnterForegroundNotification フォアグラウンドで実行を開始しようとする時
didBecomeActiveNotification アクティブになり、イベントを受け取れる状態になった時
willResignActiveNotification アクティブ状態から離れ、イベントの受信を止めようとしている
didEnterBackgroundNotification バックグラウンドに入った時

NotificationCenterが通知するタイミング

アプリ起動時

  • willEnterForegroundNotification
  • didBecomeActiveNotification

アプリをバックグラウンドへ

  • willResignActiveNotification
  • didEnterBackgroundNotification

コントロールセンターを表示して閉じる

  • 表示
    • willResignActiveNotification
  • 閉じる
    • didBecomeActiveNotification

通知センターを表示して閉じる

  • 表示
    • willResignActiveNotification
    • didBecomeActiveNotification(←なぜか呼ばれる)
    • willResignActiveNotification(←2度目が呼ばれる)
  • 閉じる
    • didBecomeActiveNotification

使用例

今回はアプリがバックグランドに入って30分以上経過した状態でフォアグラウンドに戻った時にAPI通信を行うようにします。RxSwiftを使用して、ViewModelとバインディングします。

ViewController.swift
import UIKit
import RxSwift
import RxCocoa

final class ViewController: UIViewController {

    private let viewModel = ViewModel()
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        bindOutput()
        bindIntput()
        }

    private func bindOutput() {
        NotificationCenter.default.rx.notification(UIApplication.willResignActiveNotification)
            .subscribe(onNext: { [unowned self] _ in
                // アプリがアクティブではなくなる時の時間を取得
                self.viewModel.inputs.willResignActiveTime.onNext(Date())
        })
        .disposed(by: disposeBag)

        NotificationCenter.default.rx.notification(UIApplication.didBecomeActiveNotification)
            .subscribe(onNext: { [unowned self] _ in
                // アプリがアクティブになった時の時間を取得
                self.viewModel.inputs.didBecomeActiveTime.onNext(Date())
            })
            .disposed(by: disposeBag)
    }

    private func bindIntput() {
        viewModel.outputs.refreshData
            .subscribe(onNext: { [unowned self] in
                // API通信する処理を記載
            })
            .disposed(by: disposeBag)
    }
}
Viewmodel.swift
import RxCocoa
import RxSwift

protocol ViewModelInput {
    var willResignActiveTime: PublishSubject<Date> { get }
    var didBecomeActiveTime: PublishSubject<Date> { get }
}

protocol ViewModelOutput {
    var refreshData: PublishSubject<Void> { get }
}

protocol ViewModelType {
    var inputs: ViewModelInput { get }
    var outputs: ViewModelOutput { get }
}

final class ViewModel: ViewModelType, ViewModelInput, ViewModelOutput {

    var inputs: ViewModelInput { return self }
    var outputs: ViewModelOutput { return self }

    private let disposeBag = DisposeBag()

    // Inputs
    var willResignActiveTime = PublishSubject<Date>()
    var didBecomeActiveTime = PublishSubject<Date>()

    // Outputs
    public var refreshData = PublishSubject<Void>()

   init() {
        setBind()
    }

    private func setBind() {
        let thirtyMinutesPassed = didBecomeActiveTime
            .withLatestFrom(willResignActiveTime) { [unowned self] didBecomeActiveTime, willResignActiveTime in
                self.checkThirtyMinutesPassed(didBecomeActiveTime, willResignActiveTime)
        }

        thirtyMinutesPassed
            .filter { $0 }
            .subscribe(onNext: { [unowned self] _ in
                // バックグラウンドに入って30分以上経過した場合更新する
                self.outputs.refreshData.onNext(())
            })
            .disposed(by: disposeBag)
    }

    // アプリがバックグラウンドに入ってから30分経過したか判定
    // バックグラウンドに入った時間とアプリがアクティブになった時間の差が30分(30*60秒=1800秒)以上の時はtrueを返す
    private func checkThirtyMinutesPassed(_ didBecomeActiveTime: Date, _ willResignActiveTime: Date) -> Bool {
        let convertedDidEnterBackgroundTime = Int(willResignActiveTime.timeIntervalSince1970)
        let convertedDidBecomeActiveTime = Int(didBecomeActiveTime.timeIntervalSince1970)
        return convertedDidBecomeActiveTime - convertedDidEnterBackgroundTime >= 1800
    }
}

今回はアプリがバックグランドに入った時、フォアグラウンドに戻った時の時刻をそれぞれNotificationCenterを使用して取得し、その差が30分以上の場合にAPI通信をするように実装しました。

アプリがフォアグラウンドになった状態検知にdidBecomeActiveNotificationを使用しました。アプリがフォアグラウンドになる時にはwillEnterForegroundNotificationでも通知されますが、まだアプリが完全にアクティブな状態でない時に通知されるため、正常にメソッドが実行されない懸念があります(今回で言うとAPI通信する処理)。そのため、確実にアクティブな状態になった時に通知されるdidBecomeActiveNotificationを使用するのが良いです。

アプリがバックグラウンドに入った状態検知にはwillResignActiveNotificationを使用しました。didEnterBackgroundNotificationでもバックグラウンドに入ったことを検知できますが、コントロールセンター/通知センターを使用する時に問題が発生します。
その理由は、コントロールセンター/通知センターを表示する際にはdidEnterBackgroundNotificationは通知されませんが、閉じた際にはdidBecomeActiveNotificationが通知されるからです。例えば下記のようなことが発生してしまいます。

12:00 バックグラウンドに入る(didEnterBackgroundNotification)
12:31 バックグランドから復帰(didBecomeActiveNotification)
12:32 通知センター/コントロールセンターを開く
-> didEnterBackgroundNotificationは呼ばれず、バックグラウンドに入った時刻は12:00のまま
12:33 通知センター/コントロールセンターを閉じる
-> didBecomeActiveNotificationが呼ばれ、復帰した時刻は12:33
-> 12:00と12:33で差が30分以上のため、通知センター/コントロールセンターを表示してすぐ閉じた場合でもメソッドが実行される

今回はAPI通信処理を実行するので、通知センター/コントロールセンターを表示してすぐ閉じた場合にもメソッドが実行されてしまうと、無駄な通信が発生することになってしまいます。このような事を避けるため、通知センター/コントロールセンターを表示した場合にも通知されるwillResignActiveNotificationを使用しました。これで通知センター/コントロールセンターを表示してすぐ閉じた場合にメソッドが実行されるということはなくなります。

参考文献

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

iOS14 で始める CarPlay

はじめに

みなさん、CarPlay をご存知でしょうか?
実はiOS7以降のiOSには CarPlay という機能が搭載されており、対応車種であればカーナビに iPhone を繋いで、カーナビ上で CarPlay 対応された iPhone アプリを操作できるようになっています。
対応車種はこちら
canavi.gif

本稿ではその CarPlay 対応アプリのサンプルとしてナビゲーションアプリを取り上げ、地図を表示させるところまで説明します。

準備

実際にカーナビにつなげる CarPlay のアプリの開発を始めるに当たってXcodeを開く前に色々と準備をする必要があります。
1. CarPlay の開発申請
2. Provisioning Profile の設定

なお、とりあえず Simulator でいいから作ってみたい、という人はここの項目を飛ばしてもらってCarPlay アプリを実装するの項目に行ってもらって結構です。

CarPlay の開発申請

まず、iOS アプリ開発の基本として実機デバッグするには Apple Developer Program (以下ADP) に試験用端末の登録と Provisioning Profile の登録が必要なのですが CarPlay の場合はその Provisioning Profile に CarPlay 用の設定を行わないといけません。
ですが ADP の Provisioning Profile の設定画面みてもありません。

その設定を行うにはまず Apple に申請を ここ から行う必要があります(要 ADP アカウント)。
スクリーンショット 2020-12-17 1.40.55.png

その際、開発する CarPlay 向けアプリがどのようなものか App Type のセレクトボックスから以下のカテゴリから選びます
1. Audio - 音楽アプリ
2. Automaker - 車両組み込み機能アプリ
3. Communication - チャットアプリ
4. EV Charging - 充電スポット検索アプリ
5. Navigation - カーナビアプリ
5. Parking - 駐車場検索アプリ
6. Quick Food Ordering - レストラン検索アプリ

上記カテゴリ以外のアプリは開発は許されておらず、また、2つ以上を組み合わせるアプリを作ることも許されてはいません。

この中で特殊なのが 2 の Automaker です。エアコンの操作やラジオと言った車両に組み込まれてる機能を操作するためのアプリです。
もちろん車両側からそのAPIが提供されている必要があるため実際には開発する際は車両 or 周辺機器メーカーと協業する必要がでるでしょう。

なお、申請を行えるのは ADP アカウント保持者のみです。 InHouseバイナリを配布できる法人向けアカウント Apple Developer Enterprise Program(以下 ADEP )アカウントで申請しようとすると、この画面ではなく以下のような権限がないことを伝える画面が表示されます。
スクリーンショット 2020-12-17 1.54.36.png

実はこれは結構厄介で、
現状では CarPlay 対応させると InHouse ビルドができなくなることを意味しています。

業務で iOS アプリを開発してる人のほとんどが社内テスト用に InHouse ビルドしてると思うのですが、
CarPlay 対応の開発をする際はビルドコンフィルグ等をいじって InHouse ビルド時は コンパイル範囲から CarPlay を外すと言った処理が必要になってくるでしょう。(もしくは TestFlight を使うか)

Provisioning Profile の設定

CarPlay の申請が通ると Provisioning Profile 作成画面に新たに Entitlement を設定する項目が追加されます
スクリーンショット 2020-12-17 2.41.14.png
そこの項目に App Type で設定したカテゴリを選択します。
以上で CarPlay を実機デバッグするための事前準備は終了です。次に実際に CarPlay を実装していくために必要な設定及びコードを説明していきます。

CarPlay 向けアプリを実装する

では実際にiOSアプリを CarPlay 対応させていきましょう。
CarPlay 対応アプリと書いてしまうと tvOS や watchOS アプリのように専用の Bundle Identifier を用意して
ターゲットを新たに作るというイメージを持つ人がいるかもしれませんが実はそうではなく、すでにある iOS アプリのUIをカーナビ向けに最適化させる作業になります。
どちらかというと iPhone アプリの iPad 対応の方が感覚的に近いです。

では、はじめに、で述べたようにナビゲーションアプリを作っていきます

Simulator について

前段で実機デバッグするための準備について書きましたが、ずっと車両やカーナビに繋いで開発していくのも難しいため Simulator を使うことになると思います。
CarPlay の Simulator は実は iPhone Simulator に組み込まれており以下の図の様に I/O → External Display → CarPlay を選択して表示できます
スクリーンショット 2020-12-18 16.41.37.png

Entitlements ファイルを作成

開発するアプリが CarPlay 対応アプリであるということを設定する必要があります。
Entitlements ファイルを作り Provisioning Profile に設定されたカテゴリIDを有効化させます。
今回作るナビゲーションアプリの場合、カテゴリIDは com.apple.developer.carplay-maps になります

<dict>
    <key>com.apple.developer.carplay-maps</key>
    <true/>
</dict>

CarPlay 用 SceneDelegate を作る

実は Entitlements ファイルを作成した時点で CarPlay からアプリを呼ぶことが可能になります。
が、この時点で CarPlay 側から呼びだすと CarPlay 用の SceneDelegate が設定されていないためクラッシュします。
そのため、まず以下の様な CarPlay 用の SceneDelegate を用意します。

CarPlaySceneDelegate.swift
class CarPlaySceneDelegate: UIResponder {
    var interfaceController: CPInterfaceController?
    var window: CPWindow?
}

extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate {

    func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
                                  didConnect interfaceController: CPInterfaceController, to window: CPWindow) {
        self.interfaceController = interfaceController
    }
}

上記が CarPlay 用の SceneDelegate の最小限実装です。
CarPlay から呼ばれるために CPTemplateApplicationSceneDelagate を実装します。
CarPlay 上でアプリが呼ばれると上記の templateApplicationScene(_ templateApplicationScene:didConnect interfaceController:to window:) メソッドが呼ばれるのでその中で CarPlay 用の画面を作っていくことになります。

CarPlay 用の SceneDelegate を呼び出せるようにする

CarPlay 用の SceneDelegate を作ったら今度は CarPlay から呼び出せるように設定をします。
方法としては、Info.plist でやる方法と AppDelegate でやる方法の2つがあります

呼び分け方法その1 - Info.plist でやる方法

Info.plist に Application Scene Manifest という項目があります。
XML のキーとしては UIApplicationSceneManifest になります。
その中に項目として UISceneConfigurations を追加し、iPhone用の SceneDelegate クラス、 CarPlay 用の SceneDelegate クラスをそれぞれ以下のように追記します

    <key>UIApplicationSceneManifest</key>
    <dict>
        <key>UISceneConfigurations</key>
        <dict>
            <key>UIWindowSceneSessionRoleApplication</key>
                        <!-- "iPhone用のSceneDelegate設定"-->
            <array>
                <dict>
                    <key>UISceneConfigurationName</key>
                    <string>iPhone</string>
                    <key>UISceneDelegateClassName</key>
                    <string>$(PRODUCT_MODULE_NAME).IPhoneAppSceneDelegate</string>
                </dict>
            </array>

                        <!-- "CarPlay用のSceneDelegate設定"-->
            <key>CPTemplateApplicationSceneSessionRoleApplication</key>
            <array>
                <dict>
                    <key>UISceneConfigurationName</key>
                    <string>CarPlay</string>
                    <key>UISceneDelegateClassName</key>
                    <string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
                </dict>
            </array>
        </dict>
    </dict>

これを記述することで自動的に呼び出し元が iPhone 画面なのか CarPlay 画面なのかが判定されそれに合わせた SceneDelegate が呼ばれるようになります。

呼び分け方法その2 - AppDelegate でやる方法

Info.plist だけでなく AppDelegate 側にコードを記述して出しわけの制御を書くこともできます。
iOS13から AppDelegate に画面情報( UIScene )を受け取るデリゲートメソッドを定義することができるようになりました。

AppDelegate.swift
    @available(iOS 13.0, *)
    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {

        switch connectingSceneSession.role {
        case .carTemplateApplication: // CarPlay 用のアプリとして起動された
            let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
            config.delegateClass = CarPlaySceneDelegate.self
            return config
        default: // それ以外
            let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
            config.delegateClass = IPhoneAppSceneDelegate.self
            return config
        }
    }

渡ってくる UISceneSessionrole プロパティをみるとアプリがどのシーンから起動されたのかがわかるので、
そこで出しわけをすることになります。

Info.plist で出しわけをする方がいいのか AppDelegate で出しわけをする方がいいのか正直悩ましいです。
アプリのデプロイ戦略に応じて変わると考えています。

ちなみに、両方書いた場合はどちらかが無視される、というわけではなくどうやら結果が合成されるようです。
これ以上は UIScene の概念の説明となり本稿の主題からずれるので説明しませんが、omochimetaru さんの記事が詳しいので
以下を読んでいただけるといいかもしれません。
https://qiita.com/omochimetaru/items/31df103ef98a9d84ae6b

CarPlay 向けの画面を用意する

では準備が全て終わったので CarPlay アプリを作っていきましょう。
以下のコードが CarPlay から呼び出された際に画面を表示する為の最小限のコードです。
ナビゲーションアプリを作るための UI テンプレートである CPMapTemplate をセットし、起動時にそのテンプレートが表示されるようにしています。

CarPlaySceneDelegate.Swift
class CarPlaySceneDelegate: UIResponder {
    var interfaceController: CPInterfaceController?
    var window: CPWindow?
}

extension CarPlaySceneDelegate: CPTemplateApplicationSceneDelegate {

    func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, 
                                  didConnect interfaceController: CPInterfaceController, 
                                  to window: CPWindow) {
        self.interfaceController = interfaceController
        self.window = window

        let mapTemplate = CPMapTemplate()
        interfaceController.setRootTemplate(mapTemplate, animated: true)
    }
}

CPTemplate とは

ViewController を起点として画面を構築していく iOS や Mac アプリと違い、CarPlay 対応には基本的に ViewController は出てきません。
代わりに CPTemplateApplicationSceneDelegate のデリゲートメソッドで渡ってくる CPInterfaceControllerCPTemplate を継承したクラスをセットして画面表示を行います。
CPTemplate 系クラスそのものには画面遷移を行うメソッドはないです。

したがって、画面遷移を行う場合は templateApplicationScene で渡ってくる CPInterfaceController を持ち回して実装することになります。

地図を表示する

さて、起動時にナビゲーション用のテンプレートが表示できるようになった、と言ってもテンプレートに何もセットしていないため
起動しても以下の様なただ黒い画面が表示されるだけです。
スクリーンショット 2020-12-17 11.07.42.png

なので簡単に地図を表示してみます。
前項で CarPlay 対応には基本的に ViewController は出てきません、と書いたのですが例外があります。
ナビーゲーションアプリの開発に使われる CPMapTemplate は地図表示部分に関してのみ ViewController を使うことになります。
具体的には CPWindowrootViewController プロパティに地図表示用の ViewController をセットすると
地図が表示されるようになります。
(MapViewController 自体のコードは非常に単純なものなので割愛)

CarPlaySceneDelegate.Swift
    func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene, 
                                  didConnect interfaceController: CPInterfaceController, 
                                  to window: CPWindow) {
        self.interfaceController = interfaceController
        self.window = window

        let mapTemplate = CPMapTemplate()
        interfaceController.setRootTemplate(mapTemplate, animated: true) { _,_ in
           // MapViewController は内部的に MKMapView を保持した ViewController
           window.rootViewController = MapViewController()
        }
    }

すると以下のように Apple の地図が表示されるようになります。
carplaysample.gif

よくみると上に黒いバーがあるのがわかると思うのですが、ここにボタンを置いて行って様々なインタラクションを行えるようにしていくことになります。
地図のパンニングやルート表示についてをここで説明すると長くなりすぎるため、興味があるようでしたら
参考文献のGuideとサンプルコードを参照することをお勧めします

実装する上での留意点

以上が CarPlay のナビゲーションアプリで地図を表示するまでの実装になります。
様々なテンプレートが用意されているため実装量がすくなく結構簡単に作れることがわかったと思います。
(それまでの Apple とのやりとりの方がコストが高い)

ですが、実装していく上で何点か留意すべき点があったため記述します。

タップ範囲が決められてる

CPMapTemplate はほかのテンプレートと違い ViewController を表示できるのでデザインの自由度は高いように思えます。
(実際、地図以外も表示させることは可能です)
ですが、実は ViewController にタップイベントを伝搬させることはできません。
CPMapTemplate におかれたボタン経由でしかイベントを伝搬させることしかできず、地図を直接スワイプさせたりすることはできません。
そして CPMapTemplate のみならず他のテンプレートもボタンの表示数、領域等に制限があります。
結果的にそれらの条件を考慮する必要があり、自由度の高いデザインができず、どのアプリも UI は似たものになります。

カスタムテンプレートを作れない

CPMapTemplate の基底クラスである CPTemplate は他のテンプレートクラスでも使われており画面遷移機能を持たないので
UIView に近い印象を受けるのですが開発者側のほうで継承させてカスタムテンプレートを作る、ということはできません。

Swift の言語仕様上、継承自体はできるのですが CPInterfaceController を使って画面に表示させようとすると
以下のエラーを吐いてクラッシュします

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Unsupported object <View.Test: 0x60000047e920> <identifier: F6B164D2-E640-4644-8C06-AAFB57AD7192, userInfo: (null), tabTitle: (null), tabImage: (null), showsTabBadge: 0> passed to setRootTemplate:animated:completion:. Allowed classes: {(
    CPContactTemplate,
    CPPointOfInterestTemplate,
    CPGridTemplate,
    CPMapTemplate,
    CPTabBarTemplate,
    CPInformationTemplate,
    CPListTemplate,
    CPNowPlayingTemplate,
    CPSearchTemplate
)}'

標準の組み込まれたテンプレートでなければいけない、ということらしいです。

カテゴリによっては使えないテンプレートがある

Apple に申請を出した際、CarPlay 向けにどのような機能を提供するかカテゴリを決める必要があることは申請の項目で説明しましたが、
その選んだカテゴリによって表示できる UI 、つまり CPTemplate 系のクラスが決まります。
対応表は以下のようになっています

スクリーンショット 2020-12-18 15.06.06.png

この表をみるとナビゲーションアプリは表示できる UI が多いのですが Now Playing 用と Point of Interest 用のテンプレートである
CPNowPlayingTemplateCPPointOfInterestTemplate が表示できないことになっています。
実際、表示しようとすると

Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Unsupported object

というエラーを吐いて落ちます。

テンプレートによっては画面遷移の仕方に制限がある(し、公式ドキュメントにずれがある)

基本的に CPInterfaceControllerpresentTemplate 及び pushTemplate というメソッドを使って画面遷移をさせていくのですが
presentTemplate に関しては

CarPlay can only present one modal template at a time. templateToPresent must be one of CPActionSheetTemplate, CPAlertTemplate, or CPVoiceControlTemplate.

と制限があり
pushTemplate に関しては

use pushTemplate with a supported CPTemplate class such as CPGridTemplate, CPListTemplate, CPSearchTemplate, or
CPVoiceControlTemplate.

とあり指定されたクラスしか使えないような記述がされています。
が、実は CPMapTemplatepushTemplate を使って表示させることができたりします。
また、

All apps are limited to pushing up to 5 templates in depth, including the root template. Quick food ordering
apps are limited to 2 templates in depth.

とあり、pushTemplate を使って画面遷移したとしても5階層以上いけないようになっている様です。(未検証)

CarPlay 対応アプリを呼び出すには

カーナビ上で操作を完結させるために CarPlay 対応アプリから URL スキーム等つかって他の CarPlay アプリを呼び出したい時があると思います(ex. Calendar, music, etc...)
通常の iOS アプリでは UIApplication.shared.open(url:options:complicationHandler) を使ってたと思うのですが、
CarPlay 上でそれを使うと CarPlay 側ではなにも起きずカーナビに繋いでる iPhone 側の方で別アプリが呼び出されます。

CarPlay 側でアプリ呼び出しをしたい場合は CPTemplateApplicationSceneDelegate のデリゲートメソッドで渡される templateApplicationSceneopen(url:options:complicationHandler) の方をつかう必要があります。

CarPlaySceneDelegate.swift
func templateApplicationScene(_ templateApplicationScene: CPTemplateApplicationScene,
                              didConnect interfaceController: CPInterfaceController, to window: CPWindow) {
    // こちらを使うと iPhone 側でアプリが起動する
    UIApplication.shared.open(URL(string: "なんらかの URL スキーマ")!, options: nil, complicationHandler: nil)]
    // こちらを使うと CarPlay 側のアプリが起動する
    templateApplicationScene.open(URL(string: "なんらかの URL スキーマ")!, options: nil, complicationHandler: nil)
}

個人的には iPhone 側の方でも UIApplication.shared.open を使うのは基本的にやめて UIScene.open を使うべきな気はしています。

まとめ

以上、CarPlay 対応のナビゲーションアプリの作り方と注意点を紹介してみました。
読んでいただいてわかると思うのですがカーナビという安全に直結するアプリケーションの UI であるため
非常に縛りが多く、自由なレイアウトというのはできない設計になります。

とはいえ、主観ではありますが従来の広く販売されているカーナビと比べるとやはりだいぶ使いやすいです。
また、ナビゲーションアプリに関しては ViewController を使って地図を表示してるので
その気になれば動画や音楽を流せる気がしています(未検証)。

Apple の審査に落とされそうな気がしてますが審査に出さず個人でやる分には問題ないはずなので
どこかのタイミングで実験して記事にしたいなと考えています。

参考文献

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

SwiftUIで作るプログレス

はじめに

この記事はDiverse Advent Calendar 2020の18日目の記事です。
業務でいくつかプログレスを作ったので紹介します。
同じようなデザインのプログレスを作る際はぜひご活用ください!

GitHubにこの記事のサンプルを上げてます。
↓実際に動きを見たい方はこちら
https://github.com/Masataka-n/SwiftUIProgress

Ring

struct RingProgressView: View {

    var value: CGFloat
    var lineWidth: CGFloat = 6.0
    var outerRingColor: Color = Color.black.opacity(0.08)
    var innerRingColor: Color = Color.orange

    var body: some View {
        ZStack {
            Circle()
                .stroke(lineWidth: self.lineWidth)
                .foregroundColor(self.outerRingColor)
            Circle()
                .trim(from: 0.0, to: CGFloat(min(self.value, 1.0)))
                .stroke(
                    style: StrokeStyle(
                        lineWidth: self.lineWidth,
                        lineCap: .square, // プログレスの角を丸くしたい場合は.round
                        lineJoin: .round
                    )
                )
                .foregroundColor(self.innerRingColor)
                .rotationEffect(.degrees(-90.0))
        }
        .padding(.all, self.lineWidth / 2)
    }
}
@State var value: CGFloat = 0.0

 var body: some View {
    RingProgressView(value: value)
        .frame(width: 150, height: 150)
        .onAppear {
            withAnimation(.linear(duration: 5)) {
                self.value = 1.0
            }
        }        
}

ezgif.com-gif-maker (2).gif

Pie

Shapeを継承してPieShapeというものを作っています。
Shapeを自作した場合、アニメーションが適用されないので animatableData をoverrideする必要があります。

struct PieProgressView: View {

    var value: CGFloat

    var body: some View {
        ZStack {
            Circle()
                .fill(Color.black.opacity(0.08))
            PieShape(progress: value)
                .fill(Color.orange)
                .rotationEffect(.degrees(-90))
        }
    }
}

struct PieShape: Shape {

    var value: CGFloat

    var animatableData: CGFloat {
        get { value }
        set { value = newValue }
    }

    func path(in rect: CGRect) -> Path {
        Path { path in
            let center = CGPoint(x: rect.midX, y: rect.midY)
            path.move(to: center)
            path.addArc(
                center: center,
                radius: rect.width / 2,
                startAngle: .degrees(0),
                endAngle: .degrees(Double(360 * value)),
                clockwise: false
            )
            path.closeSubpath()
        }
    }
}

@State var value: CGFloat = 0.0

var body: some View {
    PieProgressView(value: value)
        .frame(width: 150, height: 150)
        .onAppear {
            withAnimation(.linear(duration: 5)) {
                self.value = 1.0
            }
        } 
}

ezgif.com-gif-maker.gif

Square

Rectangleを2つ重ねて上のRectangleのwidthをvalueに応じて変えることで実現してます。
Squareと名付けてますがcornerRadiusを指定して角丸にすることもできます。

struct SquareProgressView: View {

    var value: CGFloat
    var baseColor: Color = Color.black.opacity(0.08)
    var progressColor: Color = Color.orange

    var body: some View {
        GeometryReader { geometry in
            VStack(alignment: .trailing) {
                ZStack(alignment: .leading) {
                    Rectangle()
                        .fill(self.baseColor)
                    Rectangle()
                        .fill(Color.progressColor)
                        .frame(minWidth: 0, idealWidth:self.getProgressBarWidth(geometry: geometry),
                               maxWidth: self.getProgressBarWidth(geometry: geometry))
                }
            }
        }
    }

    func getProgressBarWidth(geometry:GeometryProxy) -> CGFloat {
        let frame = geometry.frame(in: .global)
        return frame.size.width * value
    }
}
@State var value: CGFloat = 0.0

var body: some View {

    SquareProgressView(value: value)
        .frame(height: 20)
        //.cornerRadius(10) 角丸も可
        .onAppear {
            withAnimation(.linear(duration: 5)) {
                self.value = 1.0
            }
        } 
}

ezgif.com-gif-maker (3).gif

さいごに

今回は作成したプログレスを3つ紹介しました。
基本的にコピペで使えると思いますのでぜひご活用ください。

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

[Swift5]'PKHUD'を用いた待機画面の実装方法

PKHUDとは

PKHUDとはHUD(Head-Up Display)を表示するためのライブラリです。
HUDを使用して、何かしらの処理が実行中であることや、完了したことをユーザーに伝えることができる。

PKHUDはCocoaPods、Carthageのいずれかを使用してインストールできる。
この記事ではCocoaPodsを用いて紹介します。

ライブラリのインストール

//Podfileに以下のpodを記述して'pod install'をターミナルで実行
pod 'PKHUD'

//PKHUDを使いたいファイルにimport
import PKHUD

コードの紹介

==================================================================
①基本的な使い方
//ローディングの表示
HUD.show(.progress)

//ローディングの非表示
HUD.hide(animation: true)
==================================================================

==================================================================
②成功/失敗の表示
//成功を表示
HUD.flash(.success)

//失敗の表示
HUD.flash(.error)
==================================================================

==================================================================
③ローディング・成功・失敗の表示(テキストあり)
//テキストを指定してローディングの表示
HUD.show(.labelProgress(title: "ここにテキストを記述", subtitle: nil))

//テキストを指定して成功を表示
HUD.flash(.labeledSuccess(title: "Success", subtitle: nil))

//テキストを指定して失敗を表示
HUD.flash(.labeledError(title: "Error", subtitle: nil))
==================================================================

==================================================================
④テキストのみで表示
HUD.show(.label("ここにテキストを記述"))
==================================================================

マスクの無効化

デフォルト設定では、HUDの背景ビューは暗くなり、ユーザーはその間アプリの操作ができません。
しかし、ユーザーが操作できるようにしておきたい状況もあると思います。そういった場合の実装方法も消化します。

//HUDの背景ビューを操作できるようにする
HUD.allowsInteraction = true

//HUDの背景ビューを暗くしない
HUD.dimsBackground = false

参考にしてください!

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

モバイルチームの成長とKMM導入に向けて

はじめに

株式会社RevCommでモバイルアプリを担当している木下です。
この記事は 2020年のRevCommアドベントカレンダー 19日目の記事になります。18日目は @zomaphone さんの 【既存の解析システムに対して pytest-mock と pydantic を活用してクイックに総合テストを実装した話】 でした。

MiiTel Phone Mobile アプリについて

開発している MiiTel Phone Mobile アプリの主な機能はVoIPで、RevComm のメインプロダクトである MiiTel のオプションとしてご利用いただけます。当初は Cordova で開発されたβ版アプリが無料で提供されていましたが、アプリを起動していないと着信が受けられないなどの不都合があり、これらのユーザー体験を改善するためにネイティブ(Swift/Kotlin)で開発し直したものが現行のアプリとなります。

モバイルチームについて

モバイルアプリの開発を担うモバルチームの変遷を簡単に紹介します。

1. 一人チーム時代

約2年前にRevCommにジョインしてから、iOSとAndroidの両アプリを開発・リリースし、サポート対応や機能改善、OSアップデート対応などをほぼ一人でやってきました。アプリの実装だけでなく、モバイルアプリのための 通信サーバー の設定やサーバーアプリの改修なども対応してきました。

2. 新メンバー参入

今年の10月ついにモバイルチームに、新しいメンバーが2名加わりました。ともにiOSアプリとAndroidアプリの開発経験があり、どちらも安心して任せられるメンバーです。
私としては フルリモート・フルフレックスタイム制を導入しているRevComm で社員を迎え入れるのが初めてでした。組織として急成長してきたことや、リモートワークを推奨していることにより、コミュニケーションの不足や取りづらさの課題を以前から感じていたため、この点を特に留意し、新しいメンバーが発言・活動しやすい環境にしてプロダクトの成長に貢献できるようなチーム作りを目指しました。具体的な内容については、また機会があれば紹介したいと思います。

3. そしてさらなる成長に向けて

新たなメンバーが加わってチームとして機能するようになり、開発のスピードも上がってきました。これまでのワンオペでは機能を追加するとしても、iOSアプリの実装とリリースをしてから、Androidも同様の対応をするというフローとなり、リリースのタイミングがズレることがほとんどでしたが、メンバーが増えたことで両アプリ同時に新機能をリリースするといったことも可能になってきました。
そして、これからさらにスピードを上げるために導入を検討しているのが、マルチプラットフォーム開発です。
一つの機能を追加するのに、SwiftとKotlinで同様のロジックを実装しなくてはいけない、というのは非効率であると感じており、これを解消するためにもマルチプラットフォーム開発を導入したいと考えています。
React Native、Flutter、KMM(Kotlin Multiplatform Mobile) などありますが、既存のコードを生かしつつ、徐々に共通化が進められそう、かつ言語的に従来のモバイルエンジニアでもスムーズに開発できそうな、KMM が有力であると考えています。私自身 前職 ではAndroidアプリをメインでやっていたこともあり、KotlinでiOSアプリも開発できたら嬉しいなと考えていたところで、 Kotlin Multiplatform Mobile がアルファ段階に移行 というニュースを目にしたのをきっかけに、導入意欲が高まりました。

KMM(Kotlin Multiplatform Mobile) 入門

以前から Kotlin/Native という形でマルチプラットフォーム向けの開発環境は提供されていましたが、KMM ではiOSとAndroidのモバイルアプリに特化し Android Studio などの IDEに統合可能な環境となっており、より簡単に扱えるようになったと認識しています。
個人的にFlutterは少し触っている(Widgetの充実具合や高速な Hot reload は素晴らしいですよね)のですが、KMM は未経験でしたのでこの機会(Advent Calendar)に少し触ってみようと思い、 KMM Shared Module を作成して、AndroidアプリとiOSアプリのそれぞれでインポートして動作させる、ことをやってみようと思います。

1. 開発環境

公式サイト にも記載されていますが、次のような環境が必要となります。

  • Android Studio 4.1 以降
  • Xcode 11.3 以降
  • (AS) Kotlin plugin 1.4.20 以降
  • (AS) Kotlin Multiplatform Mobile plugin
  • JDK

2. Androidプロジェクト作成

KotlinNativeAndroid というプロジェクトを作成しました。
AndPrg.png

3. KMM Shared Module 作成

KotlinNativeAndroid を開いた状態でメニューの File > New > New Module... を選択し KMM Shared Module を選択してから Next をクリックします。
kmm-module.png

次の画面で Generate packFoxXcode Gradle task をチェックしてから Finish をクリックします。(他はデフォルトのままとしました)
kmm-module2.png

次のようなファイルが作成されました。
kmm-module3.png

以下のような内容となっており、OSのバージョンを含む文字列を返すメソッドがサンプルとして作成されていることが分かります。

commonMain/.../Greeting.kt
package com.example.kmmsharedmodule

class Greeting {
    fun greeting(): String {
        return "Hello, ${Platform().platform}!"
    }
}
commonMain/.../Platform.kt
package com.example.kmmsharedmodule

expect class Platform() {
    val platform: String
}
androidMain/.../Platform.kt
package com.example.kmmsharedmodule

actual class Platform actual constructor() {
    actual val platform: String = "Android ${android.os.Build.VERSION.SDK_INT}"
}
iosMain/.../Platform.kt
package com.example.kmmsharedmodule

import platform.UIKit.UIDevice

actual class Platform actual constructor() {
    actual val platform: String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersion
}

KotlinNativeAndroid プロジェクト内のファイルも自動で変更されます。

settings.gradle
include ':kmmsharedmodule' <- 追加
gradle.properties
kotlin.mpp.enableGranularSourceSetsMetadata=true <- 追加
kotlin.native.enableDependencyPropagation=false <- 追加

4. Androidプロジェクト修正

app/build.gradle
dependencies {
    implementation project(':kmmsharedmodule') <- 追加
}

KMM Shared Module から文字列を取得して、画面に表示する処理を実装します。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val binding = ActivityMainBinding.inflate(layoutInflater)
        binding.textView.text = Greeting().greeting()
        setContentView(binding.root)
    }
}

ここで実行してみると次のようなエラーが発生しました。

Manifest merger failed : uses-sdk:minSdkVersion 23 cannot be smaller than version 24 declared in library [:kmmsharedmodule] /Users/tkinoshita/kotlin-native-example/KotlinNativeAndroid/kmmsharedmodule/build/intermediates/library_manifest/debug/AndroidManifest.xml as the library might be using APIs not available in 23
    Suggestion: use a compatible library with a minSdk of at most 23,
        or increase this project's minSdk version to at least 24,
        or use tools:overrideLibrary="com.example.kmmsharedmodule" to force usage (may lead to runtime failures)

Moduleの minSdkVersion がアプリよりも高いことが原因のようですので、Module の minSdkVersion をデフォルト値の 24 から、アプリの設定と同じ 23 に変更します。また compileSdkVersion と targetSdkVersion もアプリに合わせました。

build.gradle.kts
android {
    compileSdkVersion(30)
    defaultConfig {
        minSdkVersion(23)
        targetSdkVersion(30)
    }

5. Androidアプリ実行結果

正常に実行されると次のような表示となります。

6. Framework 出力

続いてiOSアプリでもModuleを利用していきますが、iOSアプリ(Xcode)で利用するためには、Framework 形式でライブラリを出力する必要があります。
build.gradle.kts の下の方に Framework を出力するためのタスクが記述されているので、これを利用します。
Terminal で次のコマンドを実行します。

./gradlew :kmmsharedmodule:build

成功すると、 kmmsharedmodule/build/xcode-frameworks 内に kmmsharedmodule.framework フォルダが作成され Framework が出力されます。

7. Xcodeプロジェクト作成

次に iOSアプリのプロジェクトを作成していきます。
Xcodeで KotlinNativeiOS という名前の App プロジェクトを新規作成します。
iosPrg.png

8. Xcodeプロジェクト修正

Build SettingsFramework Search Paths に Framework のパスを追加します。ここでは $(SRCROOT)/../KotlinNativeAndroid/kmmsharedmodule/build/xcode-frameworks を設定しています。
スクリーンショット 2020-12-18 6.26.36.png

アプリに Framework を組み込むための設定を追加します。
スクリーンショット 2020-12-18 6.30.17.png

KMM Shared Module から文字列を取得して、画面に表示する処理を実装します。

ViewController.swift
import UIKit
import kmmsharedmodule

class ViewController: UIViewController {

    @IBOutlet weak var textLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()

        textLabel.text = Greeting().greeting()
    }
}

9. iOSアプリ実行結果

正常に実行されると次のような表示となります。

10. ソースコード一式

こちらのリポジトリ で公開しています。

おわりに

KMM Shared Moduleの作成から AndroidアプリとiOSアプリで利用する流れが把握でき、おおよその感触がつかめました。どこまでロジックが共通化できるかはまだ未知ですが、期待していたとおり簡単にモジュールの作成と利用ができましたので、これから積極的に採用していきたいと思います。

明日はモバイルバックエンド担当の @rhoboro さんです!!
実は @rhoboro さんもモバイルチームの一員としてバックエンドの開発を担当されています!

参考

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

iOSでmethod swizzlingして、共通の処理を実行する

はじめに

  • iOSで共通の処理を実装するときにいろいろなパターンを考えてみましたのでまとめます。今回はmethod swizzlingを中心に考えてみます。

環境

Xcode 12.3
Build version 12C33
Apple Swift version 5.3.2 (swiftlang-1200.0.45 clang-1200.0.32.28)
Target: x86_64-apple-darwin19.6.0

method swizzlingとは

  • method swizzlingはすでに存在するメソッドの処理を入れ替える機能です
  • Objective-C の全てのクラスに対して使用できる。
  • 共通の処理を継承せずに実装できる

クラスでの継承との違い

  • クラスでの継承とmethod swizzlingでの実装の違いについてまとめました。
⭕️
method swizzling 入れ替えたクラスに処理が反映される 影響範囲が見えづらいので不具合が起きた時に対処しずらい
クラスの継承 継承したクラスのみで処理が実行されるのでコントロールしやすい。 実装が必要なクラスをすべて書き換える必要があるので
  • 実装方法には一長一短あることがわかります。使い所よく考える必要があります。

活用例

kickstater

画面のトラッキング

  • viewWillAppearの実装を入れ替えて、Firebaseなどを使えば、画面表示のイベントを送る処理が楽に実装できます
import ObjectiveC
import UIKit

private func swizzle(_ vc: UIViewController.Type) {
  [
    (#selector(vc.viewWillAppear(_:)), #selector(vc.hoge_viewWillAppear(_:)))
  ]
  .forEach { original, swizzled in

    guard let originalMethod = class_getInstanceMethod(vc, original),
      let swizzledMethod = class_getInstanceMethod(vc, swizzled) else { return }

    let didAddViewDidLoadMethod = class_addMethod(
      vc,
      original,
      method_getImplementation(swizzledMethod),
      method_getTypeEncoding(swizzledMethod)
    )

    if didAddViewDidLoadMethod {
      class_replaceMethod(
        vc,
        swizzled,
        method_getImplementation(originalMethod),
        method_getTypeEncoding(originalMethod)
      )
    } else {
      method_exchangeImplementations(originalMethod, swizzledMethod)
    }
  }
}

private var hasSwizzled = false

extension UIViewController {
  public final class func doBadSwizzleStuff() {
    guard !hasSwizzled else { return }

    hasSwizzled = true
    swizzle(self)
  }

  @objc internal func hoge_viewWillAppear(_ animated: Bool) {
    self.hoge_viewWillAppear(animated)
    let instanceType = type(of: self)
    let name = String(reflecting: instanceType)
    print(name)
  }
}

まとめ

  • method swizzlingは便利ではあるが使い方を見極めて、使うことが必要

参考リンク

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

iOSでmethod swizzling使って、共通の処理を実装する

はじめに

  • iOSで共通の処理を実装するときにいろいろなパターンを考えてみましたのでまとめます。今回はmethod swizzlingを中心に考えてみます。

環境

Xcode 12.3
Build version 12C33
Apple Swift version 5.3.2 (swiftlang-1200.0.45 clang-1200.0.32.28)
Target: x86_64-apple-darwin19.6.0

method swizzlingとは

  • method swizzlingはすでに存在するメソッドの処理を入れ替える機能です
  • Objective-C の全てのクラスに対して使用できる。
  • 共通の処理を継承せずに実装できる

クラスでの継承との違い

  • クラスでの継承とmethod swizzlingでの実装の違いについてまとめました。
⭕️
method swizzling 入れ替えたクラスに処理が反映される 影響範囲が見えづらいので不具合が起きた時に対処しずらい
クラスの継承 継承したクラスのみで処理が実行されるのでコントロールしやすい。 実装が必要なクラスをすべて書き換える必要があるので
  • 実装方法には一長一短あることがわかります。使い所よく考える必要があります。

活用例

kickstater

画面のトラッキング

  • viewWillAppearの実装を入れ替えて、Firebaseなどを使えば、画面表示のイベントを送る処理が楽に実装できます
import ObjectiveC
import UIKit

private func swizzle(_ vc: UIViewController.Type) {
  [
    (#selector(vc.viewWillAppear(_:)), #selector(vc.hoge_viewWillAppear(_:)))
  ]
  .forEach { original, swizzled in

    guard let originalMethod = class_getInstanceMethod(vc, original),
      let swizzledMethod = class_getInstanceMethod(vc, swizzled) else { return }

    let didAddViewDidLoadMethod = class_addMethod(
      vc,
      original,
      method_getImplementation(swizzledMethod),
      method_getTypeEncoding(swizzledMethod)
    )

    if didAddViewDidLoadMethod {
      class_replaceMethod(
        vc,
        swizzled,
        method_getImplementation(originalMethod),
        method_getTypeEncoding(originalMethod)
      )
    } else {
      method_exchangeImplementations(originalMethod, swizzledMethod)
    }
  }
}

private var hasSwizzled = false

extension UIViewController {
  public final class func doBadSwizzleStuff() {
    guard !hasSwizzled else { return }

    hasSwizzled = true
    swizzle(self)
  }

  @objc internal func hoge_viewWillAppear(_ animated: Bool) {
    self.hoge_viewWillAppear(animated)
    let instanceType = type(of: self)
    let name = String(reflecting: instanceType)
    print(name)
  }
}

まとめ

  • method swizzlingは便利ではあるが使い方を見極めて、使うことが必要

参考リンク

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

中身をくり抜いた図形をMapKitの地図に描画する~MKPolygonを利用~

はじめに

MapKitでは地図上の好きな位置に図形を描画することができます。
これにより、特定のエリアに色をつけて目立たせたりすることができます。
今回は、エリアの一部をくり抜いた図形を表示したかったのでその方法を調べました。

完成図

result.png

環境

Xcode12.2
iPhone12シミュレータにて動作確認

実装

今回は埼玉県あたりを覆う大きな矩形と、その中に包含される大宮あたりの三角形の小さな図形を用意します。

まず、図形を緯度経度で指定しMKPolygonを作成します。
矩形のほうが大きく、埼玉県全体を覆うもので、三角形はその中に包含されます。

最後に、作成したMKPolygonをMKMapViewに追加しています。

let mapView = MKMapView()

let omiyaArea = [
    CLLocationCoordinate2D(latitude: 35.9243073, longitude: 139.4899383),
    CLLocationCoordinate2D(latitude: 35.9061086, longitude: 139.6257497),
    CLLocationCoordinate2D(latitude: 35.9800549, longitude: 139.5930384)
]
let smallPolygon = MKPolygon(coordinates: omiyaArea, count: omiyaArea.count)

let saitamaArea = [
    CLLocationCoordinate2D(latitude: 35.7926364, longitude: 139.9048961),
    CLLocationCoordinate2D(latitude: 36.2841001, longitude: 139.8764339),
    CLLocationCoordinate2D(latitude: 36.2977199, longitude: 138.6847137),
    CLLocationCoordinate2D(latitude: 35.8108062, longitude: 138.6762004)
]
let saitamaAreaPolygon = MKPolygon(coordinates: saitamaArea, count: saitamaArea.count, interiorPolygons: [smallPolygon])
// 地図に追加
mapView.addOverlay(saitamaAreaPolygon)

ポイントはsaitamaAreaPolygonのMKPolygonのinit処理です。interiorPolygonsにsmallPolygonを配列で指定しています。
くり抜きたい図形をinteriorPolygonsに指定することで三角形部分のみ描画が行われないようになりました。
init(coordinates:count:interiorPolygons:)

色の指定

addOverlayのみでは描画は行われないのでデリゲートメソッドを実装する必要があります。

// 別の箇所にて設定しておく
mapView.delegate = self

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
    if overlay is MKPolygon {
        let renderer = MKPolygonRenderer(overlay: overlay)
        renderer.fillColor = .red
        renderer.alpha = 0.5
        return renderer
    }
    return MKOverlayRenderer(overlay: overlay)
}

mapView(_:rendererFor:)

発展

くり抜いた図形の中の一部は色を描画したい場合はどうすればいいでしょうか。試してみました。

結果

三角形でくり抜いた中にさらに三角形が描画できました。
result2.png

実装

// くり抜いた三角形に包含されるエリア
let area = [
    CLLocationCoordinate2D(latitude: 35.9543861, longitude: 139.5727745),
    CLLocationCoordinate2D(latitude: 35.9562351, longitude: 139.582018),
    CLLocationCoordinate2D(latitude: 35.9492669, longitude: 139.5809517)
]
let areaPolygon = MKPolygon(coordinates: area, count: area.count)

let omiyaArea = [
    CLLocationCoordinate2D(latitude: 35.9243073, longitude: 139.4899383),
    CLLocationCoordinate2D(latitude: 35.9061086, longitude: 139.6257497),
    CLLocationCoordinate2D(latitude: 35.9800549, longitude: 139.5930384)
]
let smallPolygon = MKPolygon(coordinates: omiyaArea, count: omiyaArea.count)

let saitamaArea = [
    CLLocationCoordinate2D(latitude: 35.7926364, longitude: 139.9048961),
    CLLocationCoordinate2D(latitude: 36.2841001, longitude: 139.8764339),
    CLLocationCoordinate2D(latitude: 36.2977199, longitude: 138.6847137),
    CLLocationCoordinate2D(latitude: 35.8108062, longitude: 138.6762004)
]
// interiorPolygonsにareaPolygonも追加
let saitamaAreaPolygon = MKPolygon(coordinates: saitamaArea, count: saitamaArea.count, interiorPolygons: [smallPolygon, areaPolygon])

mapView.addOverlay(saitamaAreaPolygon)

interiorPolygonsに色を描画したい領域を追加することで実現できました。

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

【iOS】CIFilterを駆使して、ネガフィルムの写真をネガポジ変換する

まだまだ続いているフィルムカメラブーム。デジタルカメラと違い、現像後のデータ化費用が嵩んだり、撮影後のネガフィルムも整理が間に合わず溜まっていくことが多かったり...
専用の機械なども多くありますが、お手軽にiPhoneでなんとか...と考える人は多いはず。

実際AppStoreにはいくつかのフィルムスキャナーアプリがリリースされています。
有名どころだとKodak Mobile Film Scannerなどがあります。

ただ、これらのアプリってどのようにiOS上でネガポジ変換を実現してるのかと聞かれると、開発を行っている人が少なく実現方法が分かりません。
なので、今回は同様のネガポジ変換を実現してみたいと思います。

陰画から陽画を取得する

ネガフィルムは、撮影時の被写体の色が反転して画像が作られます。
白い被写体であれば、ネガフィルム上には真っ黒の被写体となります。
これをネガティブ画像と呼びます。
ネガティブ画像をプリント、データ化するときに色の再反転を行い、撮影時の被写体の色を復元します。
ここで得られる画像をポジティブ画像と呼びます。

昔は、データ化などなくフィルムから印画紙にプリントしていました。印画紙にもネガフィルム同様色を反転する感光剤が塗布されているので、ネガフィルムに写っているコマを引き伸ばし機などを用いて印画紙に投影し、印画紙にネガティブ画像を感光させることで色を再反転し、撮影時の被写体の色を復元していました。
デジタル化では、ネガティブ画像を画像処理で色の再反転を行うことで撮影時の被写体の色を復元するということを行っているだけになります。

シンプルに考えると、色の再反転を行うことでネガフィルムからポジティブ画像を取得することができるようになるということです。
negaposi.png

トーンカーブを反転させる

ネガティブ状態の画像からポジティブ画像を取得するには、色の再反転を行う必要ですが、トーンカーブのを上下反転させることで実現することが可能です。

Swiftでは、CIFilterに用意されたToneCurveFilterを利用します。

CIFilterとは?

CoreImageFrameworkで提供される画像処理フィルターがCIFilterです。
CoreImageFrameworkもiOS5から提供されている歴史あるFrameworkで、現在では100種類以上のフィルターが提供されています。
CIFilterを駆使することができるようになると、リッチな画像編集アプリなどを作ることが可能です。

CIFilterの種類は、こちらのドキュメントで確認できます。是非試してみてください。
Core Image Filter Reference

ToneCurveFilterを利用する

サンプルコードは下記のようになります。

class ToneReverse {

    func demo() {
        guard
            // AssetsCatalogに「nega」という名称のネガ画像がある想定で読み込む
            let negaImage = UIImage(named: "nege"),
            // CIFilterで処理するため、UIImageからCIImageへ変換する
            let ciNegaImage = CIImage(image: negaImage),
            // toneCurve反転を行うメソッドを介して反転後の画像を取得する
            let toneReversedImage = reverse(with: ciNegaImage) else {
            return
        }
        // CIImageからUIImageへ再変換
        let posiImage = UIImage(ciImage: toneReversedImage)
    }


    /// Tone Reverse
    /// - Parameter image: base image
    /// - Returns: filtered image
    func reverse(with image: CIImage) -> CIImage? {
        return image.applyingFilter(
            "CIToneCurve",
            parameters: [
                "inputPoint0": CIVector(x: 0.0, y: 1.0),
                "inputPoint1": CIVector(x: 0.25, y: 0.75),
                "inputPoint2": CIVector(x: 0.5, y: 0.5),
                "inputPoint3": CIVector(x: 0.75, y: 0.25),
                "inputPoint4": CIVector(x: 1.0, y: 0.0)
            ])
    }

}

CIFilterにはinputPoint0からinputPoint4までのToneCurveの座標を設定することができます。
何も触らない状態のトーンカーブは、下記の画像のようになります。

ToneCurveで色反転を行うにはToneCurveの上下反転すれば良いので、Point0からPoint4までのY座標を反転させることで実現できます。

上記図のトーンカーブをCIFilterで行うと下記のような実装になります。

/// Tone Reverse
/// - Parameter image: base image
/// - Returns: filtered image
func reverse(with image: CIImage) -> CIImage? {
    return image.applyingFilter(
        "CIToneCurve",
        parameters: [
            "inputPoint0": CIVector(x: 0.0, y: 1.0),
            "inputPoint1": CIVector(x: 0.25, y: 0.75),
            "inputPoint2": CIVector(x: 0.5, y: 0.5),
            "inputPoint3": CIVector(x: 0.75, y: 0.25),
            "inputPoint4": CIVector(x: 1.0, y: 0.0)
        ])
}

これでネガティブ画像からポジティブ画像を取得することが可能です。
ただ、ポジティブ画像になってはいるものの青味が強すぎてしまい撮影時の被写体の色を再現できていません。
被写体の色を再現するにはここからさらに色調補正を行う必要があります。


※Debug用にカメラ入力をトーン反転のみフィルター適用してライブ表示した場合が上記みたいになります。

色調補正を行う

一応ポジティブ画像を取得することはできましたが、このままでは見るに耐えられません。
なので色調補正を行うのですが、CoreImageに用意されているToneCurveFilterはRGB値の合成チャンネルでしかToneCurveを弄ることしか出来ません。
被写体の色を再現するにはRed、Green、Blueの各チャンネルごとの色調量を調整してバランスを保つ必要があります。今回で言うと青みが強いので、青の色調量を下げて相対的なバランスを作っていくことが必要です。

ただ、前述の通りCIFilterに用意されたToneCurveは合成チャンネルでしか触ることは出来ません。
ではどうするか...
CIKernelを用いてカスタムフィルターを作ります。
現在CIKernelを利用する際は、Metalを利用します。

Metalとは

Metalは、2014年に登場したAppleプラットフォーム向けのグラフィックAPIです。
記述言語は、C++11ベースのMetal Shading Languageで記述します。

Metal利用時の設定

Metalを利用する際には、Build SettingsのOther Metal Compiler FlagsOther Metal Linker Flags-fcikernelの指定が必要です。

ここで落とし穴ですが、下記のAppleの公式ドキュメントでは、MELLINKER_FLAGSをuser-definedに追加し-cikernelを設定しろという記述がありますがXcode12.3の環境下ではWarningが表示されます。上記の設定通りにすることが求められるので注意が必要です。
Apple - Type Method kernelWithFunctionName:fromMetalLibraryData:error:

Metal側の実装

今回行いたいことは、RGBごとのチャンネルをToneCurveで弄り、各チャンネルの結果を合成して1枚の画像として取得することです。
ToneCurveの操作自体は、CIFliterで各チャンネル向けにCIFilterで行い、得られたCIImageからR、G、Bの各要素だけを抽出し、各要素を掛け合わせた1枚の画像を生成することでRGBチャンネルごとにいじれるToneCurve機能を実現します。
実現にあたり、ページを参考にさせていただきました。
HSL color adjustment filter in an iOS 8.0+ app using CoreImage

#include <metal_stdlib>
using namespace metal;

#include <CoreImage/CoreImage.h>
extern "C" {
    namespace coreimage {
        float4 rgbChannelCompositing(sample_t red, sample_t green, sample_t blue) {
            return float4(red.r, green.g, blue.b, 1.0);
        }
    }
}

上記の実装は、任意の名前 + .metalの名称のファイルに記述しておきます。

Swift側の実装

先に全体のコードです。
ToneCurvePointModelのXYに入っている値は、デモ用に調整した値です。
微調整することで寒暖色などに調整することが可能です。

class RgbCompositing {

    private let redToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0,
                                                        oneX: 0.38, oneY: 0.07,
                                                        twoX: 0.63, twoY: 0.24,
                                                        threeX: 0.78, threeY: 0.49,
                                                        fourX: 1.0, fourY: 1.0)

    private let greenToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0,
                                                          oneX: 0.67, oneY: 0.07,
                                                          twoX: 0.85, twoY: 0.19,
                                                          threeX: 0.92, threeY: 0.39,
                                                          fourX: 1.0, fourY: 0.90)

    private let blueToneCurveModel = ToneCurvePointModel(zeroX: 0, zeroY: 0,
                                                         oneX: 0.69, oneY: 0.06,
                                                         twoX: 0.90, twoY: 0.20,
                                                         threeX: 0.96, threeY: 0.44,
                                                         fourX: 1, fourY: 0.92)


    func rgbCompositing(with image: CIImage) -> CIImage? {
        let redImage = image.applyingFilter(
            "CIToneCurve",
            parameters: [
                "inputPoint0": redToneCurveModel.pointZero,
                "inputPoint1": redToneCurveModel.pointOne,
                "inputPoint2": redToneCurveModel.pointTwo,
                "inputPoint3": redToneCurveModel.pointThree,
                "inputPoint4": redToneCurveModel.pointFour
            ])
        let greenImage = image.applyingFilter(
            "CIToneCurve",
            parameters: [
                "inputPoint0": greenToneCurveModel.pointZero,
                "inputPoint1": greenToneCurveModel.pointOne,
                "inputPoint2": greenToneCurveModel.pointTwo,
                "inputPoint3": greenToneCurveModel.pointThree,
                "inputPoint4": greenToneCurveModel.pointFour
            ])
        let blueImage = image.applyingFilter(
            "CIToneCurve",
            parameters: [
                "inputPoint0": blueToneCurveModel.pointZero,
                "inputPoint1": blueToneCurveModel.pointOne,
                "inputPoint2": blueToneCurveModel.pointTwo,
                "inputPoint3": blueToneCurveModel.pointThree,
                "inputPoint4": blueToneCurveModel.pointFour
            ])

        guard
            let url = Bundle.main.url(forResource: "default", withExtension: "metallib"),
            let data = try? Data(contentsOf: url),
            let rgbChannelCompositingKernel = try? CIColorKernel(functionName: "rgbChannelCompositing", fromMetalLibraryData: data) else {
            return nil
        }
        let extent = redImage.extent.union(greenImage.extent.union(blueImage.extent))
        let arguments = [redImage, greenImage, blueImage]

        guard let ciImage = rgbChannelCompositingKernel.apply(extent: extent, arguments: arguments) else {
            return nil
        }
        return ciImage
    }

}

少しづつ分解してみていきましょう。

func rgbCompositing(with image: CIImage) -> CIImage? {
    let redImage = image.applyingFilter(
        "CIToneCurve",
        parameters: [
            "inputPoint0": redToneCurveModel.pointZero,
            "inputPoint1": redToneCurveModel.pointOne,
            "inputPoint2": redToneCurveModel.pointTwo,
            "inputPoint3": redToneCurveModel.pointThree,
            "inputPoint4": redToneCurveModel.pointFour
        ])
    let greenImage = image.applyingFilter(
        "CIToneCurve",
        parameters: [
            "inputPoint0": greenToneCurveModel.pointZero,
            "inputPoint1": greenToneCurveModel.pointOne,
            "inputPoint2": greenToneCurveModel.pointTwo,
            "inputPoint3": greenToneCurveModel.pointThree,
            "inputPoint4": greenToneCurveModel.pointFour
        ])
    let blueImage = image.applyingFilter(
        "CIToneCurve",
        parameters: [
            "inputPoint0": blueToneCurveModel.pointZero,
            "inputPoint1": blueToneCurveModel.pointOne,
            "inputPoint2": blueToneCurveModel.pointTwo,
            "inputPoint3": blueToneCurveModel.pointThree,
            "inputPoint4": blueToneCurveModel.pointFour
        ])

上記の部分では、受け取ったCIImageに対してRed、Green、Blueの各チャンネルの弄りたいToneCurveの値を適応しています。
この時点では、redImageなどに入っているCIImageは、合成チャンネルでトーンカーブを弄った画像になるので理想の色調補正はまだ行われていません。

        guard
            let url = Bundle.main.url(forResource: "default", withExtension: "metallib"),
            let data = try? Data(contentsOf: url),
            let rgbChannelCompositingKernel = try? CIColorKernel(functionName: "rgbChannelCompositing", fromMetalLibraryData: data) else {
            return nil
        }

上記部分では、CIColorKernelを生成しています。
Metalファイルは、コンパイル後metallib形式でrootディレクトリ直下に生成されます。特段設定しなければリソース名はdefaultで書き出される模様です。
Bundle.main.url(forResource:, withExtension:)で、default.metallibのパスを取得し、Data(contentsOf:)でData型としてファイルを取得しています。
最後にCIColorKernel(functionName:, fromMetalLibraryData:)に対してMetalファイルで定義した画像処理メソッドのメソッド名と取得したdefault.metallibを引数で渡すと、Metal側で実装したrgbChannelCompositingが利用可能になります。

        let extent = redImage.extent.union(greenImage.extent.union(blueImage.extent))
        let arguments = [redImage, greenImage, blueImage]

        guard let ciImage = rgbChannelCompositingKernel.apply(extent: extent, arguments: arguments) else {
            return nil
        }
        return ciImage
    }

続いてCIKernelを用いて、新しい画像を取得します。
extentは画像の出力範囲です。unionを利用して3つの画像を含むサイズを取得します。今回で言うと元画像が同一であるため基本サイズがずれることはないはずです。
argumentsは、CIKernelに対して渡す元画像の配列です。並び順はrgbです。
extentargumentsを引数にし、apply(extent:arguments :)を実行すると、RGB各チャンネルのToneCurveを調整したCIImageを取得することができます。

成果物

元画像

フィルター適用後

こんな感じの画像にネガポジ変換して、色調補正をかけた画像を取得することができます。

Framework化しました

一連の処理を整理してFramework化までしてます。
サンプルコードも一緒にしてあるので気になる方は下記からどうぞ。
(Cocoapods、Carthage対応は追って行います。)
NegaDeveloping - Masami Yamate

最後に

探せば世界のどこかでやってる人はいてOSSのFrameworkがすでにあるかもしれませんが、自分で仕組みを辿ってみると楽しいですね。
個人的に使うアプリとして開発していますが、冬休みの課題で間に合えばApp Storeでも現像アプリとして公開するかもしれないので期待せずに待っていただけると嬉しいです。

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

[GraphQL] Apollo-iOS を使ってみて感じたイケてるところ。イケてないところ。

この記事は iOS #2 Advent Calendar 2020 の18日目の記事です。

概要

今業務でApollo-iOSを導入してます。
ネット上のいろんなサイトを参考し実装することができましたが、思い返すとApollo-iOSの使い方について言及されている記事は多いのですが、実際使ってみての感想はあまり見たことがないので、今回はApollo-iOSのイケてると思った所とイケてないなと思った所について、主観で好き勝手書いてみました。

これからGraphQL導入しようか迷っているという方に流し目に読んでいただけると幸いです.

捉え方は要件や環境によると思うので、当てはまらない場合もあると思います。

また、私の知識が及んでいないゆえの間違いなど気づいた点があればTwitter等でコメントいただけると嬉しいです。

GraphQLとは

簡単に言うとサーバーとクライアントとの通信をする際のプロトコルのようなもので、特定のライブラリやツールを指すものではありません。
詳しくはすでに様々なサイトで紹介されているので、参考になるであろうリンクを記載しておきます。
GraphQL.org
世のフロントエンドエンジニアにApollo Clientを布教したい
Web API初心者と学ぶGraphQL

Apollo-iOSとは

iOSアプリからGraphQLでサーバーとやり取りするためのライブラリです。
こちらも参考となるリンクを貼っておきます。
Apollo-iOS
Apollo-iOS Doc

イケてる所

サーバーエンジニアとの認識齟齬がなくなる

これはApollo-iOSというよりもGraphQLについてですが、GraphiQLPlayGroundなどのツールを導入することで、ブラウザ上でリクエストを簡単に試したり、付属するDoc機能でAPIの仕様を簡単に把握することができます。
初めてのGraphQLという本でも紹介されていた サンプルでPlayGroundを実際に触ることができます。

Screen Shot 2020-12-13 at 18.25.40.png

こうしたGraphQLから提供されたツールを利用することで、API仕様についてのレビューをより効率的に行えるようになり、
先にインターフェースの仕様をかっちりと決めて、その後にサーバーとクライアントがそれぞれ実装に入るというスキーマ駆動がしやすい点がイケてました。

また、ページネーション認証などのよくある処理について、実現パターンがGraphQLコミュニティで策定されており、これを参照することで実装方法や仕様の一貫性を保つことができる点もいいなと思いました。

Interceptorによって通信処理がカスタマイズしやすい

こちらはApollo-iOSについてです。
Apollo-iOSでは、通信、パース、キャッシュの操作などの処理をInterceptorとして個別に定義し、それらを配列にして登録することで順番に処理されるようになります。

デフォルトではLegacyInterceptorProviderが定義されていて、この中にInterceptorの配列が定義されています。

Interceptorプロトコルに準拠した構造体を独自のInterceptorProviderに組み込むことで、通信前後に任意の処理を行うことができます。

例えば、リクエストヘッダーにアクセストークンを付与することも簡単に行なえます。

イケてない所

エラーはスキーマで定義されない

GraphQLでは、エラーはHTTPステータス200として以下のようなJSON形式で返ってきます。
messagelocations の値はライブラリによって自動的に格納されます。

extensionsにはサーバーエンジニアが任意で設定した情報が格納されます。

{
    "errors": [
      {
        "message": "Cannot query field \"n\" on type \"Lift\".",
        "locations": [
          {
            "line": 3, 
            "column": 5
          }
        ],
        "extensions": {
          "code": “HogeError”
          “message”: “HogeMessage”
        }
      }
    ]
 }

エラーの情報は正常系とは違いスキーマ等には明示されません。
また、GraphQLの仕様としてかっちり仕様がきまっているわけではないようです。

認証エラーやその他ビジネスロジックに関わる独自のエラーはextensionsに格納されて返ってくるはずですが
何をどのタイミングで返すのか、どんな構造なのかはサーバーエンジニアと認識を合わせる必要があります。

Apollo-iOSでextensions内のcode にアクセスするには以下のようにします。

response?.parsedResponse?.errors?[0].extensions?[“code”] == “HogeError”

ここでもし、extensions内の構造が想定と違ったり、HogeErrorをタイポしたりするとエラーハンドリングに失敗してしまうので、個人的な思いとしてはエラーも可能な限りスキーマで管理できたり、型セーフに扱えたら良かったのにと思った次第です。

また、このような200系で返ってくるエラーはResult.failureではなくResult.successに包まれてハンドラーに返ってくる点にも注意が必要です。

各Interceptorのエラー型についてApollo-iOSの内部実装を確認する必要がある

Interceptorにそれぞれ独自のエラーが定義されており( 例: MaxRetryInterceptor)、それぞれの内部実装を確認しながら、Errorをそれぞれの具体的な型にキャストして適したハンドリングをする必要があります。
構造さえ知れば特に不便なこともないんですが、普通にライブラリ内のコードリーディングが求められているので割とハードル高めだなと思った次第です。

DDD的な思想とGraphQLの特長がマッチしない部分がある

GraphQLの思想として「(画面ごとに)必要な情報だけ取得できる」という特長がありますが、
DDD的な思想を元にアーキテクチャを組んでいる場合は以下の点でGraphQLの思想とバッティングするんじゃないかと思います。

  • 画面ごと構造体を作る == 画面駆動になってしまう。
  • 自動生成されたコードをそのままViewで使いたくない。本来データ層だけをApollo-iOSに依存させたい。
  • 自動生成されたコードのモックのデータが用意が難しくてドメイン層のテストがしづらい。
  • クエリのプロパティをフラグメントで共通化するとコードの構造も変わる。。API都合の変更でView側に修正を加えたくない。

ということで結局は、アプリ内部で使いやすいドメインオブジェクトにマッピングすることになります。その時は複数の画面でも使用できるようにオプショナルな値を持つことになると思います。そしてマッピングのためには扱いづらい自動生成されたコードと向き合う必要があります。

また、マッピングのテストをする場合自動生成された構造体のモックを作る必要があります。
モックをつくるには、以下のようなイニシャライザに渡す引数を用意する必要があります。

    public init(unsafeResultMap: [String: Any?]) {
      self.resultMap = unsafeResultMap
    }

引数を作る手順は以下です。

  1. 自動生成されたコードに以下のプロパティがあるので、
    public private(set) var resultMap: [String: Any?]
    実行中にデバッグコンソールでpo print(resultMap)で値を表示する。

  2. 1.の出力はそのままSwiftのコードとしても機能するはずなので、[String: Any]?の変数として定義すればコンパイルが通るはずです。(膨大な量のコードになるので、型を明示しないとコンパイルが通らないかもしれません。)

Interceptorの単体テストは難しい

Interceptorの処理は以下のメソッド内部に書くので、テストするためには引数のモックを用意する必要があります。引数は4つとも独自の型で構成されており、正しいモックを作るには内部のコードを知る必要があり、かなりハードルが高いなと思いました。

  func interceptAsync<Operation: GraphQLOperation>(
    chain: RequestChain,
    request: HTTPRequest<Operation>,
    response: HTTPResponse<Operation>?,
    completion: @escaping (Result<GraphQLResult<Operation.Data>, Error>) -> Void)

ちなみに、Assertionについては、RequestChainを継承したモックを作って引数に渡し、chain.proceedAsync()chain.handleErrorAsync()などの実行をトラップするのがいいのかなと思います。

QueryとMutationで実行するメソッドが違う

GraphQLでは特性に応じて主に3種類のオペレーションが存在します。
その内のQueryはサーバーから値を取得するとき、Mutationはサーバーの値を変える時に使用されます。

Apollo-iOSではQueryのリクエストはfetch、Mutationのリクエストはperformを使用する必要があるのですが、
fetchperformを単純に両方使用すると同じような処理を2つ実装する必要がでてくるので、共通化するためにちょっと複雑なコードを書く必要がありました。

自動生成したコード内で強制アンラップ祭り

これ原因でクラッシュしたことはありません。ただ、できれば強制アンラップはしてほしくないなって思いました。

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

[SwiftUI] Qiitaのタグみたいに表示されるTextFieldを作る

SwiftUIのTextFieldはカスタマイズ性に乏しいのですが、見かけだけでもそれっぽくしてみました。
ezgif-3-929af5259172.gif

実装

struct TestView: View {
    @State private var text: String = ""
    var body: some View {
        ZStack{
            TextField("入力", text: $text)
                .foregroundColor(.clear)
            HStack(spacing: 0){
                let strings = text.split(separator: " ", omittingEmptySubsequences: false)
                ForEach(strings.indices, id: \.self){i in
                    Text(strings[i])
                        .background(
                            Color(.sRGB, red: 0.847, green: 0.917, blue: 0.992)
                                .cornerRadius(5)
                                .overlay(
                                    RoundedRectangle(cornerRadius: 5)
                                        .stroke(Color(.sRGB, red: 0.745, green: 0.866, blue: 0.988))
                                )
                        )
                        .padding(.horizontal, 2)
                }
                Spacer()
            }
        }
    }
}

ポイント

TextFieldは制約が大きすぎるので色をclearにして見えなくし、その上から好きなように表示します。

制約

上からテキストを被せている都合上、カーソルが見えなくなります。これを防ぐためには例えばテキストの背景のカラーを透明色にすればいいのですが、色を合わせるのが大変だったので断念しました。

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