20190716のiOSに関する記事は12件です。

[Swift 5]ImageViewの画像の色を変更す流にはRenderingModeを変更しよう

ものすごく簡単なことですが、いつもつい忘れてしまうので…

通常、デフォルトではUIImageのレンダリングモードがオリジナルモードとなっているため、以下のようにtintColorを指定しても、元の画像の色から変更できない。

iconView.image = UIImage(named: "imageName")
iconView.tintColor = .gray

色を変更するならレンダリングモードを変更する必要があります

UIImageを生成した時にwithRenderingMode()を繋げて指定し、レンダリングモードをテンプレートモードにすればOK!

iconView.image = UIImage(named: "imageName")?.withRenderingMode(.alwaysTemplate)
iconView.tintColor = .gray

alwaysTemplateは、Assetsの画像Inspectorで指定することもできます。

参考:https://stackoverflow.com/questions/12872680/changing-uiimage-color

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

[Swift 5]ImageViewの画像の色を変更するにはRenderingModeを変更しよう

ものすごく簡単なことですが、いつもつい忘れてしまうので…

通常、デフォルトではUIImageのレンダリングモードがオリジナルモードとなっているため、以下のようにtintColorを指定しても、元の画像の色から変更できない。

iconView.image = UIImage(named: "imageName")
iconView.tintColor = .gray

色を変更するならレンダリングモードを変更する必要があります

UIImageを生成した時にwithRenderingMode()を繋げて指定し、レンダリングモードをテンプレートモードにすればOK!

iconView.image = UIImage(named: "imageName")?.withRenderingMode(.alwaysTemplate)
iconView.tintColor = .gray

alwaysTemplateは、Assetsの画像Inspectorで指定することもできます。

参考:https://stackoverflow.com/questions/12872680/changing-uiimage-color

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

【iOS, Swift】(備忘録)コーディング規約の大事なとこだけ

どうも!

Yoki(@enyyokii)と申します。

渋谷のIT企業でアプリエンジニアしている26才です。
仕事では iOS、Android、Webフロントエンドなど色々しており、週末は勉強を兼ねて個人開発したりしています。

今回はSwiftのコーディング規約についていろいろ資料を見る時間があったので、規約で忘れがち、重要度が高そうなものを抜粋してまとめてみました。

(※ 個人差ありです)

基本的なもの、具体例は「参考」にあるものが詳しいです。(エウレカのものが分かりやすいと思います。)

では本題!!

目的

  • プログラマー自身のエラーを減らして、さらにエラーを見つけやすくする
  • コードの可読性と明快さを向上させる(他の人がコードをレビューもしくは書き換えると仮定して) => 冗長さがなく、誤解の可能性を少なくする

規約

  • フォーマット
    • 半角スペース4つ分(Text Editingで設定する)。
  • 命名
    • 出来る限り省略された名前を付けない。
    • 副作用がない場合は、名詞を使用する。
    • 副作用がある場合は、動詞を使用する
  • 依存関係
  • 必要最低限のものだけをimportする。(UIKitをインポートする必要がある場合は、Foundationをインポートしない。)
    • import文はOS固有のフレームワークと外部フレームワークとの間に空行を1行入れて、アルファベット順に並べる。
  • 宣言の順序
    • classstructenumextensionprotocolなどの全ての宣言は // MARK: - <宣言の名前>を付ける。 また、// MARK:タグは上に2行、下に1行の空行を入れる。
  • アクセス修飾子
    • privateとして宣言することをデフォルトとして、必要なときだけinternalまたはpublicとして外部に公開する。
    • できる限り常にvarではなくletを使う。
  • シンタックスシュガーを使用し短く書く。

    • 必要な場合を除いて、変数やプロパティの型は宣言文の左側か右側のいずれか片側から推測できるようにする。
    • Forced Unwrappingは避ける
  • コメント

    • コメントは「なぜ?」という問いに答えるものであり、それ以外のことはコード自体が説明すべきである。
  • Collections / SequenceTypes

    • isEmpty, first, lastを使用する(indexを使用しなくても良い場合は使用しない)
    • countを使用する箇所はindicesを検討する
    • Off-by-oneエラーを回避できる
  • Self

    • selfは省略する
    • クロージャ内では[weak self]guard let節を用いて循環参照のケアをする。 また、予期せぬクラッシュをケアする為に[unowned self]は使用しない。

参考

数字は最終更新日

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

アスペクト比で高さを指定した場合のトルツメ(Viewの非表示)の方法

今回は特殊なトルツメ(Viewを消して空白を詰めること)について紹介したいです。
 androidでいうところのView.GONEがないようなので。

ちょっとばかりコアなのですが、aspect ratioでwidthからheightを動的に設定している場合を想定してください。

通常、トルツメをする際は消したいViewのheightに関する制約をドラッグしてきて

hogeHeightConstraint.constant = 0

これだけです。
調べたら大体この方法が出てくるかなと思います。
が、aspect ratioの場合はどうしたら。。。

width:height = 1:0にしちゃえばいいのかなと、とりあえず制約をドラッグしてみますがうまくいじれませんでした。
これのいじり方を知ってる方は教えてください。

というわけで別のちょっとテクニカルな方法をとりました。

priority
こんなものがあったなとふと脳裏をよぎりました。
プライオリティー
まんま、優先、ですね。優先席はプライオリティーシートなんて言いますが、それです。

これの利用方法ですが
①height = 0 の制約をpriorityを低めに設定
②widthとheightのaspect ratioの制約のpriorityを中くらいに設定(①の制約が無視されるためまだ非表示にはならない)
③トルツメしたいタイミングでheight = 0の制約のpriorityを高めに設定し直す

③のコードですが

hogeHeight.priority = UILayoutPriority(rawValue: 500)

こんな感じです
これは優先度500です
ストーリーボードなどで①の制約のpriorityを100、②のアスペクト比を300などに設定しておいてください。

ちょっとテクニカルですが参考にしてくれたら嬉しいです。

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

RxSwiftを使ってAPI通信をするアプリを作った!

初めまして!きゅうすけと申します!
iOSエンジニアの私が、自社の定例イベント 「TokyoUppersBoost」 で学んだ基礎の部分をまとめたものです!
メモ感覚ではありますが、これ違うぞってことがあったらぜひ教えてくださいー!(● ˃̶͈̀ロ˂̶͈́)੭ꠥ⁾⁾

今回は、「RxSwiftを使ってAPI通信を行う」というハンズオンメインの企画でした。
郵便番号を入力したら、住所の情報が返ってくるようにしました!

使うAPIの場所
http://zipcloud.ibsnet.co.jp/doc/api

上のリンクをまず叩いてもらって、リクエストパラメーターという表の下にあるURLがapiをJson形式で表示してくれるものです。
スクリーンショット 2019-07-16 13.49.04.png

=の後に郵便番号を入れると、住所を表示してくれます!
スクリーンショット 2019-07-16 13.52.59.png

ハンズオンまとめ

RxSwiftでやったことは、以下になります。

  • api通信のトリガー
  • テキストフィールドの入力制限
  • テキストフィールドの文字判定

このgithubを落としてきて、中をいじりました!
https://github.com/mht-mikiya-okugawa/SeminorRxSample

以下が完成コードになります!

完成コード

ViewController.swift
import UIKit
import RxCocoa
import RxSwift
//Alamofire:APIを使う時の設定をやってくれる
import Alamofire
import ObjectMapper



class AddressModel: Mappable {
    required init?(map: Map) {
    }
    var results: [Result] = []
    func mapping(map: Map) {
        results <- map["results"]
    }
}


class Result: Mappable {
    required init?(map: Map) {
    }
    var address1: String = ""
    var address2: String = ""
    var address3: String = ""
    var kana1: String = ""
    var kana2: String = ""
    var kana3: String = ""
    func mapping(map: Map) {
        address1 <- map["address1"]
        address2 <- map["address2"]
        address3 <- map["address3"]
        kana1 <- map["kana1"]
        kana2 <- map["kana2"]
        kana3 <- map["kana3"]
    }
}




class ViewController: UIViewController {

    private let baseUrl: String =  "http://zipcloud.ibsnet.co.jp/api/search?zipcode="
    @IBOutlet var zipcodeTxt: UITextField!
    @IBOutlet var resultLabel: UILabel!
    var textLength = BehaviorRelay<Int>(value: 0)
    private let disposeBag = DisposeBag()
    private var returnAddress: AddressModel? = nil

    //文字数制限
    func limitLength(textField: UITextField) {
        textField.rx.text.subscribe(onNext: { text in
            if let text = text, text.count >= 7 {
                textField.text = text.prefix(7).description
            }
        }).disposed(by: disposeBag)
    }

    //入力を数字のみにする処理
    func onlyNumber(textField: UITextField) {
        //テキストフィールドに何か打ち込んだらメソッドが呼び出される
        textField.rx.text.subscribe(onNext: { text in
            guard let txt = textField.text else { return }
            guard let intText = Int(txt) else { textField.text = ""; return }
        }).disposed(by: disposeBag)
    }

    //キーボードを閉じる
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        self.view.endEditing(true)
    }



    // キーボードを数字のみにする
    override func viewDidLoad() {
        super.viewDidLoad()
        httpRequest(zipcodeTxt: zipcodeTxt)
        limitLength(textField: zipcodeTxt)
        onlyNumber(textField: zipcodeTxt)
        // キーボードを数字のみにする
        zipcodeTxt.keyboardType = UIKeyboardType.numberPad
    }

    // api通信するところ
    func httpRequest(zipcodeTxt: UITextField) {
        zipcodeTxt.rx.text.subscribe({ _ in
        let url = self.baseUrl + zipcodeTxt.text!
        if zipcodeTxt.text?.count == 7 {
        let headers: HTTPHeaders = [
            "Contenttype": "application/json"
        ]

        Alamofire.request(url, method: .get, encoding: JSONEncoding.default, headers: headers).responseJSON { response in
            if let json = response.result.value {
                print(json)
                self.returnAddress = Mapper<AddressModel>().map(JSONObject: response.result.value)

                let address1: String = self.returnAddress!.results[0].address1
                let address2: String = self.returnAddress!.results[0].address2
                let address3: String = self.returnAddress!.results[0].address3
                let kana1: String = self.returnAddress!.results[0].kana1
                let kana2: String = self.returnAddress!.results[0].kana2
                let kana3: String = self.returnAddress!.results[0].kana3
                self.resultLabel.text = address1 + address2 + address3 + kana1 + kana2 + kana3

            }
        }

    }

    // 監視するのをやめる
    }).disposed(by: disposeBag)
    }
}


完成図

スクリーンショット 2019-07-16 14.07.43.png

某夢の国があるところを入れてみました?
郵便番号を打ったら結構すぐに住所を出してくれます!

↓ 以下が講師の人が用意してくれた記事です!
https://qiita.com/MHTcode_micky/private/0d2d8dd64d16baaeed4e
1時間くらいでできたので、ぜひ皆さんもやってみてください!

終わりに

どこで何をやっているのか、講師の人が言ってくれたことを直接コードにメモしながらやったので、
イメージしながら手を動かせたかなと思いました!

私が参加している勉強会 「TokyoUppersBoost」への参加者さん大募集中です!!
一緒にiOSについて学びましょう・:*+.(( °ω° ))/.:+

↓私が作ったLPなのでよかったら見てください!٩( 'ω' )و
TokyoUppersBoostとは?

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

Xamarin.iOSでBeaconを観測する

Xamarin.Formsを用いて、あるスマホアプリを作るのに(まだリリース前)、Beaconを用いたので、まとめてみたいと思います。
Swiftでの実装例は沢山あったんですけど、Xamarinでの実装例に関してあまり記事がなく少しばかり苦労したので、そういう方にとって理解しやすいような記事になっていれば幸いです。
今回はとりあえずiOSに関して書きます。

Beaconとは

Beaconとは、低消費電力の近距離無線技術「Bluetooth Low Energy」(BLE)を利用した位置特定技術、また、その技術を利用したデバイスのこと。
BLE形式のビーコンは何秒かに一回など断続的に信号を発信する形式であり、信号をだしっぱなしにするよりも電池の消費が抑えられ長期間使える、といった特徴があります。

GPSと何が違うの?

GPSとBeaconの最も大きな違いは、その発信源です。GPSは大気圏外に浮かぶ人工衛星からの情報を受け取りますが、Beaconは建物内や屋外の一地点に置いた発信源からの信号を受信します。

よって、GPSは電波の届かないところでは情報を受け取れないことがありますが、Beaconにおいてその心配はありません。

Beaconを使って何ができるの??

Beaconでできること1[Monitoring]

Beaconを受信できる範囲内に入ると、通知を受け取ることができます。

Beaconでできること2[Ranging]

Beaconを発信している機器との距離を把握することができます。

Beaconの活用事例

八景島シーパラダイス
https://www.itmedia.co.jp/makoto/articles/1408/18/news085.html

来場者は事前に専用アプリ「beaconnect(ビーコネクト)」をスマートフォンへインストールしておくことで、島内4つの水族館「アクアミュージアム」「うみファーム」「ドルフィンファンタジー」「ふれあいラグーン」など、来場者が今いる場所に合わせた水族館内の生きもの情報・豆知識やイベント情報を自動的に配信する。

アメリカの「MLB(メジャリーグ・ベースボール)」
https://www.mlb.com/apps/ballpark

アメリカの「MLB(メジャリーグ・ベースボール)」では、MLB.com Ballparkという専用アプリがあります。20以上のスタジアムに各100個のビーコン端末を設置し、取得したチケット情報を元に座席まで案内してくれたり、屋台のおすすめ商品を教えてくれたりするのです。

GINZA SIX
https://medium.com/tigerspike-tokyo/ginza-six-%E9%A4%A8%E5%86%85180%E3%82%AB%E6%89%80%E3%81%AB-beacon-%E3%82%92%E8%A8%AD%E7%BD%AE%E5%B0%8E%E5%85%A5%E3%81%97%E3%81%BE%E3%81%97%E3%81%9F-%E3%81%9D%E3%81%AE%EF%BC%92-a1c076808690

アプリをお使いの客様に、館内のどのフロアのどの辺りにいるかと、行きたいショップまでの最短ルートを表示します。例えば、5Fから4Fのショップへ行く場合、今いる場所から、エレベーター、エスカレーター、階段を使うルートの中から一番距離が短いルートを案内します。

開発環境

  • macOS Mojave バージョン10.14.4
  • Visual Studio 2017
  • Xamarin.Forms
  • Xamarin.iOS

Beaconの機能

Beaconの機能は大きく分けて2つあります。

  • Monitoring
  • Ranging

の2つです。
この2つに関して、具体的にどのようなことをするのか次に示します。

Monitoringとは

ビーコン領域の監視です。
設定したリージョンにユーザが入ったり出たりしたときに通知を受け取る仕組みです。リージョン監視はバックグランドでも動作するので、お店に入ったときにポイントカードやクーポンの通知を表示するといったアプリを簡単に実装することができます。

Rangingとは

Rangingは、設定されたリージョンに入ったiBeaconデバイスのUUID/major/minorといった情報と、Bluetooth信号強度や、およその距離が取得できます。Rangingが有効になっていると、1秒ごとに通知が来ます。
ちなみにRangingはMonitoringhと違って、バックグランドでの動作がサポートされていません。

Monitoringの実装

canGetNotificationで、既にBeacon領域にEnterしてるかどうかチェックしないままやると、同時に2つ通知が送られてきてしまう事がよくあったので、bool変数を定義しました。

RegionLeftは、実際にBeaconを感知しなくなってから、約40秒ほど経ってから呼び出されました。これは、逆にLeftしていないのにそう認識してしまうということを避けるために、そういう仕組みになっているらしいです。

AppDelegate.cs
using System;
using Foundation;
using UIKit;
using Beacon.iOS.Model;
using System.Diagnostics;
using WindowsAzure.Messaging;
using CoreLocation;

namespace Beacon.iOS
{
    // The UIApplicationDelegate for the application. This class is responsible for launching the 
    // User Interface of the application, as well as listening (and optionally responding) to 
    // application events from iOS.
    [Register("AppDelegate")]
    public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
    {

        #region fields
        CLLocationManager locationMgr;
        CLBeaconRegion region;
        private static bool canGetNotification;
        #endregion

        #region methods
        //アプリが起動完了した時に、APNS サーバにアプリを登録する
        public override bool FinishedLaunching(UIApplication app, NSDictionary options)
        {
            Console.WriteLine("FinishedLaunching called");
            global::Xamarin.Forms.Forms.Init();
            //本来ここら辺にRemote Notificationsの設定が書かれていると思うので、その下に以下を記述
            canGetNotification = true;//静的変数
            this.locationMgr = new CLLocationManager();//CLLocationManagerをインスタンス化
            this.locationMgr.AuthorizationChanged += LocationManagerAuthorizationChanged;//Beaconの設定を追加
            this.locationMgr.RegionEntered += LocationManagerRegionEnter;//メソッド①を追加
            this.locationMgr.RegionLeft += LocationMangagerRegionLeft;//メソッド②を追加
            return base.FinishedLaunching(app, options);//元々あるやつです
        }

        //①Regionに入った時に呼び出されるメソッド
        void LocationManagerRegionEnter(object sender, CLRegionEventArgs e)
        {
            if(canGetNotification)
            {
                //Alertを作成
                var notification = new UILocalNotification();
                notification.AlertBody = "近くにいるBeaconを感知しました!";
                UIApplication.SharedApplication.PresentLocalNotificationNow(notification);
                canGetNotification = false;
            }
        }

        //②Regionから外に出た時に呼び出されるメソッド
        void LocationMangagerRegionLeft(object sender, CLRegionEventArgs e)
        {
            if (!canGetNotification)
            {
                Debug.WriteLine("Regionから外に出たよ");
                canGetNotification = true;
            }
        }

        //Beaconの設定
        void LocationManagerAuthorizationChanged(object sender, CLAuthorizationChangedEventArgs e)
        {
            if(e.Status == CLAuthorizationStatus.AuthorizedAlways)//位置情報サービスが常にOnになっていれば
            {
                this.region = new CLBeaconRegion(new NSUuid("UUIDを入力"), "好きな名前");
                this.region.NotifyOnEntry = true;//領域に入った事を監視する
                this.region.NotifyOnExit = true;//領域から出たことを監視する
                this.locationMgr.StartMonitoring(this.region);//監視スタート
            }
        }

Rangingの実装

importやクラスの定義は省略します。

iBeaconRanging.cs
        #region fields
        CLBeaconRegion beaconRegion;
        CLBeacon clbeacon;
        private string status = "";
        private int major;
        private int minor;
        #endregion

        public void GetStatusOfMonitoring()
        {
            string uuid = "ここにはUUIDを入れる";
            NSUuid Uuid = new NSUuid(uuid);//Uuidを作成
            beaconRegion = new CLBeaconRegion(Uuid, uuid);
            locationMgr.StartRangingBeacons(beaconRegion);//Ranging開始
            locationMgr.DidRangeBeacons += (object sender, CLRegionBeaconsRangedEventArgs e) =>
            {
                if (e.Beacons.Length > 0)
                {
                    clbeacon = e.Beacons[0];//ここはどんどんスタックされていかないのか
                    this.major = (int)clbeacon.Major;
                    this.minor = (int)clbeacon.Minor;
                    this.status = clbeacon.Proximity.ToString();
                    switch (clbeacon.Proximity)
                    {
                        //観測できている時は、距離を表示
                        case CLProximity.Immediate:
                        case CLProximity.Near:
                        case CLProximity.Far:
                            Debug.WriteLine("現在約" + CutNumber(clbeacon.Accuracy) + "m離れたところにいます");//距離を表示
                            break;
                        //観測範囲内から消えた場合
                        case CLProximity.Unknown:
                            Debug.WriteLine("Unknown");
                            break;
                    }
                }
            };
        }

最後に

既にたくさんのBeaconを使ったアプリがあるので、今内容な使い方をするBeaconアプリを作ってみたいと思います。
Android, Swift版についても時間がある時に書こうと思います。

参考

たくさんのページを参考にしましたが、Microsoftのdocsをよく見ました。

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

dSYMのアップロードはBitrise/fastlaneで自動化しとくといいぞ

TL;DR

  • dSYMの取得とアップロードをするlane作成
  • BitriseにWorkflow作成
  • Workflowをスケジューリングする

前書き

Crashlyticsを利用していると, FabricへのdSYMのアップロードを求められることになります.
これをAppStoreConnectへのリリースの度に手動で行ってもいいのですが, 上げ漏れが起きたり, めんどくさかったり, めんどくさかったり, めんどくさかったりしますよね.
なので, 日次でdSYMのアップロードを行うようにBitrise上でスケジューリングすることで, 煩わしさから解放されましょう.

FastfileにdSYM関連の処理をするlane作成

Fastfile
desc "Refresh dSYMs"
lane :refresh_dsyms do

  download_dsyms(
    app_identifier: "tihimsm.my_app",
    version: "latest" # 最新のdSYMを取得するように指定
  )

  upload_symbols_to_crashlytics # dSYMをCrashlyticsへアップロード

  clean_build_artifacts # ローカルのdSYMを削除

  slack(message: "dSYMs are refreshed!")

end

download_dsymsでAppStoreConnectにあるdSYMをダウンロードするのですが, オプションでversion: "latest"を指定しています.
なにも指定しないとすべてのdSYMを取得して, けっこう時間がかかってしまうので要注意です.
あとでBitriseの方でスケジューリングをするのですが, 毎日このlaneが走る要にしておけば, よほどリリースの頻度が高くない限りは漏れなくアップロードできるはずです.

clean_build_artifactsはローカルにあるdSYMファイルを削除してくれるもので, Bitriseでlaneを走らせる限りはあまり意味がないのですが, ローカルでlane叩く可能性も考えて削除するようにしてます.
ちなみに, 今回はdSYMを削除してくれるclean_build_artifactsですが, 実はとても汎用性の高いactionで, 状況にあわせてよしなに削除してくれるとても賢いやつです.
詳しくは公式ドキュメントをご参照ください.

BitriseにWorkflowを設定

Workflowはとても簡単です.
リポジトリをCloneしてきてfastlaneでrefresh_dsymsを起動させるだけです.
スクリーンショット 2019-07-16 3.41.41.png

Workflowをスケジューリングしよう

Buildsタブの画面右上の方にStart/Schedule a Buildというボタンがあるので, そこからスケジューリングの設定を行います.
スクリーンショット 2019-07-16 3.42.24.png
上のような画面で設定が行えます.

  • 時間
  • 曜日
  • ブランチ
  • Workflow を設定することで, 自動でWorkflowが起動できます. 上の例だと毎日AM01:00に起動するようになっています.

これで毎日AppStoreConnectの最新のdSYMを取得して, Crashlyticsへアップロードを自動で行うことができるようになります.

まとめ

いかがでしょうか?
地味な内容でしたが, 手作業でやってると意外とめんどくさかったり漏れがあったりする箇所で, 人がやらなきゃいけない作業でもないので, こういう類のものは自動化してしまうに限ります.

簡単に設定できるわりに, 毎日SlackにdSYMがアップロードされた通知が来るので「うんうん, 今日もdSYMアップロードご苦労」という気分になれます笑
コスパ良くてけっこうおすすめなので, 設定してない方はぜひやってみてください!

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

Apple製 BooksアプリのセミモーダルUIを再現する

はじめに

Apple製のBooksアプリ(以後Booksアプリ)ではタイル状に並んだ本をタップすると、特徴的なセミモーダル画面へ遷移します。
gif01.gif
一般的なセミモーダルと違い、横スワイプで元画面で並んでいた本を切り替えることができ、縦スクロールすることで徐々に拡大し全画面モーダルへと変化します。
このUIの良いところは、詳細な情報を表示や、前後のコンテンツ切り替えをスムーズに行うことができるところだと思います。

ちなみに、似たようなことはUIPageViewControllerで以前より実現していましたが、前後のコンテンツの有無をユーザーへ知らせる機能が弱く(画面下へPageControlを設置など)、ユーザーに実際に気づいてもらえないことが多々あります。
ユーザーに気づいてもらうために、ガイドモーダルを表示したり、一時的に横スクロールアニメーションを行い、横スクロールを示唆したりと一工夫しているアプリを見かけます。

また、UIPageViewControllerの横スワイプによるViewController切り替えを行うため、各ViewControllerに横スワイプアクションを入れると競合してしまいます。

Booksアプリのセミモーダルは前後コンテンツが見える状態のため、前後の存在が一目瞭然です。
更に、全画面に拡大中は各要素となるViewControllerの横スワイプ切り替えは出来ないため、横方向のジェスチャーに対応したコンテンツを配置することも可能です。

良いとこばかりのBooksアプリのセミモーダルですが、UIPageViewControllerのようにUIKitに存在しないため独自実装する必要があります。
複数の要素をうまく連携する必要があるため、導入の難易度が比較的高いUIだと思います。

このエントリーでは完全ではないものの、BooksアプリのセミモーダルUIを再現しています。
デザイナーやプロダクトマネージャー等から実装の要望をされたアプリ開発者もそこそこいるのではないでしょうか?
今まで見送っていた方の導入する際の参考になれば幸いです。

再現したもの

以下は今回実装したアプリのGifです。以降の調査にある特徴的な4つの要素が再現できていると思います。
demo.gif

Githubに再現したプロジェクトをおいてあります。実装の詳細や動作を確認する際に適宜参照してください。
https://github.com/iincho/CollectionViewSemiModalTransitioning

構成する要素の調査

BooksアプリのセミモーダルUIを再現するために、どの機能を組み合わせる必要があるのか調べるため、まずはアプリの挙動を観察します。

1. タイル状に並んだ画像タップでセミモーダル状態に遷移

gif02.gif
画像と背景のViewが拡大しながらセミモーダル状態に変化しています。似たような挙動として、Twitterアプリ等で写真リスト>写真詳細へ遷移する際のアニメーションによく見られる挙動です。

2. セミモーダル状態で左右のViewをスワイプで切り替え

gif03.gif
左右Viewの部分的な表示と横スクロール時の制御では、スワイプ終了時のスクロール方向や位置と速度により停止位置が制御されています。

3. 上方向のスクロールで全画面モーダルへ遷移アニメーション

gif05.gif
上方向へのスクロールでシームレスに全画面モーダルへと状態変化しますが、スワイプだけでなく、慣性スクロールでも遷移アニメーションが継続します。これは全画面モーダルから、セミモーダル状態への遷移でも同様です。
例)
セミモーダル表示中に勢いよく画面上部に向けてスクロール→慣性スクロールにより全画面モーダル状態まで到達し、更に全画面モーダルでもスクロールを継続

4. 下方向へのスワイプでモーダルを閉じる

gif04.gif
モーダルを閉じる処理はスワイプを中断する位置によってDismissをキャンセルすることができます。また、スワイプに合わせてインタラクティブにDismissアニメーションが行われます。
また、下方向へのスワイプ中アニメーションとスワイプ完了後のアニメーションが異なります。

実装

クラス構成

クラス 概要
ViewController セミモーダル画面への遷移元画面
CollectionSemiModalViewController UICollectionViewを保持したUIViewController
CollectionViewCell UITableViewを保持したUICollectionViewCell
CollectionViewPresentAnimator セミモーダル画面のPresent, Dismissする際のアニメーションを制御するUIViewControllerAnimatedTransitioningに準拠したクラス
ModalPresentationController ViewControllerからのPresentアニメーションを制御するUIPresentationControllerを継承したクラス
DismissalTransitioningInteractor ※ Dismissアニメーションの内、上下スクロール中のアニメーションを制御
DismissalTransitionable ※ セミモーダルのDismissの内、上下スクロールをインタラクティブに制御するためのProtocolExtensionを保持し、DismissalTransitioningInteractorのインスタンスメソッドによりインタラクションを制御する。
SemiModalTransitioningDelegate ※ UIViewControllerTransitioningDelegateに準拠したクラス。関連するDelegateメソッドをViewControllerから分離するため別クラスとして定義している

以降、実装のポイントとなる箇所を抜粋しています。全体の実装はGitHubを確認ください。

1. タイル状に並んだ画像タップでセミモーダル状態に遷移

カスタムしたUIPresentaitonControllerや、UIViewControllerAnimatedTransitioningによる実装で実現します。

Present時、背景にグレーの透過Viewを差し込みます。Dismiss時には逆に透過Viewを取り除きます。そのためのUIPresentationControllerを継承したModalPresentationControllerを実装します。

ModalPresentationController.swift
final class ModalPresentationController: UIPresentationController {
    private let overlayView = UIView()

    override func presentationTransitionWillBegin() {
        super.presentationTransitionWillBegin()

        overlayView.frame = containerView!.bounds
        overlayView.backgroundColor = .black
        overlayView.alpha = 0.0
        containerView!.insertSubview(overlayView, at: 0)
        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] _ in
            self.overlayView.alpha = 0.5
        })
    }

    override func dismissalTransitionWillBegin() {
        super.dismissalTransitionWillBegin()

        presentedViewController.transitionCoordinator?.animate(alongsideTransition: { [unowned self] _ in
            self.overlayView.alpha = 0.0
        })
    }

    override func dismissalTransitionDidEnd(_ completed: Bool) {
        super.dismissalTransitionDidEnd(completed)

        if completed {
            overlayView.removeFromSuperview()
        }
    }

    override var frameOfPresentedViewInContainerView: CGRect {
        return containerView!.bounds
    }

    override func containerViewWillLayoutSubviews() {
        super.containerViewWillLayoutSubviews()

        overlayView.frame = containerView!.bounds
        presentedView!.frame = frameOfPresentedViewInContainerView
    }
}

遷移元のカラーViewセミモーダル内のカラーViewへアニメーションする処理を実装します。
UIViewControllerAnimatedTransitioningに準拠したCollectionViewPresentAnimatorを実装します。
なお、Dismiss時のアニメーションも同クラスへ実装するため、内部的に分岐処理を実装しています。

CollectionViewPresentAnimator.swift
final class CollectionViewPresentAnimator: NSObject, UIViewControllerAnimatedTransitioning { 
    let isPresent: Bool

    init(isPresent: Bool) {
        self.isPresent = isPresent
        super.init()
    }

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.3
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        if isPresent {
            // Present時のアニメーション処理を実行するメソッドをコール
            presentTransition(using: transitionContext)
        } else {
            // Dismiss時のアニメーション処理を実行するメソッド
            dismissalTransition(using: transitionContext)
        }
    }

func presentTransition(using transitionContext: UIViewControllerContextTransitioning)メソッドでは以下の処理を行っています。

  1. 遷移アニメーション開始時のView描画に必要な情報(CGRect, Index, UIColor)を用意
  2. 遷移アニメーション終了時のView描画に必要な情報(CGRect, Index)を用意
  3. 遷移アニメーション用のViewを1で生成した位置から、2で生成した位置へアニメーション実行
  4. 遷移完了後、遷移アニメーションViewを削除

遷移先CollectionViewCellに合わせて、1のアニメション開始位置のCGRect, Index, UIColor生成します。
遷移先に表示されるCollectionViewCellの数に合わせてCellごとに必要な情報を生成しています。
この際、transitionContextから生成した、遷移先のViewControllertoVCから遷移先のCollectionViewCellを取得しますが、このタイミングでは遷移先の描画が終わっておらず、ほしいCellの情報を取得できません。
そこでsnapshotView(afterScreenUpdates: true)メソッドにより表示更新後のスナップショットを取ることで、以toVCから必要なCell情報を取得することが可能となります。

また、再現アプリでは、遷移元のCollectionViewは改行を含みます。実装では遷移後の横並びCellに合わせ、遷移開始時、最大横3列のアニメーションViewを用意しています。(Booksアプリでも同様のアニメーションを行っています。)

2019/7/17追記
toVCから遷移先のCollectionViewCellが取得できない件について補足します。
アニメーションに必要なCellを参照するには描画されている必要がある→以下工程が完了している必要があります。

  • 1.CollectionViewの描画
  • 2.遷移元でタップされたCellを中央に表示

この内、2についてはUICollectionView.scrollToItem(at indexPath: at scrollPosition: animated:)によりCellを初期描画の段階で移動させる必要があります。
この場合、viewDidLayoutSubviews()で一度呼び出すことで対応していますが、UIViewControllerAnimatedTransitioningの関連メソッド内部では、明示的に呼び出す方法がありません。
beginAppearanceTransition(_:animated:) ではviewWillAppear, viewDidAppearを呼び出せますことは可能ですが、Cell移動させるにはviewWillAppearでは早すぎ(移動しない)、viewDidAppearではおそすぎ(遷移アニメーション後にCellが移動する)、うまくいきません。
試行錯誤の上、snapshotView(afterScreenUpdates: true)メソッドでスナップショットを作成すると、それ以降CollectionViewCellがスクロールした状態でCellの参照ができる事がわかりました。

CollectionViewPresentAnimator.presentTransitionメソッド
    private func presentTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let fromVC = transitionContext.viewController(forKey: .from) as! ViewController
        let toNC = transitionContext.viewController(forKey: .to) as! UINavigationController
        let toVC = toNC.viewControllers.first as! CollectionSemiModalViewController
        let finalToVCFrame = toVC.view.frame
        let containerView = transitionContext.containerView

        let selectedIndexPath = fromVC.collectionView.indexPathsForSelectedItems!.first!

        // 通常、このタイミングで取得できる[遷移先]のvisibleCellsは先頭2つのCellとなる。本来はタップしたCell+前後のCellがほしい。
        // snapshotView(afterScreenUpdates: true)によりスナップショットを取得することで、描画完了後のViewを生成するとともに目的のCellがvisibleCellsに格納されるようになる。
        if toVC.view.snapshotView(afterScreenUpdates: true) != nil {

            // 遷移元Cell関連
            // 遷移元Cellの座標をもとにアニメーション開始位置を決める。
            // 今回のアニメーションでは、遷移後の横並びに合わせ、アニメーション開始位置はタップされたCellの両脇を開始位置とする。
            // そのため、左右のセルが改行の関係で上下に位置する場合を考慮し、タップされたCellをもとにCGRectを生成する。
            // なお、遷移元のCell位置関係の取得はCollectionViewが一つであることを想定した実装であるため、複数ある場合はそれを考慮した実装が必要になる。

            // 遷移元Cellの生成 TargetCellの前後の存在有無を確認した上でCellを生成
            // cellForItemでは取得出来ない場合(画面外にあるなど)はUICollectionViewCellを生成している。
            // Frame指定する際、前後のCellはCollectionViewの改行を考慮し、TargetCellの左右に並ぶよう調整している
            let targetCell = fromVC.collectionView.cellForItem(at: selectedIndexPath)!
            let targetConvertFrame = targetCell.convert(targetCell.bounds, to: fromVC.view)
            // TODO: minimumLineSpacingはLayoutによって実際のCell間隔とズレが生じる。改行があるため、単純に前後のCell.originの比較では無いため今回は妥協している。
            let cellSpacing = (fromVC.collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.minimumLineSpacing ?? 0

            var fromCellDataList: [AnimationCellData] = []
            // PrevCell
            let prevTag = targetCell.tag - 1
            if 0 <= prevTag {
                let prevCell = fromVC.collectionView.cellForItem(at: IndexPath(row: prevTag, section: selectedIndexPath.section)) ?? UICollectionViewCell()
                prevCell.tag = prevTag
                fromCellDataList.append(AnimationCellData(cell: prevCell, targetConvertFrame: targetConvertFrame, targetType: .prev, cellSpacing: cellSpacing))
            }
            // TargetCell
            fromCellDataList.append(AnimationCellData(cell: targetCell, targetConvertFrame: targetConvertFrame, targetType: .target, cellSpacing: cellSpacing))
            // NextCell
            let nextTag = targetCell.tag + 1
            if nextTag < fromVC.collectionView.numberOfItems(inSection: selectedIndexPath.section) {
                let nextCell = fromVC.collectionView.cellForItem(at: IndexPath(row: nextTag, section: selectedIndexPath.section)) ?? UICollectionViewCell()
                nextCell.tag = nextTag
                fromCellDataList.append(AnimationCellData(cell: nextCell, targetConvertFrame: targetConvertFrame, targetType: .next, cellSpacing: cellSpacing))
            }

            // 遷移先View関連
            let toCells = toVC.collectionView.visibleCells.compactMap { cell -> CollectionSemiModalViewCell? in
                guard let castCell = cell as? CollectionSemiModalViewCell else { return nil }
                castCell.switchTitleColorView(isClear: true)
                return castCell
                }.sorted(by:{ $0.tag < $1.tag })

            let finalToCellsFramesWithTag = toCells.map { toCell -> (frame: CGRect, tag: Int) in
                let frame = toCell.convert(toCell.bounds, to: toVC.view)
                return (frame, toCell.tag)
            }
            let finalColorViewsFramesWithTag = toCells.map { toCell -> (frame: CGRect, tag: Int) in
                let frame = toCell.titleColorView?.convert(toCell.titleColorView?.bounds ?? .zero, to: toVC.view) ?? .zero
                return (frame, toCell.tag)
            }

            // AnimationView関連(toVCからSnapshotを作成)
            let animationToCells = toCells.map {  toCell -> UIView in
                let snapshotCell = toCell.resizableSnapshotView(from: toCell.bounds, afterScreenUpdates: true, withCapInsets: .zero) ?? UIView()
                snapshotCell.tag = toCell.tag
                snapshotCell.frame = fromCellDataList.first(where: {$0.tag == toCell.tag})?.frame ?? .zero
                snapshotCell.alpha = 0
                return snapshotCell
            }
            let animationColorViews = fromCellDataList.map { tuple -> UIView in
                let view = UIView(frame: tuple.frame)
                view.tag = tuple.tag
                view.backgroundColor = tuple.color
                return view
            }

            // アニメーションに関してtoVCを主に操作しているが、containerViewへ追加するのはあくまでUINavigationControllerのViewである必要がある。
            // toVCでも遷移自体は完了するが、遷移後画面がちらついたり詳細への遷移がおかしくなることがある。
            toNC.view.isHidden = true
            containerView.addSubview(toNC.view)
            animationToCells.forEach { containerView.addSubview($0) }
            animationColorViews.forEach { containerView.addSubview($0) }

            UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options:[.curveEaseInOut], animations: {
                animationToCells.forEach { animationCell in
                    animationCell.frame = finalToCellsFramesWithTag.first(where: { $0.tag == animationCell.tag })?.frame ?? .zero
                    animationCell.alpha = 1
                }
                animationColorViews.forEach { animationColorView in
                    animationColorView.frame = finalColorViewsFramesWithTag.first(where: { $0.tag == animationColorView.tag })?.frame ?? .zero
                }
            }, completion: { _ in
                toNC.view.isHidden = false
                toCells.forEach { $0.switchTitleColorView(isClear: false) }
                animationToCells.forEach { $0.removeFromSuperview() }
                animationColorViews.forEach { $0.removeFromSuperview() }
                transitionContext.completeTransition(true)
            })
        } else {
            // アニメーションさせる遷移先のSnapshotが取得出来なかった場合
            containerView.addSubview(toVC.view)
            toVC.view.frame = CGRect(origin: CGPoint(x: 0, y: finalToVCFrame.size.height), size: finalToVCFrame.size)
            UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options: [.curveEaseOut], animations: {
                toVC.view.frame = finalToVCFrame
            }, completion: { _ in
                transitionContext.completeTransition(true)
            })
        }
    }

    struct AnimationCellData {
        enum TargetType {
            case prev
            case target
            case next
        }

        let frame: CGRect
        let tag: Int
        let color: UIColor?

        init(cell: UICollectionViewCell, targetConvertFrame: CGRect, targetType: TargetType, cellSpacing: CGFloat) {
            switch targetType {
            case .target:
                frame = targetConvertFrame
            case .prev:
                frame = targetConvertFrame.offsetBy(dx: -targetConvertFrame.width - cellSpacing, dy: 0)
            case .next:
                frame = targetConvertFrame.offsetBy(dx: targetConvertFrame.width + cellSpacing, dy: 0)
            }
            tag = cell.tag
            color = cell.contentView.backgroundColor
        }
    }

2. セミモーダル状態で左右のViewをスワイプで切り替え

横スクロール時の停止位置制御は以下3パターンに分類できます。

  • 横スクロール時、Viewの半分以上をスワイプ後に指を離す→次のViewにスクロール
  • 横スクロール時、Viewの半分以下をスワイプ後に指を離す→元のView位置にスクロール
  • すばやく横スワイプ→スワイプ終了位置にかかわらず次のViewへスクロール

UICollectionViewには上記アニメーションをデフォルトで表現することは出来ないため、独自実装を行います。
始め、UICollectionViewLayoutのtargetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPointメソッドをオーバーライドして対応する方針を検討しましたが、スワイプ完了後の慣性スクロールを自然な状態にできませんでした。
参考サイト: https://dev.classmethod.jp/smartphone/iphone/collection-view-layout-cell-snap/

再現アプリではUIScrollViewDelegateメソッドscrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)でスワイプ完了時の状態により動きを制御しています。
参考サイト: https://github.com/hershalle/CollectionViewWithPaging-simplerExample

CollectionSemiModalViewController
    /// CollectionViewの横スクロールを必ず中央で止まるように制御している
    /// ドラッグ完了位置(Cell半分以上スクロール)、もしくは、スワイプ時の速度のどちらかが該当条件を満たしていた場合に、前後のCollectionViewCellの中央までスクロールするよう制御している
    func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        // 横スクロールの速度閾値
        let swipeVelocityThreshold: CGFloat = 0.5

        // 横スクロールを現在の位置で止め、現在の横スクロール位置から中央に表示されるCollectionViewCellのindexを取得
        targetContentOffset.pointee = scrollView.contentOffset
        let indexOfMajorCell = self.indexOfMajorCell()

        let dataSourceCount = collectionView(collectionView!, numberOfItemsInSection: 0)
        // 横スクロールの速度が次のCellへスライドする閾値を超えているか(かつindexが範囲内)
        let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < dataSourceCount && velocity.x > swipeVelocityThreshold
        // 横スクロールの速度が前のCellへスライドする閾値を超えているか(かつindexが範囲内)
        let hasEnoughVelocityToSlideToThePrevCell = indexOfCellBeforeDragging - 1 >= 0 && velocity.x < -swipeVelocityThreshold
        // ドラッグ開始前のIndexと現在のIndexが一致しているか
        let majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragging
        // スワイプ速度による前後Cellへのスクロールを行うか
        let didSwipeToSkipCell = majorCellIsTheCellBeforeDragging && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePrevCell)

        if didSwipeToSkipCell {
            // スワイプ速度による前後スクロール制御
            let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1)
            let toValue = layout.pageWidth * CGFloat(snapToIndex)

            // usingSpringWithDamping: 1 振動なし、initialSpringVelocity: アニメーション初速をCollectionViewの横スクロール速度に設定
            UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: velocity.x, options: .allowUserInteraction, animations: {
                scrollView.contentOffset = CGPoint(x: toValue, y: 0)
                scrollView.layoutIfNeeded()
            }, completion: { _ in
                self.selectedIndex = snapToIndex
            })

        } else {
            // indexによるスクロール位置の更新
            let indexPath = IndexPath(row: indexOfMajorCell, section: 0)
            layout.collectionView!.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
            selectedIndex = indexOfMajorCell
        }
    }

    /// CollectionViewの水平方向の位置を元に、中央付近にあるCollectionViewCellのindexを返却
    private func indexOfMajorCell() -> Int {
        let itemWidth = layout.pageWidth
        let proportionalOffset = layout.collectionView!.contentOffset.x / itemWidth
        let index = Int(round(proportionalOffset))
        let numberOfItems = collectionView.numberOfItems(inSection: 0)
        let safeIndex = max(0, min(numberOfItems - 1, index))
        return safeIndex
    }

3. 上方向のスクロールで全画面モーダルへ遷移アニメーション

全画面モーダルへの遷移アニメーションをUIViewControllerAnimatedTransitioningUIPercentDrivenInteractiveTransitionにより実現しようとしましたが、Present, Dismiss完了後のアニメーションまで制御する必要があり、その煩雑さから現実的では無いと判断しました。

回避策として、上方向のスクロールで全画面モーダル表示アニメーションを、遷移ではなくスクロールアニメーションとすることで再現します。
UICollectionViewCellUITableViewを保持し、スクロール量によってUICollectionViewCell.widthを変更するとともに、UICollectionViewの横スクロール可否を切り替えます。合わせてNavigationBarの表示制御もスクロール量により切り替えます。
※だいぶ力技な気がします。良い方法があればコメントいただければ幸いです。

縦方向のスクロールで閾値を超えた際にナビゲーションバーの表示を切り替えでは、表示切り替えごとにcontentInsetが変動するため、そのままでは表示が崩れたりUICollectionViewのサイズがおかしくなってスクロールができなくなります。
対策として、contentInsetAdjustmentBehaviorの設定をUICollectionViewCell内部のUITableViewで変動しないよう、.neverに設定。合わせて、UICollectionViewの上方向制約条件はSafeAreaに対してではなく、Superviewに対して行う必要があります。
例)
collectionView.contentInsetAdjustmentBehavior = .never

CollectionSemiModalViewController
// MARK: - UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout Methods
extension CollectionSemiModalViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return dataList.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(with: CollectionSemiModalViewCell.self, for: indexPath)
        let baseRect = cell.frame
        let data = dataList[indexPath.row]
        cell.tag = indexPath.row
        cell.configure(headerHeight: cellHeaderHeight, data: data)
        cell.scrollViewDidScrollHandler = { [weak self] offsetY in
            // CollectionViewCellが保持するUITableViewからスクロールされるたびに呼ばれるハンドラー
            // UITableViewのスクロール位置を保持し、CollectionViewCell.frameを更新するメソッドを呼ぶ
            self?.tableViewContentOffsetY = offsetY
            self?.transformCell(cell, baseRect: baseRect)
        }
        cell.tableViewDidSelectHandler = { [weak self] row in
            // 詳細へ遷移
        }
        cell.closeTapHandler = { [weak self] in
            // セミモーダルを閉じる
        }
        return cell
    }

    /// TableViewのスクロールに合わせて、画面内のCollectionViewCellのFrameを制御
    ///
    /// - Parameters:
    ///   - cell: TableViewをスクロールしているCollectionViewCell
    ///   - baseRect: CollectionViewCell初期位置のframe
    private func transformCell(_ cell: CollectionSemiModalViewCell, baseRect: CGRect) {
        switchDisplayNavigationBar(data: cell.data)
        // Cellの拡大中は横スクロールできないよう、TableViewのスクロール位置により制御
        collectionView.isScrollEnabled = tableViewContentOffsetY == 0

        let targetHeight = cellHeaderHeight + visibleNaviBarOffsetY // CellWidthが画面幅まで拡大するのが完了する高さ
        let verticalMovement = tableViewContentOffsetY / targetHeight
        let upwardMovement = fmaxf(Float(verticalMovement), 0.0)
        let upwardMovementPercent = fminf(upwardMovement, 1.0)
        let transformX = Float(view.frame.width - baseRect.size.width) * upwardMovementPercent
        let newPosX = Float(baseRect.origin.x) - transformX / 2
        let newWidth = baseRect.size.width + CGFloat(transformX)
        // 中央のCellを操作
        cell.frame = CGRect(x: CGFloat(newPosX),
                            y: baseRect.origin.y,
                            width: newWidth,
                            height: baseRect.size.height)
        // 前後のCollectionViewCellを動かす
        collectionView.visibleCells.forEach { vCell in
            if vCell.tag < cell.tag {
                vCell.frame.origin.x = (baseRect.origin.x - layout.pageWidth) - CGFloat(transformX / 2)
            } else if cell.tag < vCell.tag {
                vCell.frame.origin.x = (baseRect.origin.x + layout.pageWidth) + CGFloat(transformX / 2)
            }
        }
    }

    /// NavigationBarの表示制御
    /// 一定以上TableViewがスクロールされている場合にナビバーを表示する
    private func switchDisplayNavigationBar(data: ViewData) {
        if let nv = navigationController {
            if cellHeaderHeight + visibleNaviBarOffsetY <= abs(tableViewContentOffsetY), nv.isNavigationBarHidden {
                title = data.title
                nv.navigationBar.barTintColor = data.color
                nv.setNavigationBarHidden(false, animated: true)
            }
            if abs(tableViewContentOffsetY) < cellHeaderHeight + visibleNaviBarOffsetY, !nv.isNavigationBarHidden {
                nv.setNavigationBarHidden(true, animated: true)
            }
        }
    }

4. 下方向へのスワイプでセミモーダルを閉じる

セミモーダルを閉じる一連の動きは、以下アニメーションに分類できます。

  1. 下方向のスワイプ中アニメーション: Interactiveなアニメーション。スワイプ中断位置によりDismissキャンセル可能
  2. スワイプ完了後のアニメーション: 不可逆なDismissアニメーション

1はUICollectionViewにUIPanGestureRecognizerを設定しViewのドラッグ操作時のtranslation.yにより、Interactiveなアニメーションを実現します。

CollectionSemiModalViewController
    override fun viewDidRoad() {
        //.... 省略
        let collectionViewGesture = UIPanGestureRecognizer(target: self, action: #selector(collectionViewDidDragging(_:)))
        collectionViewGesture.delegate = self
        // collectionViewへPanGestureを設定
        collectionView.addGestureRecognizer(collectionViewGesture)
        //.... 省略
    }

    /// CollectionViewの縦方向スクロールをハンドリング 
    ///
    /// - Parameter sender: UIPanGestureRecognizer
    @objc private func collectionViewDidDragging(_ sender: UIPanGestureRecognizer) {
        // CollectionViewが横方向にスクロールしている間はInteraction開始処理しない。
        if isScrollingCollectionView { return }
        // CollectionViewCell内のTableViewスクロール位置と、CollectionView PanGestureの縦方向移動量により、ハンドリング
        handleTransitionGesture(sender, tableViewContentOffsetY: tableViewContentOffsetY)
    }
DismissalTransitionable
/// DismissTransition制御関連プロトコル
protocol DismissalTransitionable where Self: UIViewController {
    // Dismiss実行閾値(縦スクロール量の比率)
    var percentThreshold: CGFloat { get }
    // Dismiss実行速度閾値
    var shouldFinishVerocityY: CGFloat { get }
    // DismissTransitionの状態を保持
    var interactor: DismissalTransitioningInteractor { get }
}

extension DismissalTransitionable {
    /// Dismiss開始までの上下スワイプによるアニメーションと、Dismiss実行、中止を制御している
    ///
    /// - Parameters:
    ///   - sender: CollectionViewのPanGestureRecognizer
    ///   - tableViewContentOffsetY: CollectionViewCell内部のTableViewスクロール位置
    func handleTransitionGesture(_ sender: UIPanGestureRecognizer, tableViewContentOffsetY: CGFloat) {
        let translation = sender.translation(in: view)
        // スクロール位置によりインタラクションの状態を更新するメソッドをコール
        interactor.updateStateWithTranslation(y: translation.y, tableViewContentOffsetY: tableViewContentOffsetY)
        if interactor.shouldStopInteraction { return }

        // 上下スクロール量の割合を計算
        let dismisalOffsetY = translation.y - interactor.startInteractionTranslationY
        let verticalMovement = dismisalOffsetY / view.bounds.height
        let downwardMovement = fmaxf(Float(verticalMovement), 0.0)
        let downwardMovementPercent = fminf(downwardMovement, 1.0)
        let progress = CGFloat(downwardMovementPercent)

        // UIPanGestureRecognizer.state によるinteractor.stateの更新
        switch sender.state {
        case .changed:
            interactor.changed(by: dismisalOffsetY)
            if progress > percentThreshold || sender.velocity(in: view).y > shouldFinishVerocityY {
                // スクロール量の割合が閾値を超えた、もしくは、スクロール速度がしきい値を超えた場合
                interactor.state =  .shouldFinish
            } else {
                interactor.state =  .hasStarted
            }
        case .cancelled:
            interactor.reset()
        case .ended:
            // パンジェスチャー終了時のinteractor.stateによりDismiss実行有無を判定
            switch interactor.state {
            case .shouldFinish:
                interactor.finish()
            case .hasStarted, .none:
                interactor.reset()
            }
        default:
            break
        }
    }
}
class DismissalTransitioningInteractor {
    enum State {
        case none
        case hasStarted
        case shouldFinish
    }

    var state: State = .none

    var startInteractionTranslationY: CGFloat = 0

    var startHandler: (() -> Void)?

    var changedHandler: ((_ offsetY: CGFloat) -> Void)?

    var finishHandler: (() -> Void)?

    var resetHandler: (() -> Void)?

    var shouldStopInteraction: Bool {
        switch state {
        case .none: return true
        case .hasStarted, .shouldFinish: return false
        }
    }

    /// スクロール位置によるState更新
    ///
    /// - Parameters:
    ///   - translationY: CollectionViewGestrueTranslationY
    ///   - tableViewContentOffsetY: TableViewのScrollContentOffsetY ドラッグによる更新されたOffsetY (慣性スクロールは含まない)
    func updateStateWithTranslation(y translationY: CGFloat, tableViewContentOffsetY: CGFloat) {
        switch state {
        case .none:
            if tableViewContentOffsetY <= 0 {
                // Interaction開始できる状態になったら、現在のCollectionViewGestureのtranslationYを記憶し、Interaction中のstateへ更新
                // startInteractionTranslationYを記憶することで、TableViewスクロール中から連続的にDismissアニメーションにつなげることができる
                startInteractionTranslationY = translationY
                state = .hasStarted
                startHandler?()
            }
        case .hasStarted, .shouldFinish:
            // 初期位置よりも上へのスクロールの場合、インタラクション終了
            if translationY - startInteractionTranslationY < 0 {
                state = .none
                reset()
            }
        }
    }

    func changed(by offsetY: CGFloat) {
        changedHandler?(offsetY)
    }

    func finish() {
        finishHandler?()
    }

    func reset() {
        state = .none
        startInteractionTranslationY = 0
        resetHandler?()
    }
}

縦方向のスクロール量により状態を更新した結果をViewController側へハンドラ経由で伝えている。

CollectionSemiModalViewController
    /// OverCurrentTransitioningInteractorのセットアップ 各種ハンドラーのセット
    private func setupInteractor() {
        interactor.startHandler = { [weak self] in
            // CollectionViewCell内部のTableViewがバウンス出来ないように更新
            // この処理がないと、縦方向のDismissアニメーション中にCollectionViewCell内部のTableViewが一緒にスクロールしてしまう。
            self?.collectionView.visibleCells
                .compactMap { $0 as? CollectionSemiModalViewCell }
                .forEach { $0.updateBounces(false) }
        }
        interactor.changedHandler = { [weak self] offsetY in
            // 受け取ったOffsetYに合わせてcollectionViewを移動
            self?.collectionView.frame.origin = CGPoint(x: 0, y: offsetY)
        }
        interactor.finishHandler = { [weak self] in
            // Dismissアニメーションを開始
            self?.dismiss(isInteractive: true)
        }
        interactor.resetHandler = { [weak self] in
            // Dismissが中断された場合にCollectionViewを元の位置へ移動、CollectionViewCell内部のTableViewのバウンスを許可
            UIView.animate(withDuration: 0.3, delay: 0, options: [.curveEaseInOut], animations: {
                self?.collectionView.frame.origin = CGPoint(x: 0, y: 0)
                self?.collectionView.visibleCells
                    .compactMap { $0 as? CollectionSemiModalViewCell }
                    .forEach { $0.updateBounces(true) }
            }, completion: nil)
        }
    }

2はPresentと同様にUIViewControllerAnimatedTransitioningによる不可逆なDismissアニメーションを実装していきます。
実装内容はPresentと逆方向へのアニメーションです。

CollectionViewPresentAnimator
    // Dismissal Transition Animator
    private func dismissalTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let fromNC = transitionContext.viewController(forKey: .from) as! UINavigationController
        let fromVC = fromNC.viewControllers.first as! CollectionSemiModalViewController
        let toVC = transitionContext.viewController(forKey: .to) as! ViewController
        let containerView = transitionContext.containerView

        // 遷移元Cell関連
        let fromCells = fromVC.collectionView.visibleCells.compactMap { cell -> CollectionSemiModalViewCell? in
            guard let castCell = cell as? CollectionSemiModalViewCell else { return nil }
            castCell.switchTitleColorView(isClear: true)
            return castCell
            }.sorted(by:{ $0.tag < $1.tag })

        // 遷移先Cell関連
        let targetToIndexPath = IndexPath(row: fromVC.selectedIndex, section: 0)
        if toVC.collectionView.cellForItem(at: targetToIndexPath) == nil {
            // 遷移先対象Cellが画面外にいる場合、画面内にスクロールさせる。更にスナップショットをとることでcellForItemメソッドで参照可能な状態にしている。
            toVC.collectionView.scrollToItem(at: targetToIndexPath, at: .centeredVertically, animated: false)
            toVC.view.snapshotView(afterScreenUpdates: true)
        }
        let targetToCell = toVC.collectionView.cellForItem(at: targetToIndexPath)!
        let targetConvertFrame = targetToCell.convert(targetToCell.bounds, to: toVC.view)
        // TODO: minimumLineSpacingはLayoutによって実際のCell間隔とズレが生じる。改行があるため、単純に前後のCell.originの比較では無いため今回は妥協している。
        let cellSpacing = (fromVC.collectionView.collectionViewLayout as? UICollectionViewFlowLayout)?.minimumLineSpacing ?? 0
        var toCellDataList: [AnimationCellData] = []
        // PrevCell
        let prevTag = targetToCell.tag - 1
        if 0 <= prevTag {
            let prevCell = toVC.collectionView.cellForItem(at: IndexPath(row: prevTag, section: targetToIndexPath.section)) ?? UICollectionViewCell()
            prevCell.tag = prevTag
            toCellDataList.append(AnimationCellData(cell: prevCell, targetConvertFrame: targetConvertFrame, targetType: .prev, cellSpacing: cellSpacing))
        }
        // TargetCell
        toCellDataList.append(AnimationCellData(cell: targetToCell, targetConvertFrame: targetConvertFrame, targetType: .target, cellSpacing: cellSpacing))
        // NextCell
        let nextTag = targetToCell.tag + 1
        if nextTag < toVC.collectionView.numberOfItems(inSection: targetToIndexPath.section) {
            let nextCell = toVC.collectionView.cellForItem(at: IndexPath(row: nextTag, section: targetToIndexPath.section)) ?? UICollectionViewCell()
            nextCell.tag = nextTag
            toCellDataList.append(AnimationCellData(cell: nextCell, targetConvertFrame: targetConvertFrame, targetType: .next, cellSpacing: cellSpacing))
        }

        // AnimationView関連(fromVCからSnapshotを作成)
        let animationColorViews = toCellDataList.map { toCellData -> UIView in
            let view = fromCells.first(where: {$0.tag == toCellData.tag})?.titleColorView ?? UIView()
            let snapshotView = view.snapshotView(afterScreenUpdates: true) ?? UIView()
            snapshotView.frame = view.convert(view.bounds, to: toVC.view)
            snapshotView.tag = toCellData.tag
            snapshotView.backgroundColor = toCellData.color
            return snapshotView
        }
        let animationFromCells = toCellDataList.map { toCellData -> UIView in
            let cell = fromCells.first(where: {$0.tag == toCellData.tag}) ?? UIView()
            let snapshotCell = cell.snapshotView(afterScreenUpdates: true) ?? UIView()
            snapshotCell.frame = cell.convert(cell.bounds, to: toVC.view)
            snapshotCell.tag = cell.tag
            return snapshotCell
        }

        fromVC.view.isHidden = true
        animationFromCells.forEach { containerView.addSubview($0) }
        animationColorViews.forEach { containerView.addSubview($0) }

        UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, options:[.curveEaseInOut], animations: {
            animationFromCells.forEach { animationCell in
                animationCell.frame = toCellDataList.first(where: { $0.tag == animationCell.tag })?.frame ?? .zero
                animationCell.alpha = 0
            }
            animationColorViews.forEach { animationColorView in
                animationColorView.frame = toCellDataList.first(where: { $0.tag == animationColorView.tag })?.frame ?? .zero
            }
        }, completion: { _ in
            fromVC.view.isHidden = false
            fromCells.forEach { $0.switchTitleColorView(isClear: false) }
            animationFromCells.forEach { $0.removeFromSuperview() }
            animationColorViews.forEach { $0.removeFromSuperview() }
            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }

最後に

再現アプリでは遷移元、遷移先でのページングや詳細へのトランジション等実装していませんが、そこそこのボリュームとなりました。
特にPresent, Dismissアニメーションを実装しているCollectionViewPresentAnimatorが重いです。
仮にプロダクトへ導入する際には、該当アニメーション部分を簡略化しても良い気がしています。(単純な上下のSemiModalアニメーション 参考

また、今回Booksアプリの完全なアニメーションを再現できていません。タイルタップ時のアニメーションや、セミモーダルのスクロール時のアニメーション等では細かなインタラクションが散りばめられていました。機会があれば別の機会に再現したいと思います。

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

[iOS / Android] ハイブリッド&クロスプラットフォーム開発の簡潔なまとめ

はじめに

私はiOS Nativeの実務経験しかなく、ハイブリッドやクロスプラットフォーム開発の実務経験はありません。
しかし、それらのネット記事を読んで常に気になっており、後学のために簡潔に整理してみました。

ご注意

  • 私個人のネット記事観測に基づき独断でchoiceしました。
    • この記事で取り上げている以外にも、たくさんのツール・フレームワーク・サービスがあります。
  • 私の所属企業における立場、戦略、意見を代表するものではありません。
  • この記事の情報は2019年7月時点です。

お気付きの点がありましたら、コメントをお願いします。

なお、PWAを含む、WebサイトをWebView(内部ブラウザ)で表示する形式のアプリは当記事の対象外です。

ハイブリッド系

名称 提供元
PhoneGap/PhoneGap Build アドビシステムズ
Monaca アシアル
Ionic Ionic

共通の特徴

  • UIはWebViewで表示します。
  • HTMLとCSSでUIを構築し、実装はJavaScriptで行います。
  • すなわち、Webフロントエンドの知識を活用できます。
  • Native APIとの橋渡しをApache Cordovaが行います。
    • PhoneGapはアドビシステムズによるApache Cordovaの製品版です。
  • 上のようなフレームワークやサービスを使わないで、HTML+CSS+JS+Cordovaで開発する選択肢もあります。
  • ニュースアプリなど情報表示主体のアプリに向いています。
  • 一方、入力に対する反応速度や微細なアニメーションを求められるアプリには向きません。

PhoneGap BuildとMonacaの特徴

  • クラウド上で開発ができる統合サービスで、コンセプトはよく似ています。
  • いずれも、無料枠がありますが、制約があるので注意が必要です。

Ionicの特徴

  • 上記2つはクラウドサービスの部類ですが、Ionicはフレームワークです。
  • Angularをベースにしているため、TypeScriptの知識も必要となります。

クロスプラットフォーム系

名称 提供元
Xamarin マイクロソフト
React Native Facebook
Flutter Google
Kotlin/Native JetBrains

共通の特徴

  • UIがNativeであるため、反応速度や画面描画に違和感がなく、ハイブリッド系が苦手なアプリ分野に強みがあります。
  • 実現したい機能によっては、Native APIのラッパーを自作しなければならない場合もあり、その分の工数を見込まなければならない場合も。
  • UIの作成方法はツール・フレームワークよってコンセプトが異なります。

Xamarinの特徴

  • 開発言語はC#。
  • Visual Studioに統合されています。
  • Xamarin.iOS / Xamarin.Android
    • UIはOSごとに書き、ロジックのみを共通化します。
  • Xamarin.Forms
    • C#もしくはXAMLでUIを記述でき、同じUIのコードをOSごとのUIコンポーネントにマッピングします。

React Nativeの特徴

  • ReactはWeb UIフレームワークであり、ブラウザ上でのDOM制御を行う役割を担います。
  • React Nativeは、ReactをiOS/Android Nativeで利用できるようにしたもの。
  • 開発言語は、Reactと同様、JavaScriptと、JSXというマークアップ言語です。

Flutterの特徴

  • 開発言語はDart。
  • 2018年12月にVersion 1.0となり正式リリースとなりました。
  • "Skia"という、GoogleがOSSとして開発している2Dグラフィックライブラリを用いて、UIを独自に描画します。

Kotlin/Nativeの特徴

  • 開発言語はKotlin。
  • iOSについては、まだUI部分を作ることはできません。(2019年7月時点)
    • AndroidStudioで共通ロジックを書き、framework形式でexportして、Xcodeに組み込むことができます。

参考リンク

Apache Cordovaで本格スマホアプリに挑戦しよう
MonacaとPhoneGap Buildを試してみる
Ionicでのアプリ開発の始め方
Xamarin(ザマリン) とはなんぞや
10分間で分かった気になれるXamarin概要
React Nativeとは何なのか
Flutterとは? エヌ次元が企業としてFlutter開発を採用する理由
Kotlin/Native を Android/iOS アプリ開発に導入しよう
クロスプラットフォームモバイルアプリ開発ツール総ざらい2019

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

Flutterウィークリー #67

Flutterウィークリーとは?

FlutterファンによるFlutterファンのためのニュースレター
https://flutterweekly.net/

この記事は#67の日本語訳です
https://mailchi.mp/flutterweekly/flutter-weekly-67

※Google翻訳を使って自動翻訳を行っています。翻訳に問題がある箇所を発見しましたら編集リクエストを送っていただければ幸いです。

アナウンス

Flutter 1.7を発表

https://medium.com/flutter/announcing-flutter-1-7-9cab4f34eacf


Flutter 1.7はこちら! Tim Sneathが新機能の概要を説明します。

読み物&チュートリアル

Flutter波形を描く

https://matt.aimonetti.net/posts/2019-07-drawing-waveforms-in-flutter/


Flutterで波形を描画する方法についてのMatt Aimonettiによる詳細な説明。

Flutter時間依存性注入のコンパイル

https://sagarsuri56.hashnode.dev/compile-time-dependency-injection-in-flutter-cjxty3efh005fkvs14va5w339


Sagar Suriがあなたのアプリケーションにコンパイル時DIエンジンを含める方法を詳しく述べています。

Flutter材料範囲スライダ

https://medium.com/flutter/material-range-slider-in-flutter-a285c6e3447d


FlutterチームのAnthony Robledoによるこのチュートリアルで、 Flutter 1.7に含まれている空想の新しい範囲のスライダーの使い方を学びましょう。

Dartを使った定型コード生成

https://medium.com/@saifulislamadar_12003/boilerplate-code-generation-using-dart-e2c08aa21bb7


Saiful Islam Adarは、BLoCクラスを生成するために彼が作成したコードジェネレータへの説明をします。

FlutterとDialogflowを使用して20分でチャットボットを構築する

https://medium.com/flutter-community/build-a-chatbot-in-20-minutes-using-flutter-and-dialogflow-8e9af1014463


Promise Nzubechi Amadiが私のお気に入りの2つのツールFlutterとDialogflowを組み合わせて、20分でチャットボットを作成しました。

Flutter Eコマースアプリケーションバックエンドのコーディング

https://medium.com/flutter-community/coding-an-e-commerce-app-backend-in-flutter-9bd11ed5dcce


Rishi Banerjeeが、eコマースAPIを使用するアプリを作成するための基本を説明しています。

カスタムアプリケーションバー-作成Flutter

https://medium.com/@ketanchoyal/create-a-custom-app-bar-flutter-e32164e0be6f


Ketan Choyalによるこのチュートリアルに従って、独自のカスタムappbarsを作成してください。

Flutterドラッグ可能なウィジェットを作成する

https://medium.com/flutter-community/create-a-draggable-widget-in-flutter-50b61f12635d


Dane Mackierは、画面上でドラッグできるウィジェットを簡単に作成できることを示しています。

FlutterとZeplin:設計から開発プロセスをスピードアップ

https://medium.com/flutter-community/flutter-and-zeplin-speed-up-the-development-process-from-your-design-45ff5d21166a


RobertoJuárezは、デザインの各コンポーネントに対してFlutterウィジェットのコードを生成する素晴らしいZeplin拡張を作成しました。

ボトムシートを使用するためのFlutter初心者ガイド

https://medium.com/flutter-community/flutter-beginners-guide-to-using-the-bottom-sheet-b8025573c433


Dane Mackierによるこの記事で、ボトムシートの基本を学んでください。

Flutter状態管理:setState、BLoC、ValueNotifier、Provider

https://medium.com/coding-with-flutter/flutter-state-management-setstate-bloc-valuenotifier-provider-2c11022d871b


Andrea Bizzottoは最も一般的な状態管理ソリューションを比較し、それぞれの長所と短所を示します。

Flutter :カウンターアプリを理解する

https://medium.com/flutter-community/flutter-understanding-counter-app-ca89de564170


Flutterを始めとするあなたのために、Souvik BiswasはサンプルCounterアプリの詳細を分析します。

Flutterパッケージを作成、公開、管理する方法

https://medium.com/flutter-community/how-to-create-publish-and-manage-flutter-packages-b4f2cd2c6b90


natÇipliによるこのチュートリアルのおかげであなた自身のパッケージを作成し公開する方法を学びましょう。

Flutterプロジェクトを構築する方法

https://medium.com/@kelvengalvao/how-to-structure-your-flutter-project-51f34254a5ae


KelvenGalvãoさんが、 Flutter用のnpm風のパッケージマネージャ兼コードジェネレータSlidyを紹介します。

FlutterボーリングタブからFlutter

https://mightytechno.com/flutter-boring-tab-to-cool-tab/


Ishan Fernandoによる、さまざまなタブの作成方法に関するいくつかの例。

ビデオ&メディア

Async / Await - Flutterインフォーカス

https://www.youtube.com/watch?v=SmTCmDMi4BY


これは、 Dart非同期コーディングに関するFlutter in Focusシリーズの4番目のビデオです。このエピソードでは、 Dartの先物でasyncキーワードとawaitキーワードを使用する方法を学びます。

Flutterチュートリアル - Flutterチャート+ Firestore

https://www.youtube.com/watch?v=HGkbPrTSndM&feature=youtu.be


このビデオでは、chart_flutterプラグインを使用してチャートを作成し、チャートデータをFirestoreから取得する方法を説明します。

Flutter UI卑劣な私のキャラクター - パート1

https://www.youtube.com/watch?v=-5DTrcXxGs8&feature=youtu.be


Flutter卑劣な私のキャラクターのデモ。グラデーション、カスタムクリッパー、ヒーロートランジション、レスポンシブUIの作成を学びます。

Flutter - モバイル、Web、およびデスクトップアプリケーションに関するGoogleの最新の技術革新

https://www.youtube.com/watch?v=80pRyn7fZRk


ベルリンで開催されたWeAreDevelopersカンファレンスでのMartin AguinisとMatt Sullivanによる基調講演。

Dart & Flutterでpubパッケージを作成する方法

https://www.youtube.com/watch?v=rsbk0kb_tdE&feature=youtu.be


Flutterパッケージを作成して公開する方法の紹介。

FlutterとHugoでブログを作る - FunWith Devlog 01

https://www.youtube.com/watch?v=3VTTrGZrYS0&feature=youtu.be


Hugoファイルに変更が加えられるたびに自動的にFlutter Webアプリケーションを更新するためにHugoとFlutter for webを混在させる方法の例。

RichText(今週のFlutterウィジェット)

https://www.youtube.com/watch?v=rykDVh-QFfw&feature=share


複数のスタイルを組み合わせた線または段落を表示しますか? RichTextウィジェットを使用すると、テキストのスタイルを設定できます。

Flutter 1.7の新機能

https://www.youtube.com/watch?v=8U9eYVse2Hw


AndoidXおよび64ビットビルドのサポートを含む、 Flutter 1.7の新機能に関するビデオ。

ライブラリ&コード

aloisdeniel / flutter_shared_ui_poc

https://github.com/aloisdeniel/flutter_shared_ui_poc

フライングモバイルとウェブの間でuiを共有できることの証明。

ashishrawat2911 / flutter_web_portfolio

https://github.com/ashishrawat2911/flutter_web_portfolio


レスポンシブWebポートフォリオがフラッターで構築されています。

ButterCMS / buttercms-dart

https://github.com/ButterCMS/buttercms-dart

ButterCMS APIのDart SDK

csells / flutter_mplat_ttt

https://github.com/csells/flutter_mplat_ttt

Flutterマルチプラットフォームのサンプルゲーム

devrnt / book-library:

https://github.com/devrnt/book-library

AndroidとIOSの両方のための本図書館アプリ

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

アプリ開発にGoを利用する(Android/iOS/Flutter)

この記事の読み方

  • Android/iOS + Go
  • Flutter + Android/iOS ネイティブ
  • その組み合わせ

この3つから成る記事です。
複数のアプリを作りながら手順等を確認していきます。

Flutter は使わない、Go は知らない、といった方にも参考にしていただけると思います。

  • Android 開発者の方
    Flutter を使わない方は Android + Go までをご覧ください。

  • iOS 開発者の方
    iOS 開発環境がなく未検証のため、iOS の情報は少なめです。
    特に Flutter で使うために Objective-C/Swift で橋渡しする部分はほぼありません。
    それでも、Android での MethodChannel に近いと思われますので、雰囲気は掴めるはずです。
    ライブラリ作成自体や Dart/Flutter で使う部分は OS に関わらず共通です。

  • Flutter 開発者の方
    Go を使わない方は Flutter + AndroidFlutter + iOS をご覧ください。

細かなことは 付録 にまとめましたので、そちらも参考になさってください。

gomobileとは

Go をモバイルアプリ開発に活用できるという素晴らしい代物です。
Mobile · golang/go Wiki · GitHub

これを使って作れるアプリは二種類あります。

  • ネイティブアプリ
    Go だけで作るネイティブアプリ。

  • SDKアプリ
    Go で作ったライブラリを使って作るアプリ。

この記事で扱うのは SDK アプリのほうです。

SDKアプリ

gomobile によって Go のパッケージを基にバインディングが行われてライブラリ化されます。
Kotlin、Swift 等からライブラリを使えるだけでなく、逆方向に呼び出すこともできます。

ライブラリとして生成されるのは次のファイルです。

  • Android
    aar ファイル(Android Archive)

  • iOS
    framework ファイル(Framework Bundle)

Android では ARM / ARM64 / 386 / AMD64 のアーキテクチャに対応しています。
MIPS は非対応です。

Flutter を使い始めるまでは Android/iOS のロジックをこれで共通化して楽をしようと考えていました。

Goを使う理由

  • Dart でやりにくいことを Go に任せられる
    Go には有用なパッケージがあるのに、相当するものが Dart にない場合など。

  • Go が得意なことを Dart/Flutter に持ち込める
    Go は簡単に使える便利な標準ライブラリが豊富です。
    Goroutine による並行処理も得意です。
    サーバサイドで人気の Go をアプリで使えればコードを流用できます。

  • 実行速度の優位性
    Flutter では Dart のコードがネイティブのライブラリにコンパイルされる 1 ので速度に大きな差はなさそうに思えますが、試してみると違いがありました。2

  • C/C++ より扱いやすい
    C/C++ など Go 以外の言語でもライブラリは作れます。
    でも Go ならシンプルな文法、GC 等によって楽をして安全に書けます。

  • Dart より Go に慣れている人が書きやすい
    Dart を使ってみると Web でも使ってみたくなるような素敵な言語でしたが、好みの問題や人的リソースの都合があるので・・・。

  • 楽しい!
    楽しい Go と楽しい Dart/Flutter を組み合わせて使えるなんて至福(著者調べ)。

Goを使うデメリット

  • Android NDK が必要(Android のみ)3
  • アプリのサイズが大きくなる
  • 言語間のバインディングにオーバーヘッドがある 4
  • ターゲット言語側の制限により、エクスポートされた API の見た目に少し制限がある 4 5
  • 使える型が限られている
  • ライブラリ内に作った環境のパスが含まれる 6
  • gomobile 製ライブラリと Flutter を繋ぐ Java/Kotlin、Objective-C/Swift のコードも必要
  • Flutter がせっかくマルチプラットフォーム対応なのに Dart 以外も使うなんて面倒
  • ウェブアプリも作れる Flutter で Go を使うとモバイルアプリ限定になってしまう
  • ライブラリには compute() を使えず、重い処理だとメインスレッドがブロックされる

こう見ると結構ありますね。
メリットとデメリットのどちらが大きいか、ご自身で判断ください。

準備

Windows での手順になりますが、他の環境でもほぼ同じだと思います。7
Android/iOS の開発環境は既に用意されている前提です。

  1. Android NDK のインストール(Android のみ)
    Android Studio にて
    Tools > SDK Manager > 右ペインの SDK Tools タブ
    NDK にチェックが付いていなければ付けて OK または Apply

  2. gomobile のインストール
    コマンドプロンプトか PowerShell にて
    > go get -d golang.org/x/mobile/example/bind/...
    > gomobile init

これだけです。

-ndk /path/to/ndk という NDK のパス指定を説明しているサイトがありますが、

> gomobile init -ndk /path/to/ndk
flag provided but not defined: -ndk

のように怒られました。
NDK のパスを指定する必要はないようです。8
Windows 以外では未確認ですので、もし NDK のパスのエラーが出たら指定してみてください。

Goによるライブラリ作成

1. コード

非常にシンプルなライブラリを作ってみます。
わざわざ Go でライブラリにしたい類ではありませんが、あくまでわかりやすい例として。

  • 整数を受け取り、倍にした値を返す
  • 受け取る整数の範囲は 0 ~ 10 とする
  • 範囲外の値ならエラーを返す
  • 値を LogCat で確認できるように出力
simple.go
package simple

import "fmt"

func Multiply(value int32) (int32, error) {
    fmt.Println(value)

    if value < 0 || value > 10 {
        return 0, fmt.Errorf("value out of range: must be within the range of 0 to 10")
    }

    return value * 2, nil
}
  • これを GOPATH 以下のどこかに作ったフォルダの中に置く
  • パッケージ名がライブラリの名前になる(フォルダ名は関係ない)
  • Android/iOS のコードや Dart/Flutter から利用したい関数は、先頭を大文字にして export する
    → Android で使うときは先頭は小文字、先頭以外は Go で書いたまま
     [例] Go で GetHoge なら Android で使うときは getHoge(iOS では異なるようです)

関数にコメントを付けておいても、ライブラリの使用時にその情報を参照することはできませんでした。

整数型

Go の int はアーキテクチャに依存し、64 ビット実装の Go では 64 ビット の整数になります。
それに対応する Java と Objective-C の型は それぞれ LongnumberWithLong です。
IntegernumberWithInt にするには、より小さなサイズの int32 等を使いましょう。

型の対応 については付録にまとめています。

情報出力とエラーの扱い

複数の方法で動作を見てみると、かなり癖がありました。
基本的に次のように考えておけば大丈夫かと思います。
詳細は 付録 をご覧ください。

  • 情報を LogCat や Run のウィンドウに表示したい
    fmt.Println() を使う。
    fmt.Print()fmt.Printf() で第一引数の末尾に改行するのも OK。

  • Android/iOS や Dart/Flutter で例外として捕捉したい
    ライブラリで値を返すとき、二つ目の戻り値に error 型のデータを付ける。

他のポイント

長くなるので 付録 に収めました。

2. ライブラリ生成

Android では aar ファイル、iOS では framework ファイルを生成します。
次のようなパスになっているとします。

  • GOPATH
    C:\Go

  • simple.go
    C:\Go\src\hoge\gomobile_example\simple.go

生成には gomobile bind を使います。
ファイルのあるディレクトリを指定する方法と指定しない方法があります。
Android 向けに生成する場合は下のようになります。

コマンド

(a) ディレクトリへの相対パスを指定して生成する場合
※最後の引数は GOPATH/src/ からの相対パス です。
※Windows でもスラッシュ区切りにしないとエラーになりました。

> gomobile bind -target android hoge/gomobile_example

(b) ディレクトリに移動してから生成する場合

> cd C:\Go\src\hoge\gomobile_example\simple.go
> gomobile bind -target android

オプション

  • -o
    出力先を指定するには -o を使います(例: -o path/to/library.aar)。
    ここで指定するパスは カレントディレクトリからの相対パス ですのでご注意ください。
    (a) のほうでは相対パスの起点がややこしいので (b) がオススメです。

    なお、パスにはファイル名まで含める必要があります。
    また、存在しないディレクトリを指定した場合、自動的に作ってくれるわけではありません。

  • -target android/arm64
    Android ではターゲットのアーキテクチャも指定できます。
    スラッシュの後ろは armarm64386amd64 のいずれかです。
    指定しない場合、サポートする全4アーキテクチャの so ファイルを含んだ aar ファイルになります。9

  • -target ios
    iOS では -targetios を指定します。
    Android のようなアーキテクチャの指定には対応していないようです。
    そもそも幅広いバリエーションがあるわけでもないので不要ですね。


  • オプションは他にもあり、gomobile bind -h で確認できます。

ビルド時間、aarファイルのサイズ

これくらい小規模のコードを普段 Go でビルドするときと比べて長くかかります。
環境によりますが、私の PC で Android 向けにビルドしたところ 40 秒ほどでした。

また、aar 内の共有ライブラリ(.so)が一つあたり 2MB 以上、圧縮状態で 1MB 程度になりました。
aar ファイルには 4 アーキテクチャ分が入っていて計 4MB 台です。大きめですね。
ユーザが Google Play ストアからダウンロードするときにはもっと小さくなります。9

Android + Go

ライブラリ導入

Android Studio を使っていきます。
使わずに、次の 1 ~ 2 に載せた diff を参考にしてファイル追加や記述変更を手動で行っても OK です。

1. ライブラリのモジュールを追加

モジュールとは、プロジェクトを分割した機能ごとのアプリのようなものです。
ここでは、ライブラリを一つのモジュールとしてプロジェクトに追加します。

  1. 起動後のウィンドウで「Start a new Android Studio project」を選ぶ

  2. 開いたウィザードで「Empty Activity」を選び、プロジェクトが開くところまで進める

  3. New Module のダイアログを開く

  4. 「Import .JAR/.AAR Package」を選んで「Next」

  5. フォルダアイコンを押し、先ほど生成された aar ファイルを指定してから「Finish」

    Subproject name のところには自動的にサブプロジェクト(モジュール)の名前が入ります。
    自分で変えても良いでしょう。

    これで simple モジュールが追加された状態になりました。

ここまでの操作による変化は次のとおりです(Android Studio 関連ファイルは省いています)。

settings.gradle
-include ':app'
+include ':app', ':simple'
simple/build.gradle
new file mode 100644
+configurations.maybeCreate("default")
+artifacts.add("default", file('simple.aar'))
\ No newline at end of file
simple/simple.aar
new file mode 100644

2. 追加したモジュールを使う設定

追加しただけでは使えません。
メインのモジュールである app から simple を利用できるように依存関係の設定を行います。

  1. Project Structure のダイアログを開く

  2. Dependencies > app を選び、右ペインで「+」を押して「Module Dependency」を選ぶ

  3. 「simple」にチェックをつけて「OK」を押す

  4. 右ペインに「simple」が追加されているのを確認して「OK」を押す

使うための設定はこれで完了です。
この操作による変化は次のとおりです。

app/build.gradle
 dependencies {
     testImplementation 'junit:junit:4.12'
     androidTestImplementation 'com.android.support.test:runner:1.0.2'
     androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
+    implementation project(path: ':simple')
 }

ライブラリを使う

Simple ライブラリを実際に使ったアプリを作ります。

gomobile_android.gif

app/src/main/res/values/strings.xml
<resources>
    <string name="app_name">GomobileAndroid</string>
    <string name="button">Tap here!</string>
</resources>
app/src/main/res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:gravity="center">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        android:textSize="32sp"/>
    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/button"/>

</LinearLayout>
app/src/main/java/com/example/gomobile/gomobileandroid/MainActivity.kt
import simple.Simple  // これ以外のインポートは割愛

class MainActivity : AppCompatActivity() {
    private lateinit var textView: TextView
    private var value = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        textView = findViewById(R.id.textView)
        updateText()

        findViewById<Button>(R.id.button).setOnClickListener {
            value++
            updateText()
        }
    }

    private fun updateText() {
        try {
            textView.text = Simple.multiply(value).toString()
        } catch (e: Exception) {
            Log.e("MainActivity", e.message)
        }
    }
}

とても簡単ですね。
ポイントは下記箇所のみです。

import simple.Simple

....

try {
    textView.text = Simple.multiply(value).toString()
} catch (e: Exception) {
    Log.e("MainActivity", e.message)
}

ライブラリのメソッドを使っているだけです。
そのメソッドではエラー時に例外を発生させるようにしているため trycatch を使っています。

ボタンを 12 回押したときの LogCat の出力は下のようになります(途中省略)。
端末画面上の表示は 10 回目の「20」で止まります。

07-14 13:58:37.951 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 1
07-14 13:58:38.246 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 2
...
07-14 13:58:41.244 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 10
07-14 13:58:41.630 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 11
07-14 13:58:41.635 1622-1622/com.example.gomobile.gomobileandroid E/MainActivity: value out of range: must be within the range of 0 to 10
07-14 13:59:00.962 1622-1688/com.example.gomobile.gomobileandroid I/GoLog: 12
07-14 13:59:00.966 1622-1622/com.example.gomobile.gomobileandroid E/MainActivity: value out of range: must be within the range of 0 to 10

ライブラリを使う記述をする際のコード補完等については 付録 をご覧ください。

iOS + Go

iOS 開発環境がないため動作は未確認です。

ライブラリ導入

生成した framework ファイルを Xcode でプロジェクトに導入する方法は Wiki に書かれています。
参考にしながら導入してみてください。

ライブラリを使う

Wiki には挨拶のテキストを出力するサンプルコードのスクリーンショットがあります。
ライブラリを利用するためのメソッド使用箇所は次のようになっています。

bind/ViewController.m(スクショより)
textLabel.text = GoHelloGreetings(@"iOS and Gopher");

しかし こちらのサンプル ではメソッド名が異なります。

bind/ViewController.m(サンプルより)
textLabel.text = HelloGreetings(@"iOS and Gopher");

いずれかの情報がアップデートされていなくて古いのかもしれません。

なお、Go で書いた関数 は下記のとおりです。
上記の二つのメソッド名はどちらも、この元の関数名と異なります。
iOS で使うときにはその点の注意が必要です。

hello/hello.goの一部
func Greetings(name string) string {
    return fmt.Sprintf("Hello, %s!", name)
}

他にも異なる部分があるかもしれませんので、サンプル全体を一度ご確認ください。

Flutter + Android

Go 製ライブラリを Flutter で使う前に、Android 側に書いた機能を Flutter で使う方法を見てみます。
Flutter と Android/iOS の間で連携できるようにする Platform Channel というものを使います。

この図は上記リンク先より拝借したものです。
Platform Channel というのはこの全体の仕組みのことだと思われます。
使うのは MethodChannel(iOS 側だけは FlutterMethodChannel)というものです。

1. Android側(使う機能の作成)

Go で作ったライブラリと同様の機能にしてみます。
まず Flutter の新しいプロジェクトを作りますが、今回も simple という名前にしておきます。

Kotlin をサポートするプロジェクトにするには、Android Studio のウィザードで「Include Kotlin support for Android code」にチェックを付けるか、flutter create コマンドで -a kotlin を付けます。

private fun multiply(value: Int): Int? {
  return if (value in 0..10) value * 2 else null
}

受け取る値が 0 ~ 10 の範囲なら倍数、範囲外なら null を返します。

2. Android側(連携処理)

作ったメソッドを Flutter から使えるように Android 側に連携処理を書きます。
そのために用意されている MethodChannel を使います。

android/app/src/main/kotlin/com/example/simple/MainActivity.kt
class MainActivity: FlutterActivity() {
...
    val methodChannel = MethodChannel(flutterView, "example.com/simple")
    methodChannel.setMethodCallHandler { call, result ->
      when {
        call.method == "simple_multiply" -> {
          val v = call.argument<Int>("value") ?: 0
          val r = multiply(v)
          result.success(r)
        }
        else -> result.notImplemented()
      }
    }
...
}
  • MethodChannel(flutterView, "example.com/simple")
    第二引数の example.com/simple はチャンネルの名前です。
    Flutter のほうでも同じ名前を使うことでやり取りできるようになります。

  • call.method == "simple_multiply"
    simple_multiply は Flutter から機能を呼び出すときの名前です。
    使いたいメソッドが multiply() なので、それを使うことがわかる名前にしました。
    そのようなわかりやすい名前であれば何でも大丈夫です。

  • call.argument("value")
    Flutter 側から渡された引数を取り出す部分です。
    value という引数名を Android と Flutter で共通使用する必要があります。
    受け取った値は null の場合もあるため、そのことを考慮しておく必要があります。

  • result.success(~)
    成功したときに結果を返す処理です。
    ( ) 内に指定した値を Flutter 側で受け取ることができます。

  • result.error("エラーコード", "エラーメッセージ", "エラー詳細")
    上のコードにはありませんが、これを使うと Flutter 側で PlatformException になります。
    各引数に指定する情報を Flutter で取得できます。
    第3引数は Object 型なので String に限りません(使わないなら null で OK)。

  • result.notImplemented()
    存在しない名前で機能を呼び出された場合にこれを使っています。
    このとき Flutter 側で MissingPluginException として捕捉することができます。

MainActivity 全体のコードは次のようになります。

android/app/src/main/kotlin/com/example/simple/MainActivity.kt
package com.example.simple

import android.os.Bundle

import io.flutter.app.FlutterActivity
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant

class MainActivity: FlutterActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    GeneratedPluginRegistrant.registerWith(this)

    val methodChannel = MethodChannel(flutterView, "example.com/simple")
    methodChannel.setMethodCallHandler { call, result ->
      when {
        call.method == "simple_multiply" -> {
          val v = call.argument<Int>("value") ?: 0
          val r = multiply(v)

          if (r == null) {
            result.error("Out of range", "value must be within the range of 0 to 10", v)
          } else {
            result.success(r)
          }
        }
        else -> result.notImplemented()
      }
    }
  }

  private fun multiply(value: Int): Int? {
    return if (value in 0..10) value * 2 else null
  }
}

multiply() の結果が null のときは result.error() でエラーにしています。

3. Flutter側(ヘルパークラス)

Flutter 側でも MethodChannel を使います。
使うためには package:flutter/services.dart のインポートが必要です。

UI のコードにロジックが混じらないように、Simple というヘルパークラスを作ることにしました。

lib/simple.dart
import 'package:flutter/services.dart';

class Simple {
  static const _platform = MethodChannel('example.com/simple');

  static Future<int> multiply(int count) async {
    final arguments = {'value': count};

    try {
      return await _platform.invokeMethod<int>('simple_multiply', arguments);
    } on PlatformException catch (e) {
      print(e);
    } catch (e) {
      print(e);
    }

    return null;
  }
}
  • MethodChannel('example.com/simple')
    Android 側で設定したのと同じチャンネル名を指定します。

  • Future<int> multiply(int count) async
    Android 側から返ってくるのは Future です。

  • final arguments = {'value': count};
    Android 側に値を渡すには、このように Map にする必要があります。
    キーは Android 側で設定した名前に合わせます。

  • return await platform.invokeMethod('simplemultiply', arguments);
    第一引数は Android 側で設定した呼び出し名です。
    第二引数には渡したい引数の Map を指定します。
    await はここでしないと例外を補足できません。
    「invoke」で始まるメソッドは他に invokeListMethod()invokeMapMethod() があります。

  • on PlatformException catch (e)
    Android 側で result.error() に指定した情報をここで得ることができます。

    • e.code エラーコード
    • e.message エラーメッセージ
    • e.details エラー詳細

4. Flutter側(完成)

ヘルパークラスを使うメインのファイルは次のようにしました(一部省略)。

lib/main.dart
import 'simple.dart';
...
class _MyAppState extends State<MyApp> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              FutureBuilder<int>(
                future: Simple.multiply(_count),
                initialData: 0,
                builder: (_, snapshot) {
                  return Text(snapshot.hasData ? snapshot.data.toString() : '--');
                },
              ),
              RaisedButton(
                onPressed: () {
                  setState(() => ++_count);
                },
                child: const Text('Tap Here!'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

カウンターの値を Dart で持ち、ボタンが押されたときにインクリメントします。
その際に setState() しているため全体がリビルドされます。
都度 Simple.multiply(_count) が実行され、返ってきた FutureFutureBuilder で処理しています。

multiply() に渡す値が 11 以上だとエラーになり、Flutter 側では例外が発生します。
アプリを起動してボタンを 11 回押すと、Run ウィンドウに次のように出力されました。

I/flutter ( 3664): PlatformException(Out of range, value must be within the range of 0 to 10, 11)

例外については、付録 にもう少し細かく書いています。

Hot Reload/Restart

Hot Reload/Restart は Flutter の機能です。
Android 側の処理を変えたときには当然 Hot Restart しても反映されません。

しかし Android 側はしっかりと書いてしまえばその後はあまり変えることはないはずです。
さほど不便ではないと思います。

Flutter + iOS

flutter.dev のドキュメント を参考にしてみてください。
iOS ホスト側でバッテリーの情報を取得して Flutter で利用する方法が解説されています。

Android の MethodChannel に相当するものは iOS では FlutterMethodChannel です。
環境の都合で未検証ですが、コードを見ると MethodChannel の使い方に近いです。
チャンネルや呼び出しの名前を設定する点や、エラー時にコード等3種類の情報を返せる点が同じです。
OS によって Flutter 側の書き方を変えなくていいように共通化されているようです。

Flutter + Android + Go

Go で書いた処理を Flutter で使うのはもうここまでのことを組み合わせるだけです。
操作に関して少しだけ違いがあります。

Flutter のプロジェクトで Android の MainActivity.kt を開くと、ライブラリが認識されません。
上のスクリーンショットでは Nudity が赤くなっています。
右上に出るリンクで Android のプロジェクトを開き、ライブラリの導入等の操作はそちらで行いましょう。

Simple カウンター

Go で作った Simple ライブラリを Flutter で使ってみます。

Flutter + Android のコードと違うのは下記の trycatch の部分だけです。
ライブラリのエラーによって発生した例外を Android 側 で catch し、result.error() を使って Flutter 側でも catch できるようにしました。

MainActivity.ktの一部
val methodChannel = MethodChannel(flutterView, "example.com/simple")
methodChannel.setMethodCallHandler { call, result ->
  when {
    call.method == "simple_multiply" -> {
      val v = call.argument<Int>("value") ?: 0
      try {
        result.success(Simple.multiply(v))
      } catch (e: Exception) {
        result.error("Go Simple", e.message, null)
      }
    }
    else -> result.notImplemented()
  }
}

このように、本当にここまでの技術の組み合わせるだけでできてしまいます。

ヌード写真判定

Awesome Gogo-nude という Go のパッケージを見つけました。
nude.js というライブラリを Go に移植したものだそうです。

JavaScript でできるなら Dart でもできそうですが、まだ存在しないようです。
使いたくてもまだ無いケースとして Go の利用が適していると考えました。

flutter_nudiry.gif

※このスクリーンキャスト内では go-nude の example/images/ にある画像を使いました。

※実用性は低そうです。
 判定が厳しすぎるかと思ったら、逆に景色の写真がヌードと判定されることもあったりします…。10

nudity.go
package nudity

import (
    "github.com/koyachi/go-nude"
)

const (
    Unknown int = iota
    IsNotNude
    IsNude
)

func Check(path string) (int, error) {
    isNude, err := nude.IsNude(path)
    if err != nil {
        return Unknown, err
    }

    if isNude {
        return IsNude, nil
    }

    return IsNotNude, nil
}
MainActivity.ktの一部
val methodChannel = MethodChannel(flutterView, "example.com/nudity")
methodChannel.setMethodCallHandler { call, result ->
  when {
    call.method == "nudity_check" -> {
      val imagePath = call.argument<String>("imagePath")
      result.success(Nudity.check(imagePath))
    }
    else -> result.notImplemented()
  }
}
nudity.dart
import 'package:flutter/services.dart';

class Nudity {
  static const _platform = MethodChannel('example.com/nudity');

  static const unknown = 0;
  static const isNotNude = 1;
  static const isNude = 2;

  static Future<int> check(String path) async {
    final arguments = {'imagePath': path};
    return await _platform.invokeMethod<int>('nudity_check', arguments);
  }
}

画像選択は Flutter で行い、画像パスをライブラリに渡して判定結果の数値を受け取っています。
ここまでに見てきたことと大差なく、特筆することはありません。
例外処理は省きました(以下同様)。

画像変換

画像変換は時間がかかることがあります。
でもグレースケール変換くらいは一瞬でできてほしいところです。

ところが、Dart で変換してみると待たされてしまいました(画像サイズ等にもよります)。
こういったものは Go でやれば速くなるのではないかと考えました。

flutter_grayscale.gif

grayscale.dart
package grayscale

import (
    "bytes"
    "fmt"
    "github.com/anthonynsimon/bild/effect"
    "github.com/anthonynsimon/bild/imgio"
    "image/jpeg"
)

func Convert(path string) ([]byte, error) {
    img, err := imgio.Open(path)
    if err != nil {
        return nil, fmt.Errorf("failed to open image: %v", err)
    }

    img = effect.Grayscale(img)

    buf := new(bytes.Buffer)
    err = jpeg.Encode(buf, img, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to save image: %v", err)
    }

    return buf.Bytes(), nil
}
MainActivity.ktの一部
val methodChannel = MethodChannel(flutterView, "example.com/grayscale")
methodChannel.setMethodCallHandler { call, result ->
  when {
    call.method == "grayscale_convert" -> {
      val path = call.argument<String>("imagePath")
      result.success(Grayscale.convert(path))
    }
    else -> result.notImplemented()
  }
}
grayscale.dart
import 'dart:typed_data';
import 'package:flutter/services.dart';

class GrayScale {
  static const _platform = MethodChannel('example.com/grayscale');

  static Future<Uint8List> convert(String path) async {
    final arguments = {'imagePath': path};
    return await _platform.invokeMethod<Uint8List>('grayscale_convert', arguments);
  }
}

MainActivity は先ほどとほとんど同じです。
Go では変換後の画像を []byte 型にして返します。
それを Kotlin では ByteArray、Dart では Uint8List として受け取っています。
Uint8List のデータは Flutter で Image.memory() にそのまま渡して画像表示できます。

リリースビルドして大きめの画像を変換したところ、所要時間に差が出ました。

Dart Go
1 回目 11.96 秒 1.55 秒
2 回目 11.92 秒 1.41 秒
3 回目 11.89 秒 1.44 秒
4 回目 11.96 秒 1.46 秒
5 回目 11.94 秒 1.49 秒
  • 利用したパッケージ

メインスレッドのブロッキング

画像変換の間に CircularProgressIndicator を表示すると、クルクル回るアニメーションが止まりました。
そこで compute() を使うようにしてみたのですが、ライブラリのほうに使うと例外が発生しました。
解決法は不明です。
これが解決できないと辛い場合があるかもしれません。

ojichat

これで最後です。

Go のパッケージに ojichat というものがあります。
おじさんがLINEやメールで送ってきそうな文を生成してくれる楽しいパッケージです。

これを使ったチャット風アプリが簡単にできました。
コードは省略します。

flutter_ojichat.gif

付録

ここまでに書いた以外に知っておくと良いことをまとめました。

gomobileのポイント

型の対応

Go からエクスポートするものは全てサポートされている型である必要があります。
Type restrictions(gobind - GoDoc)

  • 符号付きの整数型・浮動小数点型

  • 文字列型、論理型

  • byte スライス型
    参照渡しとなり、渡した先での変更は元のスライスに反映されます。

  • 関数型
    仮引数や戻り値はサポート対象の型にすること。
    戻り値を二つにする場合、二つ目は error 型に限られます。

  • インタフェース型
    export されるメソッドはサポート対象の関数型にすること。

  • 構造体型
    export されるメソッドはサポート対象の関数型にすること。
    export されるフィールドはサポート対象の型にすること。

サポートされている型は以上です。

スライスとマップが含まれていないのが気になるので試すと、やはりどちらもダメでした。
Go ではよく使うものなので、これらが使えないのはちょっと不便かもしれません。

また、byte スライス型の「参照渡し」も試しました。
Kotlin から受け取って中身を Go で変えると Kotlin 側でも変わっていました。
しかし、逆だと変化がありませんでした。
(Kotlin に不慣れで、扱い方を間違えた可能性もあります。)

情報出力、エラー

★印が付いている二つのどちらかを用途に合わせて使いましょう。

  • fmt.Print("message") fmt.Printf("%s", "message")
    意外なことに、何も起こりませんでした。

  • fmt.Println("message") fmt.Printf("message\n")
    LogCat や Flutter の Run ウィンドウに Info レベルの情報として出てきます。
    タグは「GoLog」です。
    Printf() でも Println() と同じ意味になるように末尾に改行を置けば OK のようです。

  • fmt.Print("message\nhoge") fmt.Printf("%s\nhoge", "message")
    なんと!
    メッセージの途中に改行があると、順序が逆転して「hoge message」になりました…。

  • 関数の二つ目の戻り値としてエラーを返す
    Android 側や Flutter 側で例外として補足できます。
    例外処理をしない場合、Flutter ではないネイティブの Android アプリは異常終了します。

    一方 Flutter のアプリは、Android 側で例外処理をし忘れていても生き続けます。
    アプリが丸ごと落ちないように対策されているようで、例外の情報が出力されるだけです。
    その場合、Flutter 側では MissingPluginException となります。

    Android 側で例外を無視して Flutter でハンドルすることもできますが、微妙です。
    ライブラリの異常は全て上記例外となり、種類をメッセージで判別するしかなくなります。
    それよりもきちんと Android 側で対応したほうが良さそうです。

  • log.Fatal("message") log.Fatalf("message") log.Fatalln("message")
    os.Exit(1) を呼ぶものなのでアプリごと終了します。
    その際、指定したメッセージが Info レベルの情報として出力されます。

    改行の有無は関係なく情報が出力されました。
    また、途中に改行があっても出力順序は逆転せず、改行は改行として出ました。

  • panic("message")
    Android 側を巻き込んで異常終了してしまいます。
    その際、指定したメッセージが Error レベルの情報として出力されます。
    タグは「GoLog」ではなく「Go」です。

    当然ですが、次のように recover() で回復させれば異常終了は防げます。

defer func() {
    if r := recover(); r != nil {
        fmt.Println(r)
    }
}()

構造体、レシーバー

Passing Go objects to target languages(gobind - GoDoc)

この記事で見てきた例では、値を保持するのは Flutter 側や Android 側でした。
実験的に Go のライブラリ内で状態保持させてみたいと思います。
ライブラリのパッケージ内の変数に持たせれば簡単ですが、あえて構造体を使ってみます。

counter.go
package counter

type GoCounter struct {
    value int32
}

func NewGoCounter() *GoCounter {
    c := new(GoCounter)
    c.value = 0
    return c
}

func (c *GoCounter) Increment() int32 {
    c.value++
    return c.value
}

NewGoCounter()GoCounter という構造体を初期化してそのポインタを返します。11
それをレシーバーとする Increment() では、構造体が持つ value の値が 1 増やして結果を返します。

Java や Dart にレシーバーはありませんが、どうなるのでしょうか。
上記コードで作ったライブラリを Android のプロジェクトに導入し、デコンパイルした情報を見てみます。
見方については バインディングの中身 を参照してください。

Counter.class(デコンパイル)
package counter;

public abstract class Counter {
    private Counter() { /* compiled code */ }

    public static void touch() { /* compiled code */ }

    private static native void _init();

    public static native counter.GoCounter newGoCounter();
}

Counter というクラスが作られ、newGoCounter() をメソッドとして持っているのがわかります。
newGoCounter() が返すのは GoCounter 型であり、ポインタではありません。

GoCounter のクラスも作られているので見てみましょう。

GoCounter.class(デコンパイル)
package counter;

public final class GoCounter implements go.Seq.Proxy {
    private final int refnum;

    public final int incRefnum() { /* compiled code */ }

    public GoCounter() { /* compiled code */ }

    private static native int __NewGoCounter();

    GoCounter(int i) { /* compiled code */ }

    public native int increment();

    public boolean equals(java.lang.Object o) { /* compiled code */ }

    public int hashCode() { /* compiled code */ }

    public java.lang.String toString() { /* compiled code */ }
}

こちらには自分で書かなかったプロパティやメソッドも含まれています。
それよりも注目すべきはコンストラクタです。
NewGoCounter という名前から判断して勝手にコンストラクタを用意してくれています。

また、もし構造体のフィールドを export していた場合にはゲッターとセッターが自動的に用意されます。
今回は value を export していないので含まれていません。

次は Android 側です。

MainActivity.kt
import counter.GoCounter
...
val methodChannel = MethodChannel(flutterView, "example.com/counter")
methodChannel.setMethodCallHandler { call, result ->
  when {
    call.method == "counter_init" -> {
      goCounter = GoCounter()
    }
    call.method == "counter_increment" -> result.success(goCounter.increment())
    else -> result.notImplemented()
  }
}

GoCounter の初期化は Counter.newGoCounter() でもできますが、先ほどのコンストラクタを使って GoCounter() としました。
これにより、Counter は使わずに済みました。

GoCounter のインスタンスを Flutter に渡せると良いのですが、無理でした。
Java/Kotlin のオブジェクトを渡す何らかの方法ができるかもしれません。
代わりに Android 側で保持しておくことにしました。

Flutter 側のヘルパークラスは次のようにしました。

gocounter.dart
import 'package:flutter/services.dart';

class Counter {
  static const _platform = MethodChannel('example.com/counter');

  static Future init() async {
    await _platform.invokeMethod('counter_init');
  }

  static Future<int> increment() async {
    return _platform.invokeMethod<int>('counter_increment');
  }
}

ライブラリの increment() を呼び出し、結果を UI に反映するだけで済みます。
Flutter側で状態管理しないでカウンターのアプリが実現しました。
残りのコードは割愛します。

インタフェース

gobind のドキュメント のコード例がわかりやすいので抜粋します。

Goのインタフェース
package myfmt

type Printer interface {
    Print(s string)
}

func PrintHello(p Printer) {
    p.Print("Hello, World!")
}
bindによって自動生成されるJavaのインタフェース
public abstract class Myfmt {
    public static void printHello(Printer p0);
}

public interface Printer {
    public void print(String s);
}
Javaでインタフェースを実装して利用
public class SysPrint implements Printer {
    public void print(String s) {
        System.out.println(s);
    }
}

Printer printer = new SysPrint();
Myfmt.printHello(printer);
  1. Go で書いたライブラリに Printer というインタフェースがある
  2. そのインタフェースを Java で実装して SysPrint クラスとする
  3. インタフェースが持つメソッドである print() を SysPrint 内で具象化する
  4. SysPrint のインスタンスを生成し、それをライブラリの PrintHello() に渡す
  5. PrintHello() の結果が SysPrint.print() を使って出力される

Go と Java/Kotlin ではインタフェースの書き方が大きく異なります。
それにもかかわらず、違和感なく使えるようにうまくできていますね。

先ほどの 構造体、レシーバー のところでもそうでしたが、言語間の差異がうまく緩衝されているのがわかると思います。

Goからアプリ側ネイティブAPIへのアクセス

Reverse bindings(gobind - GoDoc)

ここまでとは逆に Go から Java や Objective-C で用意された API にアクセスできる旨が書かれています。
上記ページには次のような例が掲載されています。

java.lang.SystemをGoで読み込んでcurrentTimeMillisメソッドを利用
import "Java/java/lang/System"

t := System.CurrentTimeMillis()

実際にやってみると確かにできました。
ただし、GoLand 等の IDE では存在しないメソッドのように扱われ、利用しにくかったです。

NSDateをGoで読み込んでdateメソッドを利用
import "ObjC/Foundation/NSDate"

d := NSDate.Date()

これだけに留まらず、例えば Android なら次のように Go で Activity を継承することもできるようです。
面白いですね。

GoでAndroidのActivityを継承してMainActivityを作る
import "Java/android/app/Activity"

type MainActivity struct {
    app.Activity
}

メモリリークの危険性

Avoid reference cycles(gobind - GoDoc)

今見たように、Go とターゲットの間で双方向にデータをやり取りできます。
片方が他方のオブジェクトへの参照を持っている場合、そのオブジェクトへのアクセスがなくなると、オブジェクトの実体を持っているほうの言語で GC によって適切に参照が破棄されるようです。12

しかし、もし参照を相互に持っているとオブジェクトを回収できなくなり、メモリリークが発生します。
そんなことはあまりしないと思いますが、ちょっと注意が必要なところだと思います。

Flutter側の例外処理

Flutter + Android で扱ったコードを使って見ていきます。

simple.dart の on PlatformException catch (e) のブロックを変えてみましょう。
次のように変えると、Android 側の result.error() で指定した情報がちゃんと出力されます。

lib/simple.dartの一部を改変
on PlatformException catch (e) {
  print(e.code);
  print(e.message);
  print(e.details);
}
I/flutter ( 3664): Out of range
I/flutter ( 3664): value must be within the range of 0 to 10
I/flutter ( 3664): 11

今後はチャンネル名を変えてみます。
「simple」を「hoge」に変えると MissingPluginException が出ました。
括弧内を訳すと「example.com/hoge チャンネルには simple_multiply メソッドの実装が見つからない」です。

lib/simple.dartの一部を改変
static const _platform = MethodChannel('example.com/hoge');
I/flutter ( 3664): MissingPluginException(No implementation found for method simple_multiply on channel example.com/hoge)

最後に trycatch を使わないようにしてみます。

lib/simple.dartの一部を改変
final arguments = {'value': count};
return await _platform.invokeMethod<int>('simple_multiply', arguments);

ボタンを 11 回以上押してもチャンネル名を変えても、何も出力されませんでした。
意図的に無視することもできるようになっているようです。
しかし、異常に気づいて対応できるように trycatch しておくのが良いと思います。

Docker

go4droid/Dockerfile at master · mpl/go4droid · GitHub
https://github.com/mpl/go4droid/blob/master/Dockerfile

gomobile の Wiki からリンクされている Dockerfile です。
既存環境を汚したくない方にはおすすめです。
また、Go で作ったライブラリに環境の情報(パス)が含まれるのを気にする方は対策に使えます。

ただし、ファイルの中身を見ると対象の環境が古いです。
Android や Go のバージョンを書き換えて使う必要があると思います。

Android Studioについて

バインディングの中身

自作ライブラリであっても、Android Studio は使い方がわかるように補助してくれます。13

MainActivity のコードの中でライブラリのクラス名にカーソルの上で
右クリック > Go To > Declaration
と操作すると、ライブラリの class ファイルをデコンパイルしたものが表示されます。

Simple.class(デコンパイル)
package simple;

public abstract class Simple {
    private Simple() { /* compiled code */ }

    public static void touch() { /* compiled code */ }

    private static native void _init();

    public static native int multiply(int i) throws java.lang.Exception;
}

最初に見たサンプル(Simple ライブラリ)だと次のようになります。
作ったライブラリを Java/Kotlin でどう使えばいいのかわかりやすくて助かりますね。

  • int multiply(int i)
    仮引数も戻り値も int になっています。
    これは Go で Multiply(value int32) int32 のように int32 を使ったためです。
    64 ビットの Go で Multiply(value int) int とすると long multiply(long l) になります。

  • throws java.lang.Exception
    multiply() で二つ目の戻り値によってエラーを返さない場合、例外はスローされません。

デコンパイルで得られたクラス/メソッド等の定義の情報は
View > Quick Definition
の操作でも表示されます。

コード補完やクラス・メソッド等の情報表示もしてくれて助かります。

ライブラリの更新方法

ライブラリの中身を変えた場合、aar ファイルを上書きするだけで変更が適用されます。
ただし、Android Studio はその変更をすぐに認識しません。
変更をコード補完などにも反映するには、プロジェクトを開き直す必要があります。

その方法で反映されないときは、app の build.gradle から

implementation project(path: ':simple')

を消してから再追加し、プロジェクトの sync をしたところ、ようやく反映されました。
少し手間ですが、そこまですると確実です。

Android App Bundle

Go で作ったライブラリはサイズが大きめになりがちです。
特に複数のアーキテクチャ向けのファイルが含まれていると大きくなります。

少しでもユーザにやさしいサイズになるよう、ストアには APK ではなく App Bundle にしましょう。
そうすれば、必要なアーキテクチャの APKs にしてくれたり、モジュール単位のダウンロードが可能になったりします。


  1. Flutter の FAQ の中で説明されています。 

  2. 必要に応じて CGO を使って C も組み合わせれば速度の違いは更に大きくなるかもしれません。 

  3. C/C++ で作る場合も NDK は必要で、Go だからではありません。 

  4. https://github.com/golang/go/wiki/Mobile#sdk-applications-and-generating-bindings 

  5. "The equivalent of calling newCounter in Go is GoMypkgNewCounter in Objective-C. The returned GoMypkgCounter* holds a reference to an underlying Go *Counter." 見た目の制限とはこのあたりのことかなと思います。https://godoc.org/golang.org/x/mobile/cmd/gobind#hdr-Passing_Go_objects_to_target_languages 

  6. gomobile に限らず Go 自体がそういうものです 

  7. 記事執筆時の調査等には Go 1.12.7 (windows/amd64)、Flutter 1.7.8+hotfix.3 (channel stable)、Dart 2.4.0、Android Studio 3.4.2 を使用しました。 

  8. NDK のパスを環境変数の Path に設定する必要もありませんでした。数年前に使っていたときには設定した記憶があるのですが、不要になったのかもしれません。 

  9. サポートしたいアーキテクチャ分をすべて含んだ App Bundle をストアにアップロードすると、ユーザの利用端末に合わせて自動的に最適化した APK を配信してくれるため、複数を含んでいることを気にする必要はないと思います。32/64 ビット両方を対象に含めた App Bundle の生成は、先日リリースされたばかりの Flutter 1.7 で可能になりました。 

  10. 研究論文に基づいて実装されたものだそうです。また、nude.js の作者の ブログ には "I wouldn’t recommend using the library in production mode right now because the detection rate is about 60%" と書かれています。 

  11. 型を初期化する関数(コンストラクタのようなもの)の名前の先頭に「New」を付けるのは Go の慣習です。 

  12. ちょっと理解があやふやです。間違っていればご指摘ください。 

  13. Visual Studio Code はこの点は不十分なようです。 

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

Bitrise/fastlaneでpod updateの自動化はいいぞー

TL;DR

  • Podで管理してるライブラリ群のアップデートをするlaneを書く
  • BitriseでWorkflowを指定
  • Workflowをスケジューリングしておこう

前書き

iOSの開発を行っているみなさん, Pod, 使いますよね?
Podに限らず, Railsで開発するときもGemでライブラリ群を管理すると思うのですが, ライブラリというのは定期的にアップデートしないといつの間にか古くなってしまったりしますよね.
マイナーバージョンやパッチバージョンがいくつかアップデートされてるだけならまだいいのですが, 気づいたらメジャーバージョンが二つも上がってる! なんてこともあるかもしれません.

これにはいくつか原因があるかも知れないのですが, わたしは気付けないことが原因になることが多いのではと思っています.
つまり, アップデートがあったときに検知できる仕組みがあることが重要だと考えます.

この記事では, Bitriseとfastlaneを用いてPodで管理されているライブラリ群のアップデートがあったときに検知できる仕組みを作っていこうと思います.

FastfileにPodをupdateするlaneを用意する

Fastfile
desc 'Update pods'
lane :update_pods do
  date = Date.today.to_s # 現在日付を取得
  branch_name = "feature/pod-update-automation-" + date # branch名

  sh("pod update") # この時点でファイル差分があれば以降の処理でPRが出る
  sh("git checkout -b #{branch_name}")

  git_add
  git_commit(
    path: "./",
    message: "Update Pods"
  )
  push_to_git_remote(
    remote: "origin",
    local_branch: branch_name,
    remote_branch: branch_name,
    tags: false
  )
  create_pull_request(
    repo: "tihimsm/my_app",
    title: "Update Pods", # PRタイトル
    head: branch_name,
    base: "develop", # PRを出す先のブランチ
    body: "A difference exists in Podfile.lock.\n Please check!" # PRのメッセージ部分
  )

  slack(message: "A difference exists in Podfile.lock. Please confirm Pull Request.")
end

このようなlaneをFastfile内に記述します.
やっていることはシンプルです.
1. pod updateコマンドをたたく
2. Podfile.lockに差分があればPRを作成

やろうと思えば, 差分があったらメインブランチに直接pushとかもできるのですが, さすがに動作確認などを行わずにメインブランチには反映したくないと思うので, PRを出すようにしています.

ちなみにこの中で使われているsh, git_add, git_commit, push_to_git_remote, create_pull_requestはfastlaneが標準で用意しています.
オプションなど詳しい情報は公式ドキュメントを下に貼っておくのでご覧ください.

BitriseにWorkflowを設定

Workflowはとても簡単です.
リポジトリをCloneしてきてfastlaneでupdate_podsを起動させるだけです.
スクリーンショット 2019-07-16 0.11.35.png

Workflowをスケジューリングしよう

Buildsタブの画面右上の方にStart/Schedule a Buildというボタンがあるので, そこからスケジューリングの設定を行います.
スクリーンショット 2019-07-16 0.18.53.png
上のような画面で設定が行えます.

  • 時間
  • 曜日
  • ブランチ
  • Workflow

を設定することで, 自動でWorkflowが起動できます.
上の例だと毎日AM02:00に起動しますが, 毎日だとさすがに鬱陶しい場合は週一とかにすると良いと思います.

まとめ

いかがでしょうか?
今回はPodのアップデートについて書きましたが, Gemのアップデートも同様に行えるので, fastlaneやcocoapodsのアップデートも自動で検知できるようにすると非常に便利ですよ!

ちなみに, ライブラリをアップデートしたら不具合が! なんてこともよくあると思うので, PRが出た時に自動テストが走るようにしておくとGoodですね!

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