- 投稿日:2019-07-16T23:20:21+09:00
[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
- 投稿日:2019-07-16T23:20:21+09:00
[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
- 投稿日:2019-07-16T21:44:08+09:00
【iOS, Swift】(備忘録)コーディング規約の大事なとこだけ
どうも!
Yoki(@enyyokii)と申します。
渋谷のIT企業でアプリエンジニアしている26才です。
仕事では iOS、Android、Webフロントエンドなど色々しており、週末は勉強を兼ねて個人開発したりしています。今回はSwiftのコーディング規約についていろいろ資料を見る時間があったので、規約で忘れがち、重要度が高そうなものを抜粋してまとめてみました。
(※ 個人差ありです)
基本的なもの、具体例は「参考」にあるものが詳しいです。(エウレカのものが分かりやすいと思います。)
では本題!!
目的
- プログラマー自身のエラーを減らして、さらにエラーを見つけやすくする
- コードの可読性と明快さを向上させる(他の人がコードをレビューもしくは書き換えると仮定して) => 冗長さがなく、誤解の可能性を少なくする
規約
- フォーマット
- 半角スペース4つ分(Text Editingで設定する)。
- 命名
- 出来る限り省略された名前を付けない。
- 副作用がない場合は、名詞を使用する。
- 副作用がある場合は、動詞を使用する
- 依存関係
- 必要最低限のものだけを
import
する。(UIKit
をインポートする必要がある場合は、Foundation
をインポートしない。)
import
文はOS固有のフレームワークと外部フレームワークとの間に空行を1行入れて、アルファベット順に並べる。- 宣言の順序
class
、struct
、enum
、extension
、protocol
などの全ての宣言は// 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]
は使用しない。参考
数字は最終更新日
- raywenderlich 2019/04/28
- Swift API Design Guidelines
- エウレカ 2016/08/03
- クックパッド 2017/04/25
- リクルートライフスタイル 2017/07/11
- 投稿日:2019-07-16T15:35:29+09:00
アスペクト比で高さを指定した場合のトルツメ(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などに設定しておいてください。ちょっとテクニカルですが参考にしてくれたら嬉しいです。
- 投稿日:2019-07-16T14:33:45+09:00
RxSwiftを使ってAPI通信をするアプリを作った!
初めまして!きゅうすけと申します!
iOSエンジニアの私が、自社の定例イベント 「TokyoUppersBoost」 で学んだ基礎の部分をまとめたものです!
メモ感覚ではありますが、これ違うぞってことがあったらぜひ教えてくださいー!(● ˃̶͈̀ロ˂̶͈́)੭ꠥ⁾⁾今回は、「RxSwiftを使ってAPI通信を行う」というハンズオンメインの企画でした。
郵便番号を入力したら、住所の情報が返ってくるようにしました!使うAPIの場所
http://zipcloud.ibsnet.co.jp/doc/api上のリンクをまず叩いてもらって、リクエストパラメーターという表の下にあるURLがapiをJson形式で表示してくれるものです。
ハンズオンまとめ
RxSwiftでやったことは、以下になります。
- api通信のトリガー
- テキストフィールドの入力制限
- テキストフィールドの文字判定
このgithubを落としてきて、中をいじりました!
https://github.com/mht-mikiya-okugawa/SeminorRxSample以下が完成コードになります!
完成コード
ViewController.swiftimport 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) } }完成図
某夢の国があるところを入れてみました?
郵便番号を打ったら結構すぐに住所を出してくれます!↓ 以下が講師の人が用意してくれた記事です!
https://qiita.com/MHTcode_micky/private/0d2d8dd64d16baaeed4e
1時間くらいでできたので、ぜひ皆さんもやってみてください!終わりに
どこで何をやっているのか、講師の人が言ってくれたことを直接コードにメモしながらやったので、
イメージしながら手を動かせたかなと思いました!私が参加している勉強会 「TokyoUppersBoost」への参加者さん大募集中です!!
一緒にiOSについて学びましょう・:*+.(( °ω° ))/.:+↓私が作ったLPなのでよかったら見てください!٩( 'ω' )و
TokyoUppersBoostとは?
- 投稿日:2019-07-16T14:05:16+09:00
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個のビーコン端末を設置し、取得したチケット情報を元に座席まで案内してくれたり、屋台のおすすめ商品を教えてくれたりするのです。
アプリをお使いの客様に、館内のどのフロアのどの辺りにいるかと、行きたいショップまでの最短ルートを表示します。例えば、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.csusing 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をよく見ました。
- 投稿日:2019-07-16T12:59:38+09:00
dSYMのアップロードはBitrise/fastlaneで自動化しとくといいぞ
TL;DR
- dSYMの取得とアップロードをするlane作成
- BitriseにWorkflow作成
- Workflowをスケジューリングする
前書き
Crashlyticsを利用していると, FabricへのdSYMのアップロードを求められることになります.
これをAppStoreConnectへのリリースの度に手動で行ってもいいのですが, 上げ漏れが起きたり, めんどくさかったり, めんどくさかったり, めんどくさかったりしますよね.
なので, 日次でdSYMのアップロードを行うようにBitrise上でスケジューリングすることで, 煩わしさから解放されましょう.FastfileにdSYM関連の処理をするlane作成
Fastfiledesc "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
を起動させるだけです.
Workflowをスケジューリングしよう
Builds
タブの画面右上の方にStart/Schedule a Build
というボタンがあるので, そこからスケジューリングの設定を行います.
上のような画面で設定が行えます.
- 時間
- 曜日
- ブランチ
- Workflow を設定することで, 自動でWorkflowが起動できます. 上の例だと
毎日AM01:00
に起動するようになっています.これで毎日AppStoreConnectの最新のdSYMを取得して, Crashlyticsへアップロードを自動で行うことができるようになります.
まとめ
いかがでしょうか?
地味な内容でしたが, 手作業でやってると意外とめんどくさかったり漏れがあったりする箇所で, 人がやらなきゃいけない作業でもないので, こういう類のものは自動化してしまうに限ります.簡単に設定できるわりに, 毎日SlackにdSYMがアップロードされた通知が来るので「うんうん, 今日もdSYMアップロードご苦労」という気分になれます笑
コスパ良くてけっこうおすすめなので, 設定してない方はぜひやってみてください!
- 投稿日:2019-07-16T08:36:18+09:00
Apple製 BooksアプリのセミモーダルUIを再現する
はじめに
Apple製のBooksアプリ(以後Booksアプリ)ではタイル状に並んだ本をタップすると、特徴的なセミモーダル画面へ遷移します。
一般的なセミモーダルと違い、横スワイプで元画面で並んでいた本を切り替えることができ、縦スクロールすることで徐々に拡大し全画面モーダルへと変化します。
このUIの良いところは、詳細な情報を表示や、前後のコンテンツ切り替えをスムーズに行うことができるところだと思います。ちなみに、似たようなことは
UIPageViewController
で以前より実現していましたが、前後のコンテンツの有無をユーザーへ知らせる機能が弱く(画面下へPageControlを設置など)、ユーザーに実際に気づいてもらえないことが多々あります。
ユーザーに気づいてもらうために、ガイドモーダルを表示したり、一時的に横スクロールアニメーションを行い、横スクロールを示唆したりと一工夫しているアプリを見かけます。また、
UIPageViewController
の横スワイプによるViewController切り替えを行うため、各ViewControllerに横スワイプアクションを入れると競合してしまいます。Booksアプリのセミモーダルは前後コンテンツが見える状態のため、前後の存在が一目瞭然です。
更に、全画面に拡大中は各要素となるViewControllerの横スワイプ切り替えは出来ないため、横方向のジェスチャーに対応したコンテンツを配置することも可能です。良いとこばかりのBooksアプリのセミモーダルですが、
UIPageViewController
のようにUIKitに存在しないため独自実装する必要があります。
複数の要素をうまく連携する必要があるため、導入の難易度が比較的高いUIだと思います。このエントリーでは完全ではないものの、BooksアプリのセミモーダルUIを再現しています。
デザイナーやプロダクトマネージャー等から実装の要望をされたアプリ開発者もそこそこいるのではないでしょうか?
今まで見送っていた方の導入する際の参考になれば幸いです。再現したもの
以下は今回実装したアプリのGifです。以降の調査にある特徴的な4つの要素が再現できていると思います。
Githubに再現したプロジェクトをおいてあります。実装の詳細や動作を確認する際に適宜参照してください。
https://github.com/iincho/CollectionViewSemiModalTransitioning構成する要素の調査
BooksアプリのセミモーダルUIを再現するために、どの機能を組み合わせる必要があるのか調べるため、まずはアプリの挙動を観察します。
1. タイル状に並んだ画像タップでセミモーダル状態に遷移
画像と背景のViewが拡大しながらセミモーダル状態に変化しています。似たような挙動として、Twitterアプリ等で写真リスト>写真詳細へ遷移する際のアニメーションによく見られる挙動です。2. セミモーダル状態で左右のViewをスワイプで切り替え
左右Viewの部分的な表示と横スクロール時の制御では、スワイプ終了時のスクロール方向や位置と速度により停止位置が制御されています。3. 上方向のスクロールで全画面モーダルへ遷移アニメーション
上方向へのスクロールでシームレスに全画面モーダルへと状態変化しますが、スワイプだけでなく、慣性スクロールでも遷移アニメーションが継続します。これは全画面モーダルから、セミモーダル状態への遷移でも同様です。
例)
セミモーダル表示中に勢いよく画面上部に向けてスクロール→慣性スクロールにより全画面モーダル状態まで到達し、更に全画面モーダルでもスクロールを継続4. 下方向へのスワイプでモーダルを閉じる
モーダルを閉じる処理はスワイプを中断する位置によって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.swiftfinal 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.swiftfinal 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)
メソッドでは以下の処理を行っています。
- 遷移アニメーション開始時のView描画に必要な情報(CGRect, Index, UIColor)を用意
- 遷移アニメーション終了時のView描画に必要な情報(CGRect, Index)を用意
- 遷移アニメーション用のViewを1で生成した位置から、2で生成した位置へアニメーション実行
- 遷移完了後、遷移アニメーション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-simplerExampleCollectionSemiModalViewController/// 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. 上方向のスクロールで全画面モーダルへ遷移アニメーション
全画面モーダルへの遷移アニメーションを
UIViewControllerAnimatedTransitioning
とUIPercentDrivenInteractiveTransition
により実現しようとしましたが、Present, Dismiss完了後のアニメーションまで制御する必要があり、その煩雑さから現実的では無いと判断しました。回避策として、上方向のスクロールで全画面モーダル表示アニメーションを、
遷移
ではなくスクロールアニメーション
とすることで再現します。
UICollectionViewCell
がUITableView
を保持し、スクロール量によってUICollectionViewCell.widthを変更するとともに、UICollectionView
の横スクロール可否を切り替えます。合わせてNavigationBarの表示制御もスクロール量により切り替えます。
※だいぶ力技な気がします。良い方法があればコメントいただければ幸いです。縦方向のスクロールで閾値を超えた際にナビゲーションバーの表示を切り替えでは、表示切り替えごとにcontentInsetが変動するため、そのままでは表示が崩れたりUICollectionViewのサイズがおかしくなってスクロールができなくなります。
対策として、contentInsetAdjustmentBehavior
の設定をUICollectionView
とCell内部の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. 下方向へのスワイプでセミモーダルを閉じる
セミモーダルを閉じる一連の動きは、以下アニメーションに分類できます。
- 下方向のスワイプ中アニメーション: Interactiveなアニメーション。スワイプ中断位置によりDismissキャンセル可能
- スワイプ完了後のアニメーション: 不可逆なDismissアニメーション
1はUICollectionViewに
UIPanGestureRecognizer
を設定しViewのドラッグ操作時のtranslation.yにより、Interactiveなアニメーションを実現します。CollectionSemiModalViewControlleroverride 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アプリの完全なアニメーションを再現できていません。タイルタップ時のアニメーションや、セミモーダルのスクロール時のアニメーション等では細かなインタラクションが散りばめられていました。機会があれば別の機会に再現したいと思います。
- 投稿日:2019-07-16T07:43:49+09:00
[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 Flutter 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
- 投稿日:2019-07-16T06:39:12+09:00
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時間依存性注入のコンパイル
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分でチャットボットを構築する
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:設計から開発プロセスをスピードアップ
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
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と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の両方のための本図書館アプリ
- 投稿日:2019-07-16T00:48:41+09:00
アプリ開発に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 + Android と Flutter + 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 ので速度に大きな差はなさそうに思えますが、試してみると違いがありました。2C/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 の開発環境は既に用意されている前提です。
Android NDK のインストール(Android のみ)
Android Studio にて
Tools
>SDK Manager
> 右ペインのSDK Tools
タブ
⇒NDK
にチェックが付いていなければ付けてOK
またはApply
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.gopackage 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 の型は それぞれLong
、numberWithLong
です。
Integer
、numberWithInt
にするには、より小さなサイズの int32 等を使いましょう。型の対応 については付録にまとめています。
情報出力とエラーの扱い
複数の方法で動作を見てみると、かなり癖がありました。
基本的に次のように考えておけば大丈夫かと思います。
詳細は 付録 をご覧ください。
情報を LogCat や Run のウィンドウに表示したい
fmt.Println()
を使う。
fmt.Print()
やfmt.Printf()
で第一引数の末尾に改行するのも OK。Android/iOS や Dart/Flutter で例外として捕捉したい
ライブラリで値を返すとき、二つ目の戻り値にerror
型のデータを付ける。他のポイント
長くなるので 付録 に収めました。
2. ライブラリ生成
Android では aar ファイル、iOS では framework ファイルを生成します。
次のようなパスになっているとします。
GOPATH
C:\Gosimple.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 ではターゲットのアーキテクチャも指定できます。
スラッシュの後ろはarm
、arm64
、386
、amd64
のいずれかです。
指定しない場合、サポートする全4アーキテクチャの so ファイルを含んだ aar ファイルになります。9-target ios
iOS では-target
にios
を指定します。
Android のようなアーキテクチャの指定には対応していないようです。
そもそも幅広いバリエーションがあるわけでもないので不要ですね。他
オプションは他にもあり、gomobile bind -h
で確認できます。ビルド時間、aarファイルのサイズ
これくらい小規模のコードを普段 Go でビルドするときと比べて長くかかります。
環境によりますが、私の PC で Android 向けにビルドしたところ 40 秒ほどでした。また、aar 内の共有ライブラリ(.so)が一つあたり 2MB 以上、圧縮状態で 1MB 程度になりました。
aar ファイルには 4 アーキテクチャ分が入っていて計 4MB 台です。大きめですね。
ユーザが Google Play ストアからダウンロードするときにはもっと小さくなります。9Android + Go
ライブラリ導入
Android Studio を使っていきます。
使わずに、次の 1 ~ 2 に載せた diff を参考にしてファイル追加や記述変更を手動で行っても OK です。1. ライブラリのモジュールを追加
モジュールとは、プロジェクトを分割した機能ごとのアプリのようなものです。
ここでは、ライブラリを一つのモジュールとしてプロジェクトに追加します。
起動後のウィンドウで「Start a new Android Studio project」を選ぶ
開いたウィザードで「Empty Activity」を選び、プロジェクトが開くところまで進める
フォルダアイコンを押し、先ほど生成された aar ファイルを指定してから「Finish」
Subproject name のところには自動的にサブプロジェクト(モジュール)の名前が入ります。
自分で変えても良いでしょう。
これで simple モジュールが追加された状態になりました。
ここまでの操作による変化は次のとおりです(Android Studio 関連ファイルは省いています)。
settings.gradle-include ':app' +include ':app', ':simple'simple/build.gradlenew file mode 100644 +configurations.maybeCreate("default") +artifacts.add("default", file('simple.aar')) \ No newline at end of file
simple/simple.aarnew file mode 1006442. 追加したモジュールを使う設定
追加しただけでは使えません。
メインのモジュールである app から simple を利用できるように依存関係の設定を行います。
使うための設定はこれで完了です。
この操作による変化は次のとおりです。app/build.gradledependencies { 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 ライブラリを実際に使ったアプリを作ります。
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.ktimport 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) }ライブラリのメソッドを使っているだけです。
そのメソッドではエラー時に例外を発生させるようにしているためtry
~catch
を使っています。ボタンを 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.ktclass 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.ktpackage 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.dartimport '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.dartimport '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)
が実行され、返ってきたFuture
をFutureBuilder
で処理しています。
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 のコードと違うのは下記の
try
~catch
の部分だけです。
ライブラリのエラーによって発生した例外を 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 Go で go-nude という Go のパッケージを見つけました。
nude.js というライブラリを Go に移植したものだそうです。JavaScript でできるなら Dart でもできそうですが、まだ存在しないようです。
使いたくてもまだ無いケースとして Go の利用が適していると考えました。※このスクリーンキャスト内では go-nude の example/images/ にある画像を使いました。
※実用性は低そうです。
判定が厳しすぎるかと思ったら、逆に景色の写真がヌードと判定されることもあったりします…。10nudity.gopackage 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.dartimport '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 でやれば速くなるのではないかと考えました。grayscale.dartpackage 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.dartimport '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やメールで送ってきそうな文を生成してくれる楽しいパッケージです。これを使ったチャット風アプリが簡単にできました。
コードは省略します。付録
ここまでに書いた以外に知っておくと良いことをまとめました。
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.gopackage 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.ktimport 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.dartimport '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);
- Go で書いたライブラリに Printer というインタフェースがある
- そのインタフェースを Java で実装して SysPrint クラスとする
- インタフェースが持つメソッドである print() を SysPrint 内で具象化する
- SysPrint のインスタンスを生成し、それをライブラリの PrintHello() に渡す
- 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)最後に
try
~catch
を使わないようにしてみます。lib/simple.dartの一部を改変final arguments = {'value': count}; return await _platform.invokeMethod<int>('simple_multiply', arguments);ボタンを 11 回以上押してもチャンネル名を変えても、何も出力されませんでした。
意図的に無視することもできるようになっているようです。
しかし、異常に気づいて対応できるようにtry
~catch
しておくのが良いと思います。Docker
go4droid/Dockerfile at master · mpl/go4droid · GitHub
https://github.com/mpl/go4droid/blob/master/Dockerfilegomobile の 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 にしてくれたり、モジュール単位のダウンロードが可能になったりします。
Flutter の FAQ の中で説明されています。 ↩
必要に応じて CGO を使って C も組み合わせれば速度の違いは更に大きくなるかもしれません。 ↩
C/C++ で作る場合も NDK は必要で、Go だからではありません。 ↩
https://github.com/golang/go/wiki/Mobile#sdk-applications-and-generating-bindings ↩
"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 ↩
gomobile に限らず Go 自体がそういうものです ↩
記事執筆時の調査等には Go 1.12.7 (windows/amd64)、Flutter 1.7.8+hotfix.3 (channel stable)、Dart 2.4.0、Android Studio 3.4.2 を使用しました。 ↩
NDK のパスを環境変数の
Path
に設定する必要もありませんでした。数年前に使っていたときには設定した記憶があるのですが、不要になったのかもしれません。 ↩サポートしたいアーキテクチャ分をすべて含んだ App Bundle をストアにアップロードすると、ユーザの利用端末に合わせて自動的に最適化した APK を配信してくれるため、複数を含んでいることを気にする必要はないと思います。32/64 ビット両方を対象に含めた App Bundle の生成は、先日リリースされたばかりの Flutter 1.7 で可能になりました。 ↩
研究論文に基づいて実装されたものだそうです。また、nude.js の作者の ブログ には "I wouldn’t recommend using the library in production mode right now because the detection rate is about 60%" と書かれています。 ↩
型を初期化する関数(コンストラクタのようなもの)の名前の先頭に「New」を付けるのは Go の慣習です。 ↩
ちょっと理解があやふやです。間違っていればご指摘ください。 ↩
Visual Studio Code はこの点は不十分なようです。 ↩
- 投稿日:2019-07-16T00:29:47+09:00
Bitrise/fastlaneでpod updateの自動化はいいぞー
TL;DR
- Podで管理してるライブラリ群のアップデートをするlaneを書く
- BitriseでWorkflowを指定
- Workflowをスケジューリングしておこう
前書き
iOSの開発を行っているみなさん, Pod, 使いますよね?
Podに限らず, Railsで開発するときもGemでライブラリ群を管理すると思うのですが, ライブラリというのは定期的にアップデートしないといつの間にか古くなってしまったりしますよね.
マイナーバージョンやパッチバージョンがいくつかアップデートされてるだけならまだいいのですが, 気づいたらメジャーバージョンが二つも上がってる! なんてこともあるかもしれません.これにはいくつか原因があるかも知れないのですが, わたしは気付けないことが原因になることが多いのではと思っています.
つまり, アップデートがあったときに検知できる仕組みがあることが重要だと考えます.この記事では, Bitriseとfastlaneを用いてPodで管理されているライブラリ群のアップデートがあったときに検知できる仕組みを作っていこうと思います.
FastfileにPodをupdateするlaneを用意する
Fastfiledesc '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
を起動させるだけです.
Workflowをスケジューリングしよう
Builds
タブの画面右上の方にStart/Schedule a Build
というボタンがあるので, そこからスケジューリングの設定を行います.
上のような画面で設定が行えます.
- 時間
- 曜日
- ブランチ
- Workflow
を設定することで, 自動でWorkflowが起動できます.
上の例だと毎日AM02:00
に起動しますが, 毎日だとさすがに鬱陶しい場合は週一とかにすると良いと思います.まとめ
いかがでしょうか?
今回はPodのアップデートについて書きましたが, Gemのアップデートも同様に行えるので, fastlaneやcocoapodsのアップデートも自動で検知できるようにすると非常に便利ですよ!ちなみに, ライブラリをアップデートしたら不具合が! なんてこともよくあると思うので, PRが出た時に自動テストが走るようにしておくとGoodですね!