- 投稿日:2020-12-15T23:51:36+09:00
CoreGraphicsのあれこれ
UIImage+CoreGraphics.swiftimport UIKit extension UIImage { //準備 private func preprare(size: CGSize, _ draw: (() -> Void)? = nil) -> UIImage! { UIGraphicsBeginImageContextWithOptions(size, true, 0) draw?() let newImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() return newImage } func resize(ratio: CGFloat) -> UIImage! { let size = CGSize(width: self.size.width * ratio, height: self.size.height * ratio) return self.resize(size: size) } func resize(height: CGFloat) -> UIImage! { let width = self.size.width * height / self.size.height let size = CGSize(width: width, height: height) return self.resize(size: size) } func resize(width: CGFloat) -> UIImage! { let height = self.size.height * width / self.size.width let size = CGSize(width: width, height: height) return self.resize(size: size) } func resize(size: CGSize) -> UIImage! { preprare(size: size) { self.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height)) } } func compose(image: UIImage, frame: CGRect) -> UIImage! { preprare(size: self.size) { self.draw(at: .zero) image.draw(in: frame) } } func compose(image: UIImage, atCenter: CGPoint, blendMode: CGBlendMode = .copy, alpha: CGFloat = 1.0) -> UIImage! { preprare(size: self.size) { self.draw(at: .zero) let at = CGPoint(x: atCenter.x - image.size.width/2, y: atCenter.y - image.size.height/2) image.draw(at: at, blendMode: blendMode, alpha: alpha) } } func compose(image: UIImage, at: CGPoint, blendMode: CGBlendMode = .copy, alpha: CGFloat = 1.0) -> UIImage! { preprare(size: self.size) { self.draw(at: .zero) image.draw(at: at, blendMode: blendMode, alpha: alpha) } } }
- 投稿日:2020-12-15T22:53:04+09:00
【Swift】ぼんやりとDelegateの流れをつかむ
委任とは
考えるとややこしくなってきたがぼんやりと流れをつかむ。
委任側
「メソッドを使わせてくれ」とお願いする側。
NextViewController.swift// 1. 規則を決める protocol CatchProtocol { func catchData(count:Int) } class NextViewController: UIViewController { var count:Int = 0 // 2. 1を deledele という名前の変数にする var deledele:CatchProtocol? @IBAction func push(_ sender: Any) { // 3. 発動する。引数が必要であれば渡す deledele?.catchData(count:count) }受託側
「メソッドを使わせてくれ」とお願いされる側。
ViewController.swift// 1. 宣言する class ViewController: UIViewController, CatchProtocol { @IBOutlet weak var label: UILabel! var count:Int = 0 override func viewDidLoad() { super.viewDidLoad() } // 2. デリゲートメソッド。宣言したときのエラーで fix 押したら作られる func catchData(count: Int) { label.text = String(count) } @IBAction func next(_ sender: Any) { performSegue(withIdentifier: "next", sender: nil) } override func prepare(for segue: UIStoryboardSegue, sender: Any?) { let nextVC = segue.destination as! NextViewController // 「NextViewControllerのdeledeleを任されました」という記述 nextVC.deledele = self } }
- ホントは
deledeleなんてトンチキな名前じゃなくてdelegateとかにしたほうがチームメンバーなど他の人が見たときにわかりやすい- 流れはなんとなくわかったけど最後の
selfがぼんやりしてる
- 投稿日:2020-12-15T20:17:30+09:00
[Cocoa][ObjC]iOS 12以前のUISceneへの対処
確かXcode 11からだったと思うが、XcodeのiOS Appの雛形から生成されるプロジェクトでビルドしたアプリがiOS12以前だと正しく描画できない状態となる。
自分の記憶だとビルドエラーになったと思っていたのだが、Objective-Cだからか警告メッセージが表示されるだけで、実機での起動はできる。
原因は、同一アプリの画面を複数表示するために導入されたUISceneがiOS13以降でないと対応していないためだ。
以前のAppDelegateで行われていた画面周りのコードがSceneDelegateに移動し、iOS12以前だとAppDelegateに画面周りのコードが存在しないため、表示がおかしくなっている。
試行錯誤した結果、対処方法は簡単だった。SceneDelegateで定義されているwindowプロパティをAppDelegateでも定義するだけでOKだった。
@interface AppDelegate : UIResponder <UIApplicationDelegate> @property (strong, nonatomic) UIWindow * window; @end【関連情報】
Cocoa Advent Calendar 2020
Cocoa.swift
Cocoa勉強会 関東
Cocoa練習帳
- 投稿日:2020-12-15T19:48:01+09:00
Xcode12.3 への XVim2 導入メモ
Xcode へ Vim ライクな機能を入れるプラグイン XVim2 をインストールする方法を毎回探しに行くのが面倒だったので、備忘録としてここにまとめようと思います。
実行環境
MacOS 11.0.1(Intel)
Xcode12.3参考
ここに記載する内容は本家 GitHubリポジトリ を参考にしています。
https://github.com/XVimProject/XVim21.証明書の作成
キーチェーンアクセス.app を開き、メニューから 「キーチェーンアクセス」 > 「証明書アシスタント」 > 「証明書を作成...」 を選択。
名前と証明書のタイプを変更し、次へ次へと進んで作成を完了させます。
2.Xcodeを自己署名
続いて次のコマンドをターミナルで実行します。
sudo codesign -f -s XcodeSigner /Applications/Xcode.appAppStoreからインストールした場合は上記ディレクトリにあるはず。
※ Xcodeがある場所が異なる場合は適宜パスを変更してください。
※ 完了まで結構時間がかかります。。3.XVim2 を clone し、 make を実行
git clone git@github.com:XVimProject/XVim2.git↑の 2.Xcodeを自己署名 が完了していることを確認し、clone したディレクトリに移動して
makeを実行です。cd XVim2 make4.Xcode起動 -> Load Bundle を選択
Xcode を起動するとダイアログが出てくるので「Load Bundle」を選択します。
(「Skip Bundle」がアクティブになっているので注意!)以上でカーソルがブロックになって Vim と同じような操作が可能になります。
自分のプロジェクトを開いて確認してみましょう!(番外編) ちなみに、Xcodeアップデート後に必要なことはある?
Xcodeのアップデートをインストールした場合は、再び Xcode の自己署名、XVim2 ディレクトリでの
make操作が必要です。この記事を書くタイミングで AppStore でインストールした Xcode12.2 を 12.3 へアップデートをしましたが、アップデート直後に起動すると機能が解除されていました。XVim2 ディレクトリへ移動し、
makeを実行しても機能せず。。しかし、2.Xcodeを自己署名 からやり直したところ、今まで通り動作することが確認できました。
- 投稿日:2020-12-15T19:41:32+09:00
iOS14から使用できるUIMenuの実装について
はじめに
iOS Advent Calendar 2020 の18日目になります!
今回は、WWDC20にて紹介されていました
UIMenuの新機能について焦点を当ててみようと思います。
UIMenu自体はiOS13から使用できるのですが、
iOS14からはUIButtonやUIBarButtonItemにも使用可能となった内容がありましたので、おさらい的な紹介ができればと思います?♂️
とはいっても、すでにとても分かりやすくまとめられている記事がありましたので、細かい説明は割愛させていただきます。
本記事では、実際にコードで書いてみて使い心地を感じられればと思います。UIMenu:
https://developer.apple.com/documentation/uikit/uimenu
WWDC20:
https://developer.apple.com/videos/play/wwdc2020/102051. 新機能について
すでに分かりやすくまとめられている記事が多々ありますのでリンクを貼らせていただきました?♂️
(これ以上上手にまとめられる気がしませんでした。。)
https://medium.com/better-programming/whats-new-in-ios-14s-uimenu-and-contextmenu-433cd2037c372. UIMenuのメリット
UIMenuと比較する機能としてUIAlertController(ActionSheet)が挙げられると思います。
WWDC20でもこちらを比較して紹介されていましたので以下にまとめてみました。
UIMenuのメリットとして以下が挙げられていました。
- iPad表示するためにポップオーバー表示するための実装をする必要がない
- 表示の際に背景を暗くする処理がなくなったため、その分軽量感のある遷移になる
- 閉じるための「キャンセル」ボタンが不要(Menu外をタップすると閉じる)
- タップした箇所からMenuが表示されるので操作しやすい(操作性) etc...
3. 実際に書いてみました
「百聞は一見にしかず」と言うことで、実際にコードを書いてみました。
- [3-1]
HIGH, MID, LOWを切り替える- [3-1]
UIMenuを開いた際には、選択した項目にチェックマークがついている- [3-2]
UIMenuを非同期で構築する場合と言う内容を実装してみたいと思います。
3-1. 完成画面
3-1-1. 下準備
UIMenuを設定する前に、設定するためのUIButtonなどを下準備します。class ViewController: UIViewController { // メニュー表示項目 enum MenuType: String { case high = "HIGH" case mid = "MID" case low = "LOW" } // メニュー選択ボタン @IBOutlet weak var menuButton: UIButton! // 選択されたMenuType var selectedMenuType = MenuType.high override func viewDidLoad() { super.viewDidLoad() } }3-1-2. UIButtonにUIMenuを設定
UIMenuをUIButtonに設定するために、以下のメソッドを作成しました。private func configureMenuButton() { var actions = [UIMenuElement]() // HIGH actions.append(UIAction(title: MenuType.high.rawValue, image: nil, state: self.selectedMenuType == MenuType.high ? .on : .off, handler: { (_) in self.selectedMenuType = .high // UIActionのstate(チェックマーク)を更新するためにUIMenuを再設定する self.configureMenuButton() })) // MID actions.append(UIAction(title: MenuType.mid.rawValue, image: nil, state: self.selectedMenuType == MenuType.mid ? .on : .off, handler: { (_) in self.selectedMenuType = .mid // UIActionのstate(チェックマーク)を更新するためにUIMenuを再設定する self.configureMenuButton() })) // LOW actions.append(UIAction(title: MenuType.low.rawValue, image: nil, state: self.selectedMenuType == MenuType.low ? .on : .off, handler: { (_) in self.selectedMenuType = .low // UIActionのstate(チェックマーク)を更新するためにUIMenuを再設定する self.configureMenuButton() })) // UIButtonにUIMenuを設定 menuButton.menu = UIMenu(title: "", options: .displayInline, children: actions) // こちらを書かないと表示できない場合があるので注意 menuButton.showsMenuAsPrimaryAction = true // ボタンの表示を変更 menuButton.setTitle(self.selectedType.rawValue, for: .normal) }3-1-3. 完成
あとは、作成したメソッドを
viewDidLoad内で呼び出す様にします。class ViewController: UIViewController { // メニュー表示項目 enum MenuType: String { case high = "HIGH" case mid = "MID" case low = "LOW" } @IBOutlet weak var selectButton: UIButton! // 選択されたMenuType var selectedType = MenuType.high override func viewDidLoad() { super.viewDidLoad() // UIButtonにUIMenuを設定する self.configureMenuButton() } }3-2. 非同期でUIMenuを構築する場合
非同期で
UIMenuの構築が必要な場合は、UIDeferredMenuElementを使用することで実装することができます。var actions = [UIMenuElement]() let deferredMenuElement = UIDeferredMenuElement({ completion in // 時間がかかる処理 .... completion(actions) }) menuButton.menu = UIMenu(title: "UIDeferredMenuElement", options: .displayInline, children: [deferredMenuElement]) menuButton.showsMenuAsPrimaryAction = trueUIDeferredMenuElement:
https://developer.apple.com/documentation/uikit/uideferredmenuelement4. まとめ
これらの実装は、iOS14から対応なので実際にはiOS13以下の場合の分岐処理を書かなければならず、面倒ではあります。
ただ、操作感が良くや使い所が多岐に渡りそうな気がするのでとても魅力的な機能でした。
個人的にはUIAlertController(ActionSheet)実装時にiPad用に処理を書き忘れてしまうと、クラッシュする原因にもなるのでこの辺り考えなくて良くなるのは嬉しい部分ではありました。実装してみましたが、説明やコードで誤りなどございましたらご指摘いただけると幸いです。
ご協力のほどよろしくお願いいたします?♂️
- 投稿日:2020-12-15T19:20:17+09:00
[AutoLayout] Intrinsic Content Sizeを活用しよう〜その2 Self Sizing編〜
はじめに
こんにちは!Life is Tech ! #2 Advent Calendar 2020の22日目を担当しますふみっちです。
この記事は〜その1 概要編〜の続きとなっていて、
Intrinsic Content Sizeに関する具体的な実装を解説を行います!
プロジェクト全体はこちらからダウンロードできるので是非手元で動かしてみてください?
Intrinsic Content Sizeを用いると動的なコンテンツの変化によるサイズ調整を自動で実装できるようになるという話を〜その1 概要編〜に書かせてもらいましたが、おそらく動的なサイズ調整が必要になるケースとしてはUITableViewやUICollectionViewを使用するが多いと思います。
そこで今回はUITableViewのCellをIntrinsic Content Sizeを用いてセルごとに可変なものにしていきます!UITableViewCell × Intrinsic Content Size
TableViewやCollectionViewには一つ一つのセルの高さを可変にするという仕組みが備わっており、
Self Sizingと呼ばれています。
このSelf Sizingは先ほど説明したIntrinsic Content Sizeを用いることで手動で計算をしなくても実現することができます。Self Sizingの具体例
今回は
Self Sizingを使用して画像の大きさに合わせて動的な高さを持つUITableViewCellを作成する例を紹介します。
また、セルのボタンをタップするとコンテンツの表示・非表示を変更できる方法も紹介します。
Apple純正の「リマインダー」アプリでみるようなセルの表示・非表示もAppleの場合はセルの数自体を変えているように見えますが、リマインダーアプリに近いことができるようになると思います。
リマインダー
先ほどのリンクのSample2のプロジェクトに対応する内容です。
Demo このように可変サイズを持つUIViewは
Intrinsic Content Sizeをうまく使うことで簡単に実現できます!実装ポイント
Cellの内部は
Intrinsic Content Sizeを持つUIViewのみを配置するSelf Sizingによって自動でセルの高さを決定する場合は
Intrinsic Content Sizeが有効である必要があります。なのでただのUIViewやUIScrollViewをセルの内部に配置すると正しくセルがリサイズされない場合があります。
セルを可変にする際はセルに配置するUIViewに注意 表示中のCellの高さを変更したい場合は一度セルをリロードする
Intrinsic Content Sizeを用いて手軽にCellごとの高さを動的に変更する際に注意点が一つ必要です。
それは一度表示したあとにCellの高さを変更したい場合はセルを再度更新する必要があるということです。(他にも方法はありそうですがこの方法が一番簡単だったので紹介します。)ただ、
UITableViewが持つreloadData()は全てのセルを更新してしまうので、同じくUITableViewが持つreloadRows(at:with:)というメソッドを用いて高さの変更が必要なセルのみを更新します。
このとき、ボタンがタップされたことはセル側が検知するため、ViewController側にデリゲートを利用して処理を移譲する必要があります。デリゲートを定義してViewControllerに処理を渡す
以下のようにセルからViewControllerに処理を移譲するためにプロトコルを作成してあげます。
TableViewCell.swiftprotocol TableViewCellDelegate: AnyObject { func didTapChangeVisibleButton(cell: TableViewCell) }このプロトコルには
didTapChangeVisibleButtonというメソッドを定義していますが、引数にセル自身を渡してあげられるようなメソッドであれば他の名前でも大丈夫です!ボタンタップ時にデリゲートメソッドを呼ぶ
次に上の
TableViewCellDelegateを使用してセルでボタンがタップされたことを受け取ったら、ViewController(デリゲート)側に処理を伝えます。TableViewCell.swiftimport UIKit class TableViewCell: UITableViewCell { @IBOutlet weak var randomImageView: UIImageView! // デリゲートをプロパティとして参照する weak var delegate: TableViewCellDelegate? // ボタンがタップされたことをdelegate側に伝える @IBAction func tapChangeVisibleButton() { // 先ほど定義したメソッドを呼んでデリゲート側で処理をしてもらう delegate?.didTapChangeVisibleButton(cell: self) } }ViewController(デリゲート)側でセルを更新する
次に
ViewControllerが先ほど定義したTableViewCellDelegateに準拠し、セルの更新処理を行います。ViewController.swiftimport UIKit extension ViewController: TableViewCellDelegate { func didTapChangeVisibleButton(cell: TableViewCell) { if let indexPath = tableView.indexPath(for: cell) { data[indexPath.row].toggle() tableView.reloadRows(at: [indexPath], with: .automatic) } } }先ほど紹介した
tableView.reloadRows(at:with:)を呼ぶためにセルのIndexPathを取得する必要がありますが、引数にセル自身を渡しているのでtableView.indexPath(for: cell)という具合に取得することができます!// 引数に指定したセルのIndexPathが取得できる let indexPath: IndexPath? = tableView.indexPath(for: cell)セルに関するデータモデル
セル一つ一つに対して画像を表示するのか・しないのかに関してはセル側ではなくViewController側で管理する必要があります。
今回は「セルに画像を表示しますか?」「はい、いいえ」という知識を持っていれば良いので
var data: [Bool] = []というようにデータを定義します。var data: [Bool] = [ false, false, false, false, false, false, false, false, false, false ]最初は画像を非表示の状態から始めたいので
falseとしています。UITableViewDataSource
ViewController側で実装されている以下の二つのメソッドの中身を解説していきます。
tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> InttableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCelltableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
このメソッドはセルの個数を決めるメソッドです。
配列dataの要素数と同じにすればデータの数だけセルが表示されるようになります。(セルの数を増やしたい場合は配列の要素を増やしましょう!)tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
このメソッドはIndexPathというUITableViewにおけるセルの所在地を元に表示するセルを決定するメソッドです。
ここのメソッドの内部では以下のコードがミソです。let isVisible = data[indexPath.row] if isVisible { cell.randomImageView.image = imageArray[indexPath.row % 4] } else { cell.randomImageView.image = nil }上記の最初の行では、
data変数のセルに該当する要素を取得しています。
もしセルは画像を表示するべき(isVisibleがtrue)であればセルに画像を表示します。
一方セルは画像を表示するべきではない(isVisibleがfalse)であればセルも画像はnilになります。UIImageViewの
Intrinsic Content Sizeはimageプロパティの値によって変わるため、imageをnilにすると自動的にUIImageViewの高さも0になり、画像が代入されるとその画像に適する高さにリサイズされます。
このようにして自分で高さの調整をせずとも表示するデータに応じてセルが自動的にサイズを調整してくれるわけです!
完成イメージ 最後に
この記事では〜その1 概要編〜で説明した
Intrinsic Content Sizeを交えた実装例を紹介しました。
Intrinsic Content Sizeはマイナーな内容かもしれないですが、実は普段から意識しなくても使っているということもあると思うのでこの機会に興味を持ってもらえると嬉しいです!
最後まで読んでいただきありがとうございました‼️
- 投稿日:2020-12-15T19:04:12+09:00
[macOS][Cocoa][Swift]IMEを変更させる
IMEの変更
ソース
import Cocoa import InputMethodKit class InputSource { fileprivate static var inputSources: [TISInputSource] { let inputSourceNSArray = TISCreateInputSourceList(nil, false).takeRetainedValue() as NSArray return inputSourceNSArray as! [TISInputSource] } fileprivate static var selectCapableInputSources: [TISInputSource] { return inputSources.filter({ $0.isSelectCapable }) } static func change(id: String) { guard let inputSource = selectCapableInputSources.filter({ $0.id == id }).first else { return } TISSelectInputSource(inputSource) } // 確認用 static func print() { for source in inputSources { Swift.print("id:[\(source.id)]") Swift.print("localizedName:[\(source.localizedName)]") Swift.print("isSelectCapable:[\(source.isSelectCapable)]") Swift.print("isSelected:[\(source.isSelected)]") Swift.print("sourceLanguages:[\(source.sourceLanguages)]") Swift.print("--------------------") } } } extension TISInputSource { func getProperty(_ key: CFString) -> AnyObject? { guard let cfType = TISGetInputSourceProperty(self, key) else { return nil } return Unmanaged<AnyObject>.fromOpaque(cfType).takeUnretainedValue() } var id: String { return getProperty(kTISPropertyInputSourceID) as! String } var localizedName: String { return getProperty(kTISPropertyLocalizedName) as! String } var isSelectCapable: Bool { return getProperty(kTISPropertyInputSourceIsSelectCapable) as! Bool } var isSelected: Bool { return getProperty(kTISPropertyInputSourceIsSelected) as! Bool } var sourceLanguages: [String] { return getProperty(kTISPropertyInputSourceLanguages) as! [String] } }// IMEを変更 InputSource.change(id: "com.google.inputmethod.Japanese.base")確認用の出力結果
id:[com.apple.inputmethod.Kotoeri.Japanese] localizedName:[Hiragana] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["ja"]] -------------------- id:[com.apple.inputmethod.Kotoeri.Roman] localizedName:[Romaji] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["en"]] -------------------- id:[com.apple.inputmethod.Kotoeri] localizedName:[Japanese] isSelectCapable:[false] isSelected:[false] sourceLanguages:[["ja", "en"]] -------------------- id:[com.apple.inputmethod.Kotoeri.Japanese.Katakana] localizedName:[Katakana] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["ja"]] -------------------- id:[com.apple.CharacterPaletteIM] localizedName:[Emoji & Symbols] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["en"]] -------------------- id:[com.apple.50onPaletteIM] localizedName:[Japanese Kana Palette] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["ja"]] -------------------- id:[com.apple.inputmethod.Kotoeri.Japanese.HalfWidthKana] localizedName:[Half-width Katakana] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["ja"]] -------------------- id:[com.google.inputmethod.Japanese.Katakana] localizedName:[Katakana (Google)] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["ja"]] -------------------- id:[com.google.inputmethod.Japanese.HalfWidthKana] localizedName:[Half-width Katakana (Google)] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["ja"]] -------------------- id:[com.google.inputmethod.Japanese.Roman] localizedName:[Alphanumeric (Google)] isSelectCapable:[true] isSelected:[true] sourceLanguages:[["en"]] -------------------- id:[com.google.inputmethod.Japanese.FullWidthRoman] localizedName:[Full-width Alphanumeric (Google)] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["ja"]] -------------------- id:[com.google.inputmethod.Japanese.base] localizedName:[Hiragana (Google)] isSelectCapable:[true] isSelected:[false] sourceLanguages:[["ja"]] -------------------- id:[com.google.inputmethod.Japanese] localizedName:[Google Japanese Input] isSelectCapable:[false] isSelected:[false] sourceLanguages:[["ja", "en"]] --------------------
- 投稿日:2020-12-15T17:53:15+09:00
ITMS-90626エラーの解決方法
ITMS-90626エラーの解決方法
■問題
バイナリをAppStoreConnectにアップロードした後、ITMS-90626エラーが発生した。
※アップロード自体は成功したが、メールにて通知がきた。
(環境:XCode 12.2 / macOS Catalina)◇実際のエラー内容
ITMS-90626: Invalid Siri Support - Localized title for custom intent: 'Configuration' not found for locale: ja■原因
調べるとどうやらiOS14から追加されたWidgetKitを追加したことが影響していたようだ。
Siriに関する実装はしていないはずなのにSiri関連のエラーはおかしいなと思ったら、
WidgetKitをターゲットに追加する時のウィザードで、Configurationを有効にして作成すると、自動的にSiri Intent Definitionというものが作成されていた。今回のエラーは、その作成されたSiri Intent Definition(*.intentdefinitionファイル)のローカライズがされていないための警告だった。
■対策
今回は、このSiri Intent Definitionを特に意識的に使用していないのだが、Widget側のコードにもConfigurationIntentが登場しており、単純にターゲットから外す訳にはいかないので、Siri Intent Definitionのローカライズを追加することで対応した。
しかし、よくよく考えてみると、既にウィジェット機能を追加したアプリは公開済で、前回まではこのエラーは発生していなかった。
今回のアップデートで追加した機能が関係するとすると、
アプリ側の永続化データをウィジェット側で取得したくなり、
UserDefaultsの連携ができるように、App Groupsの追加をしたので、
そのことが関係するのかもしれない。※ローカライズの話なので、日本語化、多言語対応とかした場合に直接顕在化する問題だと思うが、その対応は前回以前にしていたので、このタイミングで出たエラー理由ではなさそう。
ま、あとはApple側のチェック機構がこのタイミングでなされたかですかね。
- 投稿日:2020-12-15T17:50:49+09:00
iOSアプリで環境ごとに設定を変えるベストプラクティス(Swift)
はじめに
本記事は Swift/Kotlin愛好会 Advent Calendar 2020 の5日目の記事です。
空いていたので参加しました。iOSアプリ開発において、環境ごとに変数の値を切り替えるベストプラクティスを紹介します。
背景
私が開発しているアプリで、APIの接続先が3つ(開発用・ステージング用・リリース用)必要になりました。
Build Configurations(以下「ビルド構成」と呼ぶ)」にStagingを追加し、#ifで分岐する方法をよく見かけます。EnvironmentVariables.swiftenum EnvironmentVariables { #if DEBUG static let apiBaseUri = "https://example.com/debug/" #elseif STAGING static let apiBaseUri = "https://example.com/staging/" #elseif RELEASE static let apiBaseUri = "https://example.com/release/" #endifしかし、最近はXcodeのビルドシステムが
DebugとReleaseの2種類を前提としており、ビルド構成を追加するとライブラリ周りで問題が発生するようです。
StagingみたいなBuild Configurationを作ってユーザー定義変数を設定して#if STAGINGみたいにして分岐する、ようなのはずっと昔はそれで別に良かったんだけど今はXcodeのビルドシステムがDebug/Releaseの2種類が前提で早晩ライブラリ周りで問題が起こって詰むので基本触ってはいけない。
— kishikawa katsumi (@k_katsumi) December 8, 2020
ちなみに私はDebug/Release以外のビルド構成を見つけたら消す仕事をけっこういろんなところでしていて、今もしています。
— kishikawa katsumi (@k_katsumi) December 8, 2020XcodeGenを使っている場合、環境ごとに設定を変えてプロジェクトを生成するのがいいとのことなので、その方法を紹介します。
はい、XcodeGen使ってるならスキーマを変えるように ENVIRONMENT=STAGING HOST=staging.example .com xcodegen --spec ... みたいな感じで(例えばです)Staging構成の設定のプロジェクトを適宜作って使う、っていうのができるのでそれがいいと思います。
— kishikawa katsumi (@k_katsumi) December 8, 2020最も伝えたいこと
本記事で最も伝えたいことは、 ビルド構成をいじらず、それ以外の方法で環境ごとに設定を変えよう です。
「それ以外の方法」として、XcodeGenを使うと比較的かんたんに実現できる、ということです。といいつつも、実はビルド構成をいじることによる具体的な問題を把握していないので、知っていたら教えていただけると嬉しいです
![]()
ただ、私の認識は以下のツイートの通りなので、問題の有無にかかわらずビルド構成とは別の方法で設定を変えるのに賛成です。
Build Configurations(ビルド構成)
— ウホーイ (@the_uhooi) December 16, 2020
Debug: Xcode で Run するとき
Release: .ipa ファイルを作るとき
という認識なので、そもそもAPI接続先などの環境と関係ないよね?
この認識は極端かな?前提条件
- XcodeGenを使っている
使っていない場合、おまけが参考になるかもしれない環境
- OS:macOS Catalina 10.15.7
- Xcode:12.2 (12B45b)
- Swift:5.3.1
- XcodeGen:2.18.0
実装
環境ごとに設定を変えられるよう実装します。
Makefileの作成(任意)
プロジェクト内で使いたい値を環境変数としてエクスポートし、XcodeGenでプロジェクトを生成するコマンドを準備します。
エクスポートする環境変数を環境ごとに変えるため、makeなどを使ってタスク化するのがオススメです。私は以下のような
Makefileを作成しています。MakefileDEBUG_ENVIRONMENT := DEBUG STAGING_ENVIRONMENT := STAGING RELEASE_ENVIRONMENT := RELEASE .PHONY: generate-xcodeproj-debug generate-xcodeproj-debug: # Generate project with XcodeGen for debug $(MAKE) generate-xcodeproj ENVIRONMENT=${DEBUG_ENVIRONMENT} .PHONY: generate-xcodeproj-staging generate-xcodeproj-staging: # Generate project with XcodeGen for staging $(MAKE) generate-xcodeproj ENVIRONMENT=${STAGING_ENVIRONMENT} .PHONY: generate-xcodeproj-release generate-xcodeproj-release: # Generate project with XcodeGen for release $(MAKE) generate-xcodeproj ENVIRONMENT=${RELEASE_ENVIRONMENT} .PHONY: generate-xcodeproj generate-xcodeproj: mint run xcodegen xcodegen generateこの
MakefileではENVIRONMENT環境変数に以下の値をエクスポートしています。
環境 値 デバッグ DEBUGステージング STAGINGリリース RELEASE環境変数の名前は
ENVIRONMENTでなくても問題ありません。例えば
API_BASE_URIとして、直接APIの接続先を渡すこともできます。
しかし、それだと他にも環境ごとに変えたい設定が出てきた場合、そのたびに環境変数をエクスポートしなければいけません。
私は環境を判定する値を1つのみエクスポートし、プロジェクト内でAPIの接続先を変えるようにします。2020/12/16 追記
例えばリリース時に開発やステージング環境の設定をどうしてもバイナリに含めたくない場合、設定値を直接エクスポートするのもありだと思います。project.ymlの修正
エクスポートした環境変数をプロジェクトに注入します。
project.ymltargets: {製品ターゲット名}: # {中略} settings: base: + ENVIRONMENT: ${ENVIRONMENT}XcodeGenでは、環境変数を
${環境変数名}で取得できます。
ここではENVIRONMENTという名前でUser-Definedの設定を作成し、先ほどエクスポートした環境変数を注入しています。ここまで実装して
make generate-xcodeproj-releaseを実行すると、以下のUser-Definedが作成されます。
見てわかる通り、ビルド構成にかかわらずすべて
RELEASEの値が入っています。
つまり 環境とビルド構成は互いに独立している ということであり、「リリース環境でデバッグビルド」や「ステージング環境でリリースビルド」などができるようになります。Info.plistにUser-Definedの設定を追加
Info.plistにproject.ymlで定義したUser-Definedの設定を追加します。
Key Type Value 任意 String$(User-Definedの設定名)Keyは任意ですが、わかりやすいようにUser-Definedの設定名に近い名前がいいと思います。
EnvironmentVariables.swiftの追加
Info.plistに追加したことで、Bundle.main.object(forInfoDictionaryKey: "キー")を呼び出してSwiftファイルから設定を取得できるようになりました。私は環境変数を一元管理したいため、1ファイルにまとめています。
EnvironmentVariables.swiftimport Foundation enum Environment: String { case debug = "DEBUG" case staging = "STAGING" case release = "RELEASE" } enum EnvironmentVariables { static var environment: Environment { guard let environmentString = Bundle.main.object(forInfoDictionaryKey: "Environment") as? String, let environment = Environment(rawValue: environmentString) else { fatalError("Fail to load `Environment` from `Info.plist`.") } return environment } static var apiBaseUri: String { switch environment { case .debug: return "https://example.com/debug/" case .staging: return "https://example.com/staging/" case .release: return "https://example.com/release/" } } }これで実装は完了です。
今後環境ごとに変えたい設定が増えた場合、
apiBaseUriと同様に実装すればOKです。
EnvironmentVariables.swift以外の修正は不要です。ちなみに
EnvironmentVariablesをケースなしの列挙型にしているのは、単純に名前空間が欲しいためです。使い方
使い方は以下の通りです。
make generate-xcodeproj-○○で環境変数のエクスポートとプロジェクトを生成するEnvironmentVariables.××で設定値をSwiftで呼び出す例として
make generate-xcodeproj-stagingを実行し、AppDelefate.swift内で設定値を呼び出します。$ make generate-xcodeproj-staging /Applications/Xcode.app/Contents/Developer/usr/bin/make generate-xcodeproj ENVIRONMENT=STAGING mint run xcodegen xcodegen generate ⚙️ Generating plists... ⚙️ Generating project... ⚙️ Writing project... Created project at /Users/{ユーザー名}/{中略}/{プロジェクト名}.xcodeprojAppDelefate.swift@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { print(EnvironmentVariables.apiBaseUri) // "https://example.com/staging/" if EnvironmentVariables.environment == .staging { // true print("Environment is staging.") } return true } }環境ごとの設定値を取得することができました!
EnvironmentVariables.swiftでenvironmentをprivateにしないことで、環境ごとに処理を分岐することができます。発展: プロトコルを噛ませてモック化できるようにする
上記の実装だと単体テスト時に値を差し替えづらいので、プロトコルを噛ませてモック化できるようにします。
列挙型だとケースがないとインスタンス化できないので、構造体に変更しています。私は Mockolo というモック生成ライブラリを使っているため、プロトコルに
/// @mockableコメントを付けています。EnvironmentVariables.swiftimport Foundation + + /// @mockable + protocol EnvironmentVariablesProtocol { + var environment: Environment { get } + var apiBaseUri: String { get } + } enum Environment: String { case debug = "DEBUG" case staging = "STAGING" case release = "RELEASE" } - enum EnvironmentVariables { - static var environment: Environment { + struct EnvironmentVariables: EnvironmentVariablesProtocol { + var environment: Environment { guard let environmentString = Bundle.main.object(forInfoDictionaryKey: "Environment") as? String, let environment = Environment(rawValue: environmentString) else { fatalError("Fail to load `Environment` from `Info.plist`.") } return environment } - static var apiBaseUri: String { + var apiBaseUri: String { switch environment { case .debug: return "https://example.com/debug/" case .staging: return "https://example.com/staging/" case .release: return "https://example.com/release/" } } }
AppDelefate.swift内で呼び出す例は以下のように変わります。AppDelefate.swift@UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + let environmentVariables: EnvironmentVariablesProtocol = EnvironmentVariables() - print(EnvironmentVariables.apiBaseUri) // "https://example.com/staging/" + print(environmentVariables.apiBaseUri) // "https://example.com/staging/" - if EnvironmentVariables.environment == .staging { // true + if environmentVariables.environment == .staging { // true print("Environment is staging.") } return true } }インスタンス化する手間は増えますが、私はプロトコルを噛ませるほうがテスタブルで好みです。
例として、
apiBaseUriをイニシャライザ経由でDIします。ApiClient.swiftfinal class ApiClient { private let apiBaseUri: String init(environmentVariables: EnvironmentVariablesProtocol) { self.apiBaseUri = environmentVariables.apiBaseUri } }これだと
EnvironmentVariablesProtocolの全プロパティやメソッドがイニシャライザ内で呼び出せるため、apiBaseUriのみDIするのもありだと思います。
environmentのみ使いたい場合、EnvironmentをDIすると余計なプロパティやメソッド(今回だとapiBaseUriプロパティ)を呼び出せなくなってわかりやすいです。Foo.swiftfinal class Foo { private let environment: Environment init(environment: Environment) { self.environment = environment } func foo() { if environment == .staging { // ステージング環境で特有の処理 } } }おまけ: BuildConfig.swiftを使う
@417_72ki さんが開発している BuildConfig.swift を使えば、XcodeGenを使っていないプロジェクトでも環境ごとに設定を変えられます。
詳細は以下のスライドをご参照ください。
https://speakerdeck.com/417_72ki/management-of-environment-variables-with-yamls-ver-dot-2おわりに
これで環境ごとに設定を変えてほしい要求が来ても安心です!
他にいい方法があれば、コメントなどで教えていただけると嬉しいです![]()
以上、 Swift/Kotlin愛好会 Advent Calendar 2020 の5日目の記事でした。
翌日も @uhooi の記事です。参考リンク
- 投稿日:2020-12-15T16:10:01+09:00
Fatal error: Unexpectedly found nil while unwrapping an Optional valueについて
Swiftでよく見るこのエラー
Fatal error: Unexpectedly found nil while unwrapping an Optional value
まずは翻訳してみましょう!
オプショナル形の値のアンラップ中に予期せずnilが見つかりましたです。
Storyboardを使って開発してる方向けに言うと、
このエラーが起きたら、6割、関連付けができていないことが原因です!
この,6割ってのが割と大事。(※筆者の肌感覚です)
(6割方原因これ)関連付けが主な原因な場合
もしこのエラー文章が、
@IBOutlet接続してるものが含まれる文章に書いてあるんだったら、
関連付けできてるか確認しましょう。XCodeは便利なもので、
関連付けしてあったら、◉
してなかったら、○
が行のところに表示してくれます。
(たまに関連付けしてるのに、○表示になってることもあるけど、、)ってことで関連付けして終了です。
ちなみに
@IBOutletをつけるとき("@IBOutletオプションの時"って言う)、"!"をつけるの!!!って人はこちら参照!
【IBOutletはなぜ" ! "で定義すべきか 】(4割方の原因)変数がnilの状態のままその変数をつかって処理をしようとしてクラッシュしてる
直訳通りです。
ます頭に入れといて欲しいのは
変数は箱だってこと。
箱の中身には値が入ります。
この画像で言うなら、"a"って言う箱に何かしらの値が入ってるって意味っす。
変数についての復習はこちら。
この箱の中には値が入ってなくて(つまりnilの状態)、その状態のまま処理しようとしてるからアウトってパターンです!
どう直せばいいのか
空になってる変数に値を入れればいいのです。
いかに注意して、変数に値を入れてください。
①関数内のプログラミングコードはは上から下に読まれる
②大抵の場合、viewDidLoad関数は一番最初に読まれる。
- 投稿日:2020-12-15T15:57:03+09:00
XcodeのStoryboardで指定するUIViewのBGColorがカラーピッカーから取得したカラーコードに色が合わなくて困った
AdobeXDで作られたスマホアプリのデザインを実装していたのですが、XDのスポイトで取得したカラーコードを実装してもいまいち色が合わなかったので調べました。
※ 色は同じディスプレイでみてます。XDの色
ピックすると#83C449のカラーで取得。
StoryboadのUIViewのBackgroundに指定
XDで取得したカラーコードをUIViewのBGカラーとして設定したのですが、いまいちくすんで色が合わない。
シミュレータで実行後のUIViewの色がこちら。カラープロファイルを指定する
調査したところディスプレイやデジタルカメラ、スキャナなどは、それぞれ様々な規格でRGBの色を表示しており、デバイスによっては表現できる色の幅が変わるとのこと。
UIViewのデフォルトはGeneric RGBが指定されており、これをsRGBで指定すれば大差なく表示できるとあったので、設定を変えてみました。変更してシミュレータで実行した結果がこちら。
あんまり変わってない。。。
結論
解決方法がわからなかったので、Googleでカラーコードを入力し、そこから目視で"#86D442"を設定しました。
シミュレータでの"#86D442"の結果がこちら。ということで、結局微調整は必要なのかもです。。。
どなたか詳しい方いればコメントいただけるとうれしいです。
- 投稿日:2020-12-15T14:30:46+09:00
Combine導入の戦略について考えてみた
はじめに
- iOSのバージョンアップに伴いフレームワークも進化してきました。SwiftUI, Combineの登場によって、UIの構築方法の変化、アーキテクチャの変化が必要な時期になってきました。しかし、既存のプロダクトを全て書き換えるのはすぐにはできないです。今回は新しいフレームワークに徐々に移行していくための戦略に追加考えてみました。
CombineとSwiftUI
- 新しく登場したSwiftUIですがいきなり複雑なUIをSwiftUIで実装するにはハードルが高いと思います。なぜなら、SwiftUIが登場して、2年ほどしか立っておらず、フレームワークの進化の途中であり、iOSのバージョンで使えるAPIの差分が大きいと感じます。
- Combineに関して、すでにRxSwiftなどのReactive Programmingで実装する土壌がある程度整っていて、UIKitとSwiftUIどちらでも対応可能です。 さらにRxSwiftなどをすでに使っていれば、学習コストが低く、既存のものから置き換えやすいと考えられます。
- 以上の結果からCombineの導入で話を進めます。
Combine導入
Target versionをiOS13に変更する
⭕️
- サポートバージョンをiOS13にすれば問題なく使えることができます。
❌
- 運用中のサービスであると関係者の了解をとらないといけないです
OpenCombineを入れる
⭕️
- 導入が容易
- iOS13以下でも動作する
- Combineと実装は同じような実装はできる
❌
- ライブラリとして追加するのでバイナリサイズが余計に増え
- メンテナンスが継続的にされるかわからない
必要なところだけ自作してみる
⭕️
- 自作することで内部実装まで理解することが容易になります。
- OpenCombineを使うより、影響範囲を自分でコントロールできる
❌
- 自作するので実証者によっては学習コストがかかる
Combine導入後
- アーキテクチャや実装方針を決める
- 書き方をある決めておくとコードのメンテがしやすくなる
- githubに多くのサンプルがあるのでとても参考になります
参考リンク
- 投稿日:2020-12-15T14:30:46+09:00
iOS開発でのCombine導入の戦略について考えてみた
はじめに
- iOSのバージョンアップに伴いフレームワークも進化してきました。SwiftUI, Combineの登場によって、UIの構築方法の変化、アーキテクチャの変化が必要な時期になってきました。しかし、既存のプロダクトを全て書き換えるのはすぐにはできないです。今回は新しいフレームワークに徐々に移行していくための戦略に追加考えてみました。
CombineとSwiftUI
- 新しく登場したSwiftUIですがいきなり複雑なUIをSwiftUIで実装するにはハードルが高いと思います。なぜなら、SwiftUIが登場して、2年ほどしか立っておらず、フレームワークの進化の途中であり、iOSのバージョンで使えるAPIの差分が大きいと感じます。
- Combineに関して、すでにRxSwiftなどのReactive Programmingで実装する土壌がある程度整っていて、UIKitとSwiftUIどちらでも対応可能です。 さらにRxSwiftなどをすでに使っていれば、学習コストが低く、既存のものから置き換えやすいと考えられます。
- 以上の結果からCombineの導入で話を進めます。
Combine導入
Target versionをiOS13に変更する
⭕️
- サポートバージョンをiOS13にすれば問題なく使えることができます。
❌
- 運用中のサービスであると関係者の了解をとらないといけないです
OpenCombineを入れる
⭕️
- 導入が容易
- iOS13以下でも動作する
- Combineと実装は同じような実装はできる
❌
- ライブラリとして追加するのでバイナリサイズが余計に増え
- メンテナンスが継続的にされるかわからない
必要なところだけ自作してみる
⭕️
- 自作することで内部実装まで理解することが容易になります。
- OpenCombineを使うより、影響範囲を自分でコントロールできる
❌
- 自作するので実証者によっては学習コストがかかる
Combine導入後
- アーキテクチャや実装方針を決める
- 書き方をある決めておくとコードのメンテがしやすくなる
- githubに多くのサンプルがあるのでとても参考になります
参考リンク
- 投稿日:2020-12-15T12:47:33+09:00
Numbers API を利用した実践的なアプリで学ぶ SwiftUI(UIKit) + TCA
はじめに
こんにちは、アイカワと申します。
この記事は iOS Advent Calendar 2020 の 23 日目の記事です。
昨日は堤さんの「coremltools 4.0でPyTorchモデルを変換する」でした。最近は SwiftUI や The Composable Architecture(以後 TCA)の勉強をしていて、TCA についての解説記事もあまりないことを踏まえて、この記事を書こうと思います(ボリュームが想像以上に膨れあがってしまいすみません?♂️ )
iOS のバージョンの兼ね合いもあって SwiftUI をプロダクトに導入する事はまだ難しいかもしれませんが、TCA は UIKit でも利用できます。(Combine に依存しているため、iOS13 からしか利用できませんが? )
また、利用方法は SwiftUI と UIKit でそこまで大きくは変わらないです。
そのため UIKit から少し触ってみるでもいいので、SwiftUI をまだ触っていなくても一度勉強してみるのは良さそうと思っています。
それと、記事の最後でも少し説明しますが、「UIKit に TCA を組み込む -> View を SwiftUI に切り替える」という手順で徐々に SwiftUI に移行していくのも個人的には良さそうだと思っています。今回は TCA の作者である Point-Free さんが公開されている Example > Case Studies > 02-Effects-Cancellation を題材に TCA について解説してみようと思います。
「02-Effects-Cancellation」は実際に API 通信も行うことになるため、ある程度実践的な例になっていると思います。
しかし、そのまま解説しようとすると TCA の若干発展的な内容も含んでしまうため、「少し簡単な構造に作り替えた + UIKit に書き換えたものを追加」したものをベースに説明していこうと思います。(コードはこちら)TCA とは?
The Composable Architecture はざっくり説明すると(語弊はありますが) Redux ライクな状態管理手法を提供するライブラリです。(詳しい説明については README を参照していただけるとわかりやすいと思います。FAQ には Elm, Redux と比較した違いのようなことも書かれています)
今回 TCA が何かという厳密な説明は省略しますが、(正確ではない部分はありますが)イメージは ↓ の図のように
- View
- View そのもの。後述の ViewStore を保持する。ViewStore を通じ、「Action を Reducer に送って State(状態)を変更」・「State の変更を UI に反映」などを行う
- Action
- (主に)View から発生しうる Action を定義
- Reducer
- ViewStore から受け取った Action に応じて主に State を変更する
- Effect
- Reducer 内の処理で副作用が存在する場合には、Effect を利用して Action を送り、再び Reducer に State の変更を委ねる(今回の例で出てくるので、今何を言っているのかわからなくても大丈夫です)
- Effect で明確に副作用を管理しているというところが TCA のイチオシポイントです
- Environment
- Reducer で使用する依存関係(例えば API Client や スケジューラなど)の整理場所。Environment を Reducer で使用することによって、テストが容易になるなどの恩恵がある
- State
- アプリが管理する状態。Action によって動作する Reducer のみが State を変更できる
- Store
- State, Reducer, Environment を用いてイニシャライズされる。実装する上では、Store を直接利用するのではなく、Store を利用した ViewStore を主に利用することになる
という主に 8 つの役割(太字の部分を数えています)が登場します。
今回紹介する例では全てのものを利用するため、この記事を読み終える頃にはある程度、それぞれの役割と使い方を理解できるようになって頂けていれば嬉しいです。自分は TCA の存在自体は今城さんの「Swiftによるアプリ開発のためのThe Composable Architectureがすごく良いので紹介したい」という記事で知りました。
その時、TCA のことを良さそうだと思って何から勉強すれば理解できるんだろう、という時に役立ったのは Point-Free さんが公開されている A Tour of the Composable Architecture でした。
Part1 - 4 まで無料で公開されており、TCA の網羅的な解説や TCA の便利なテストサポート機能、Reducer の少し発展的な使用方法(Reducer を分割してみる)まで記載されているので、興味を持たれた方はぜひ目を通してみて頂けると良いと思います。
(余談ですが、Point-Free さんの他の動画は有料のものが多いですが、どれも非常に勉強になるものばかりなのでおすすめです)ちなみに、力尽きて続きを書くことができていないのですが、Qiita に「A Tour of the Composable Architecture」の Part1 - Part3 の前半までについては解説記事(ほぼ翻訳みたいになってしまっている)のようなものも書いているので、英語きつい...みたいな方はぜひ参考にしていただけると嬉しいです。(もちろん一番は元記事を見ていただくことが網羅的に理解できることに繋がるかとは思います? )
今回解説するアプリ概要について紹介
さて、前置きが長くなってしまいましたが、コードの紹介の前に簡単にアプリの概要を紹介します。
今回紹介するアプリは、好きな数字を選択して、その数字に関係するトリビアを表示するだけという UI 的には簡単なものになります。
数字にまつわるトリビアについては、Numbers API を使用して取得しています。
イメージのために、gif も貼っておきます ↓数字を Stepper で選択し、Button を押せば選択した数字に関するトリビアが出てくるシンプルなアプリです。通信中にボタンを再度押せば、通信をキャンセルすることができるようにもなっています。(おまけ程度に記事の後半で紹介する UIKit バージョンもチラッと gif に載せています)
Numbers API の仕様
Numbers API の Reference は ↓ になります。
非常にシンプルな API で、今回は以下のように利用しています。
typeはtriviaで固定numberはユーザーに Stepper で入力させるhttp://numbersapi.com/{number}/triviaを叩いて、その結果(String)を取得し、それを View 上に表示するという形で利用するコードを辿りながらアプリの実装方法について解説
一応、参考のために最初にファイルツリーを示します。
TCASampleCancellation |__ SwiftUI | |__ EffectsCancellationView.swift # SwiftUI 製の View と TCA の各要素 |__ UIKit | |__ EffectsCancellationViewController.swift # UIKit 製の View | |__ EffectsCancellationViewController.xib # xib |__ Internal | |__ ActivityIndicator.swift # ローディング中であることを示す Indicator(重要ではないので説明しない) | |__ UIViewRepresented.swift # UIKit から ActivityIndicator を利用できるようにしている(重要ではないので説明しない) |__ TCASampleCancellationApp.swift # Root View(各 View のイニシャライズを行う) |__ NumbersAPIClient.swift # NumbersAPI と通信するための APIClientTCA の要素それぞれについて説明する前に、まず Numbers API と通信するための APIClient の実装について説明します。
APIClient
APIClient Interface
NumbersAPIClient.swiftstruct NumbersAPIClient { var trivia: (Int) -> Effect<String, TriviaApiError> struct TriviaApiError: Error, Equatable {} }APIClient のインタフェースは上記のように定義されています。
Numbers API は任意の数字を含んだ URL でリクエストを行い、それによって返却される String のレスポンスを受け取る仕様になっています。
API 通信なのでエラーが発生する可能性も考慮し、独自のTriviaApiErrorという struct を定義し、利用しています。それらを考慮し、
triviaは(Int) -> Effect<String, TriviaApiError>というクロージャで定義されています。プログラム上の副作用は TCA では Effect で扱います。例えば今回の API Client などが副作用にあたるため、
triviaでは Effect を返却するようにしています。
Effect は Reducer 内で利用するため、そちらでもう少し詳しく解説します。
今は「 Effect は Combine の Publisher のラッパーで、副作用を扱うためのもの」程度に理解しておいて頂ければ問題ないと思います。APIClient Implementation
上で紹介した Interface を実際に実装して、簡単に利用できるようにしたものが以下になります。
NumbersAPIClient.swiftextension NumbersAPIClient { static let live = NumbersAPIClient( trivia: { number in URLSession.shared.dataTaskPublisher(for: URL(string: "http://numbersapi.com/\(number)/trivia")!) .map { data, _ in String.init(decoding: data, as: UTF8.self) } .catch { _ in Just("\(number) is a good number Brent") .delay(for: 1, scheduler: DispatchQueue.main) } .mapError { _ in TriviaApiError() } .eraseToEffect() }) }extension 内で、static な
liveという変数で定義することによって、他の場所からはNumbersAPIClient.liveという形で APIClient を利用できるようになります。
triviaはnumberという Int 型の変数を受け取って、その変数を利用してURLSessionで通信を行っています。
dataTaskPublisherの内部では主に以下のようなことが行われています。
"http://numbersapi.com/\(number)/trivia"でリクエストmap内で、受け取ったdataを元に String として decodecatchによって、エラーがあれば単純な"\(number) is a good number Brent"という文字列を返すようにしているmapErrorによって、エラーがあれば、TriviaApiError()を返すようにしているeraseToEffectは Publisher のOutputとFailureを Effect 型として扱えるようにする。上流の operator によってOutput == String,Failure == TriviaApiErrorという結果になっているため、Effect<String, TriviaApiError>として扱えるようになる(Publisher の Output, Failure などがピンとこない方は WWDC の Combine の動画を見ていただけると理解できると思います)上記は Combine の話がほとんどで、唯一
eraseToEffectのみは TCA の概念となっています。
eraseToEffectは上記で説明したことを行うだけですが、もし詳しい実装を見たい方はこちらを参照して頂けると良いかと思います。この一連の処理によって、
NumbersAPIClient.triviaは(Int) -> Effect<String, TriviaApiError>という定義を満たせるようになります。State
次に State についてです。
State という名前の通り、State ではアプリで管理したい状態を定義します。
TCA では State は基本的に struct で定義することになります。
State のコードは以下のようになります。EffectsCancellationView.swiftstruct EffectsCancellationState: Equatable { var count = 0 var currentTrivia: String? var isTriviaRequestInFlight = false }
count:ユーザーが現在選択している数字currentTrivia:数字についてのトリビアを入れるための変数。View 上にトリビアを表示しない場合もあるため optional で定義しているisTriviaRequestInFlight:API リクエスト中かどうかを判断するための変数State を
Equatableに適合させているのには主に以下のような理由があります。
- テストで State が扱いやすくなる
- State を assert するようなことを考えるとこれは想像しやすいかもしれないです。今回はテストまで踏み込んで説明はしないのですが、TCA のテストヘルパーは非常に強力なので一度利用してみるのがおすすめです。(一応、こちらの記事でもテストについて紹介しています)
- View で State を扱う際に自動的に State の重複を排除してくれる
- TCA は State を
Equatableに適合させることによって、自動的に State の重複を排除してくれる仕組みを備えています。Combine のremoveDupulicatesを利用して重複を排除するような実装になっている(コードはこちら)のですが、少し横道に逸れるため詳しくは踏み込まないでおこうと思います。State を
Equatableに適合させずに実装することもできるようですが、↑のような恩恵があるため基本的に State はEquatableに適合させるのが良いと思っています。Action
次に Action についてです。
Action はユーザーが起こす UI 操作イベントなど発生しうる全ての Action を定義する部分になります。
列挙する形になるため、enum で定義することになります。
これについても先にコードを示します。EffectsCancellationView.swiftenum EffectsCancellationAction: Equatable { case cancelButtonTapped // API リクエスト中にキャンセルボタンをタップした時 case stepperChanged(Int) // Stepper の値が変更された時(+ or - ボタンを押した時) case triviaButtonTapped // API リクエストボタンをタップした時 // ↑ ユーザーの操作によって発火 // --------------------------------------------------------------------- // ↓ Effect によって発火 // triviaButtonTapped の処理中で返却される Effect によって発火する case triviaResponse(Result<String, TriviaApiError>) }View の説明はまだしていないため、少しイメージが湧きにくいかもしれないのですが、先ほどの gif をイメージしながら考えていただけると良いと思います。
後で説明することになりますが、View 内で Action を送るためには、
viewStore.send(.アクション名)という記述で Action を発火させることができます。
TCA では Action を通じてのみアプリの状態である State を変更することができるため、何か状態を変更したい場合は必ず Action を発火させることになります。上から三つの Action はユーザーの操作によって発火するため、比較的わかりやすいと思うのですが、
triviaResponse(Result<String, TriviaApiError>)は少し特殊です。
具体的にはtriviaButtonTappedAction が発火することによって Reducer で State を変更するための処理が行われるのですが、その処理の中でtriviaResponseは発火します。
文章だけだとイメージも湧きにくく、次の Reducer 内で説明した方がわかりやすいかと思うので、ここではこのくらいの説明に留めます。ちなみに Action が
Equatableに適合しているのもテストで扱いやすくするためです。Reducer
さて、いよいよ TCA の中でも一番複雑な Reducer について説明します。
複雑とは言いましたが、処理の流れを少しずつ追えば理解できるものではあるので、少しずつ処理を追っていこうと思います。まず前提として Reducer は以下のように動作することを頭に入れていただけると良いかと思います。
- (主に)View から Action が
viewStore.send(アクション名)という形で送られる- Action に応じて Reducer 内で State を変更するための処理を行う
- その際、依存関係を利用するために後述の Environment を利用したり
- 副作用を扱うために前述の Effect を利用したりする
- 最終的に Reducer 内の各処理は Effect を返す
もし処理の流れがわからなくなった時は ↑ のどこにあたるかを意識しながら読んでいただけると良いと思います。
処理の流れを追う前に少しだけ Environment について説明します。
Environment
Environment はアプリにおいて依存関係を整理するための場所です。
イメージは外部から値を注入(DI)した方が、テストなどが書きやすくなるような値をここに置きます。
今回は以下のようになっています。struct EffectsCancellationEnvironment { var mainQueue: AnySchedulerOf<DispatchQueue> var numbersClient: NumbersAPIClient }今回はテストについて解説しないため、利点をうまく伝えることができず苦しいですが、こちらのコードを参照していただくのがわかりやすいと思います。
チラッとだけ説明すると、Test では Test 用の
TestStoreというものを利用することになるのですが、これのイニシャライズ時に Environment は以下のように注入されます。(コードの処理について解説はしないので、深く追わなくても大丈夫です)let store = TestStore( initialState: .init(), reducer: effectsCancellationReducer, environment: .init( mainQueue: self.scheduler.eraseToAnyScheduler(), trivia: { n in Effect(value: "\(n) is a good number Brent") } // mock の client(数を受け取って Effect を返すだけ) ) )このように Environment を利用することによって、簡単に依存関係を外部から注入できる仕組みが TCA には備わっています。
Reducer の処理を追ってみる
それでは、少しずつ処理を追っていきます。
まず、Reducer の全体像は以下のようになっています。
let effectsCancellationReducer = Reducer< EffectsCancellationState, EffectsCancellationAction, EffectsCancellationEnvironment > { state, action, environment in // 1. リクエストをキャンセルする時に、リクエストを一意に識別するための ID struct TriviaRequestId: Hashable {} switch action { case .cancelButtonTapped: // 1. キャンセルボタンを押した時の処理 case let .stepperChanged(value): // 2. Stepper で数字が変更された時の処理(value は 数字) case .triviaButtonTapped: // 3. triviaButton(API リクエストを行うボタン)を押した時の処理 case let .triviaResponse(.success(response)): // 3. 後で説明します case .triviaResponse(.failure): // 3. 後で説明します } }まず、Reducer は以下のように State, Action, Environment を定義して利用する形になります。
let effectsCancellationReducer = Reducer< EffectsCancellationState, EffectsCancellationAction, EffectsCancellationEnvironment > { state, action, environment in // ... }このように定義することによって、今まで定義してきた State, Action, Environment を Reducer 内で扱うことができるようになります。
実際にどのように扱うかについては、コメントアウトの部分に数字を振ったので、数字ごとに追っていきます。
1. キャンセルボタンを押した時の処理
まず、View のキャンセルボタンを押した時に関わる処理から説明します。
Reducer は「Action を受け取る -> State を変更する -> Effect を返却する(後ほど説明)」という処理をほぼ一貫して行うため、一つの処理を理解できれば、次以降はサクサク理解できるかなと思います。処理は多くないため、「キャンセルボタンを押した時の処理」について先にコードを示します。
struct TriviaRequestId: Hashable {} switch action { case .cancelButtonTapped: state.isTriviaRequestInFlight = false return .cancel(id: TriviaRequestId()) case ...
TriviaRequestIdという struct はリクエストを一意に識別するために利用するHashableなもので、今回のように.cancel(id: )の引数として利用することができます。
.cancelなどの内部的な処理については詳しく解説はしませんが、コードを見ていただけるとなんとなく処理は掴めるかなと思います。概要だけ説明すると
TriviaRequestIdは以下のように扱っています。
- リクエストを投げる時には
.cancellable(id: TriviaRequestId())という形で一意にリクエストを識別できる値を保持する(後の 3 番で登場します)- リクエストをキャンセルしたい時には今回のように
.cancel(id: TriviaRequestId())という形で一意に識別したリクエストをキャンセルできる以上を踏まえて、
.cancelButtonTappedAction では以下のような処理が行われています。
- State の
isTriviaRequestInFlightをfalseにして、リクエスト中ではないという状態を保持する.cancel(id: TriviaRequestId())によってリクエストをキャンセルしつつ、.cancel(id: )の戻り値である Effect を 返却 するReducer の処理の流れについては説明しましたが、Effect を 返却 するという部分については説明できていないため、軽く説明します。
TCA における Reducer は Effect を返却する必要があるため、各 Action を受け取って処理を終える時には必ず何らかの Effect を 返却しなければなりません。
.cancel(id: )はリクエストをキャンセルしつつ、Effect を戻り値として持っています。
参考のために、.cancel(id: )の実装は以下のようになっています。swift-composable-architecture/Sources/ComposableArchitecture/Effects/Cancellation.swiftpublic static func cancel(id: AnyHashable) -> Effect { return .fireAndForget { cancellablesLock.sync { cancellationCancellables[id]?.forEach { $0.cancel() } } } }このため
return .cancel(id: )という実装によって、「Reducer が Effect を返却しなければならない」という要件を満たすことができていることになります。2. Stepper で数字が変更された時の処理
次に View の Stepper で数字が変更された時に関わる処理を説明します。
こちらもコードを先に示します。switch action { case ... case let .stepperChanged(value): state.count = value state.currentTrivia = nil state.isTriviaRequestInFlight = false return .cancel(id: TriviaRequestId()) case ...先ほどの 1 番の処理が理解できていれば、こちらの処理は容易く理解できると思います。
一応説明しておくと以下のような処理が行われています。
- State の
countに Stepper で選択された数字を保持する- 数字が変更されたということなので、State の
currentTriviaをnilにし、画面に表示する用のトリビアテキストが表示されないようにする- State の
isTriviaRequestInFlightをfalseにして、リクエスト中ではないという状態を保持する.cancel(id: TriviaRequestId())によってリクエストをキャンセルしつつ、.cancel(id: )の戻り値である Effect を 返却する(1 番と同じ処理)3. triviaButton を押した時の処理
さて、Reducer の最後の処理です。
switch action { case ... // 3-1: triviaButton が押された時に発火 case .triviaButtonTapped: state.currentTrivia = nil state.isTriviaRequestInFlight = true return environment.numbersClient.trivia(state.count) .receive(on: environment.mainQueue) .catchToEffect() .map(EffectsCancellationAction.triviaResponse) .cancellable(id: TriviaRequestId()) // 3-2: map(EffectsCancellationAction.triviaResponse) が success だった時に発火 case let .triviaResponse(.success(response)): state.isTriviaRequestInFlight = false state.currentTrivia = response return .none // 3-3 map(EffectsCancellationAction.triviaResponse) が failure だった時に発火 case .triviaResponse(.failure): state.isTriviaRequestInFlight = false return .noneさらに番号を振ったので、番号ごとに説明します。
3-1
case .triviaButtonTapped(複雑なので State の変化と Effect に関わる処理に分けて説明します)[State の変化]
- State の
currentTriviaをnilにして、一旦 View に表示されるトリビアを非表示にする- State の
isTriviaRequestInFlightをtrueにして、リクエスト中であるという状態を保持するEffect に関わる処理は結構複雑であるため、↑ の図と照らし合わせながら説明します。
countの値を使って、Environment のnumbersClientで API リクエストを行っている(①)
- この時点では
Effect<String, TriviaApiError>という型になっている(忘れてしまった方は APIClient の実装部分を参照)environment.mainQueueで指定したスレッドで処理を実行するようにしている(②)Effect<String, TriviaApiError>という型で流れてきた値をcatchToEffectによって<Result<String, TriviaApiError>, Never>という型で扱えるようにする(TCA のcatchToEffectについての実装についてはこちら)map(EffectsCancellationAction.triviaResponse)で、流れてきた Result の結果によってtriviaResponseAction を発火させるcancellable(id: TriviaRequestId())という形で一意にリクエストを識別できるように保持しておきつつ、Effect を返却している複雑には見えますが、一つ一つの operator を追っていけば何とか処理の流れは理解できるかなと思います。
重要なのはcatchToEffectによって<Result<String, TriviaApiError>, Never>という型で扱えるようにしている部分だと思います。
この Result でEffectsCancellationAction.triviaResponseを発火させることによって、Result が success の場合であれば、.triviaResponse(.success(response))が発火し、Result が failure であれば、.triviaResponse(.failure)が発火するという流れになっています。それぞれの
triviaResponseAction が発火した後の動作は簡単ですが、一応説明します。3-2
case let .triviaResponse(.success(response))
- リクエストが終了した状態なので、State の
isTriviaRequestInFlightをfalseにする- State の
currentTriviaに返却されたresponseを代入するnoneEffect(何も Effect を返却する必要がない場合はこれを使う) を返却する3-3
.triviaResponse(.failure)
- リクエストが終了した状態なので、State の
isTriviaRequestInFlightをfalseにするnoneEffect を返却するView
最後に View について説明します。ここまで理解できていればそれほど難しくないと思います。
まず全体像を示します。EffectsCancellationView.swiftstruct EffectsCancellationView: View { // ① Store を View 内で定義 let store: Store<EffectsCancellationState, EffectsCancellationAction> var body: some View { // ② WithViewStore を使って ViewStore を利用できるようにする WithViewStore(self.store) { viewStore in Form { Section( footer: Button("Number facts provided by numbersapi.com") { UIApplication.shared.open(URL(string: "http://numbersapi.com")!) } ) { // ④ Stepper の表示 Stepper( value: viewStore.binding( get: { $0.count }, send: EffectsCancellationAction.stepperChanged) ) { Text("\(viewStore.count)") } // ③ トリビアボタン(リクエスト・リクエストキャンセル)の表示 if viewStore.isTriviaRequestInFlight { HStack { Button("Cancel") { viewStore.send(.cancelButtonTapped) } Spacer() ActivityIndicator() } } else { Button("Number fact") { viewStore.send(.triviaButtonTapped) } .disabled(viewStore.isTriviaRequestInFlight) } // ⑤ トリビアテキストの表示 viewStore.currentTrivia.map { Text($0).padding([.top, .bottom], 8) } } } } .navigationBarTitle("Effect cancellation") } }イメージとしては以下をおさえておけばコードを理解できると思います。
- Store を View 内で定義
- WithViewStore を用いて、ViewStoreを View 内で利用できるようにする
- ViewStore を用いて Action を送ったり、State を利用したりする
- Action を送った結果 State が変更されれば View は自動的に更新されます
こちらも少しずつ見ていきます。
① Store を View 内で定義
EffectsCancellationView.swiftlet store: Store<EffectsCancellationState, EffectsCancellationAction>まず、View 内で Store を定義します。
Store ではこれまでに作成したEffectsCancellationStateとEffectsCancellationActionを利用するようにします。View のイニシャライズ時にこの store に値を設定するため、View のイニシャライズは以下のように行います。
TCASampleCancellationApp.swiftEffectsCancellationView( store: Store( initialState: EffectsCancellationState(), // State のイニシャライズ reducer: effectsCancellationReducer, // Reducer は EffectsCancellationView.swift で定義したものを利用 environment: EffectsCancellationEnvironment( // Environment はイニシャライズ時に注入 mainQueue: DispatchQueue.main.eraseToAnyScheduler(), numbersClient: NumbersAPIClient.live ) ) )このようにすれば、View 内で Store を利用することが可能になります。
② WithViewStore を使って ViewStore を利用できるようにする
EffectsCancellationView.swiftvar body: some View { WithViewStore(self.store) { viewStore in // ... } }先ほど Store を定義しましたが、View 内部では Store を直接扱うのではなく、ViewStore というものを用いて Action の送信や State へのアクセスを行います。
↑のようにWithViewStoreに定義していた Store を入れてあげると ViewStore が View から扱えるようになります。
(ちなみに Store を与えるだけで利用できるようになっているのは、State をEquatableに適合させているからになります。)③ トリビアボタン(リクエスト・リクエストキャンセル)の表示
次はトリビアボタンの表示に関わる部分について説明します。
画面では以下がそれぞれ対応します。
リクエストボタン リクエストキャンセルボタン コードは以下になります。
EffectsCancellationView.swiftif viewStore.isTriviaRequestInFlight { HStack { Button("Cancel") { viewStore.send(.cancelButtonTapped) } Spacer() ActivityIndicator() } } else { Button("Number fact") { viewStore.send(.triviaButtonTapped) } .disabled(viewStore.isTriviaRequestInFlight) }難しいことはしていませんが、以下のような処理を行っています。
isTriviaRequestInFlightがtrueなら
- 通信中ということなので「Cancel ボタン」を表示する
isTriviaRequestInFlightがfalseなら
- 通信中ではないということなので「Number Fact」ボタンを表示する
処理は簡単ですが、重要なことを行っているので軽く以下で説明します。
ViewStore を通した State の利用
コードでは
viewStore.isTriviaRequestInFlightがこれに当たります。
細かい理解をせずとも ViewStore を通じて State 内の変数を利用できるということさえ理解できれていれば TCA を用いた実装を行うことは可能ですが、仕組みについても少しだけ説明します。TCA の ViewStore 自体の State に関わる実装は以下のようになっています(コード全体はこちら)。
swift-composable-architecture/Sources/ComposableArchitecture/ViewStore.swift@dynamicMemberLookup public final class ViewStore<State, Action>: ObservableObject { ... public private(set) var state: State { willSet { self.objectWillChange.send() } } public subscript<LocalState>(dynamicMember keyPath: KeyPath<State, LocalState>) -> LocalState { self.state[keyPath: keyPath] } ... }上記のように、dynamicMemberLookup を用いることによって、
viewStore.state.isTriviaRequestInFlightではなくviewStore.isTriviaRequestInFlightという形で利用できるようになっています。SwiftUI で ViewStore を利用する時には、
stateという記述を省略できて良さそう?くらいの印象ですが、後述する「UIKit から ViewStore を利用する場合」との対称性的なことを考えると、ここで dynamicMemberLookup が使われているメリットももう少し見えてくると今城さんの発表を聞いて感じることができました。(余談ですが、今城さんが開催されている iOSアプリ開発のためのFunctional Architecture情報共有会はクローズドな共有会ですが、非常に勉強になって楽しいので興味がある方は参加してみると良いかもしれないです)ViewStore を通した Action の送信
前述したように TCA では Action を送ることにより Reducer に Action ごとの処理を委ね、State の変更を行います。
つまり、Action を送ることでしか State を変更することはできません。
Action の送信方法は簡単で、以下のようにすれば Action を送信することが可能です。EffectsCancellationView.swiftButton("Cancel") { viewStore.send(.cancelButtonTapped) } Button("Number fact") { viewStore.send(.triviaButtonTapped) }このように
sendに Action 名を指定すれば、View 内では状態を気にせず実装を行うことができます。④ Stepper の表示
Stepper で数字を切り替える部分に関するコードは以下になります。
EffectsCancellationView.swiftStepper( value: viewStore.binding( get: { $0.count }, send: EffectsCancellationAction.stepperChanged) ) { Text("\(viewStore.count)") }③と利用方法が若干異なる部分は
viewStore.bindingを使っている部分だけなので、そこだけ説明します。
例えば、今回が良い例だと思うのですが Stepper のように数字が変更された場合、その数字自体も取得して Stepper の value にしたいし、Stepper によって数字が変更された場合、Action を送りたいという場合にviewStore.bindingを利用します。
viewStore.bindingは上記のコードのように、getに取得したい State を記述し、sendに送信したい Action を記述するだけになります。⑤ トリビアテキストの表示
最後にトリビアテキストを表示する部分になります。
以下のようにmapを利用して、currentTriviaState に入っている値を Text として表示しているだけになります。EffectsCancellationView.swiftviewStore.currentTrivia.map { Text($0).padding([.top, .bottom], 8) }以上が SwiftUI + TCA の解説でした。(長くなってしまったのに加え、わかりにくい所も結構ありそうです... ?♂️ )
次は、このアプリを UIKit+TCA で作ってみたので、その説明もおまけ程度にしてみようかなと思います。(コードは冒頭で説明したリポジトリの UIKit ディレクトリに置いています)
おまけ(UIKit で TCA を使う)
ここまでで、SwiftUI と TCA の相性の良さについては何となく理解してもらえていると嬉しいです。
もちろん、この位の複雑度の低いアプリであれば、TCA を利用せずに作ることもできますが、状態が増えれば増えるほど TCA のありがたみは大きくなりそうです。
状態が増えてきて、Reducer ごちゃごちゃになってしまいそう...みたいな時には、今回は紹介できていませんが、Reducer を分割することもできます。さて、TCA についてある程度解説を終えた上で、おまけ程度に今回のアプリを UIKit + TCA で作ってみたので、その紹介もします。
SwiftUI と UIKit における TCA の利用方法の違い
SwiftUI と UIKit における TCA の利用方法は大きくは異なりません。
State, Action, Reducer, Environment などについては使い回すことができるため、UIKit で View を作り替えて、少しだけ調整してあげれば終わりです。State・Action・Reducer・Environment をそのまま利用するという前提だと、主に以下の対応を行えば UIKit に置き換えることができます。
- UIKit で View を作る
- 今回は個人的に楽な方法で実装したいので Storyboard と ViewController で作ります
- ViewController で
viewStoreを定義する- ViewController の
viewDidLoad内でviewStore.publisherから値を取得して UI に反映する
viewStore.publisherは後で詳しく説明しますが、Combine の Publisher とほぼ同じくらいのイメージで OK です- RxSwift であれば ViewModel から View にデータバインディングする感覚と似ていると思います
- UI イベントが起こった時に
viewStore.sendで Action を発火させる- (おまけ)今回は SwiftUI 製の画面からから、この View に遷移させたいため、
UIViewControllerRepresentableに適合させた WrapperView を作りますそれでは、一つずつ見ていきます。
UIKit で View を作る
楽に作ることを目的としたため、SwiftUI と同じ見た目の View ではないですが、機能を最低限満たす以下のようなものを UIKit で作りました。
単純な View だけに関わるコードは以下になります。(ほとんど Storyboard で作っています)
EffectsCancellationViewController.swiftfinal class EffectsCancellationViewController: UIViewController { @IBOutlet private weak var numberLabel: UILabel! // ユーザーが選択した数字を表示するラベル @IBOutlet private weak var triviaLabel: UILabel! // API 実行が成功した時に表示するトリビアラベル @IBOutlet private weak var apiButton: UIButton! // NumberFact, Cancel 用のボタン @IBOutlet private weak var activityIndicator: UIActivityIndicatorView! ... }ViewController で
viewStoreを定義するSwiftUI の場合は View 内で Store を定義していましたが、UIKit の場合は ViewStore を定義します。
MVVM を触ったことがある方であれば、ViewModel のように扱うものだとイメージして頂くのがわかりやすそうな気がします。ViewStore に関わるコードは以下のようになります。
EffectsCancellationViewController.swiftfinal class EffectsCancellationViewController: UIViewController { ... private let viewStore: ViewStore<EffectsCancellationState, EffectsCancellationAction> private var cancellables: Set<AnyCancellable> = [] init(store: Store<EffectsCancellationState, EffectsCancellationAction>) { self.viewStore = ViewStore(store) ... }SwiftUI とそこまで変わらないのですが、SwiftUI では Store を定義していたものの代わりに ViewStore を
EffectsCancellationStateとEffectsCancellationActionを利用する形で定義しています。
イニシャライズ時にviewStoreをセットしてあげる必要があるため、ViewController のイニシャライザで注入するようにしています。
cancellablesは Combine における Cancellable です。
viewDidLoad内でviewStore.publisherから値を取得して UI に反映するSwiftUI と主に異なる部分は State を UI へ反映する方法だと思います。
とは言え、Combine さえ理解できていれば特に難しいことはないと思います。先ほど、
viewStoreを定義しましたが、viewStoreにはStorePublisher<State>型のpublisherがあります。
StorePublisher<State>は Combine における Publisher のラッパーであるため、viewStore.publisherは基本的に Publisher と同じように扱うことができます。そのため、UIKit の場合は以下のコードのように
viewStore.publisherを通じて State を取得し、State に変更があれば UI にも反映されるようにします。EffectsCancellationViewController.swiftoverride func viewDidLoad() { super.viewDidLoad() // activityIndicator はずっとアニメーションさせておく activityIndicator.startAnimating() viewStore.publisher .map { "\($0.count)" } .assign(to: \.text, on: numberLabel) .store(in: &cancellables) viewStore.publisher.currentTrivia // 加工する必要がなければ直接 assign できる .assign(to: \.text, on: triviaLabel) .store(in: &cancellables) // (Combine 勉強中なので、UIButton に assign する方法がわかりませんでした ? ) viewStore.publisher.sink { [weak self] state in let buttonTitle = state.isTriviaRequestInFlight ? "Cancel" : "NumberFact" self?.apiButton.setTitle(buttonTitle, for: .normal) }.store(in: &cancellables) viewStore.publisher .map { $0.isTriviaRequestInFlight ? false : true } .assign(to: \.isHidden, on: activityIndicator) .store(in: &cancellables) }UI イベントが起こった時に
viewStore.sendで Action を発火させるUI イベントが起こった時に
viewStore.sendで Action を発火させるのは SwiftUI の場合とほとんど変わりません。
具体的に発火させているのは以下のコードになります。EffectsCancellationViewController.swift@IBAction private func tapStepper(stepper: UIStepper) { viewStore.send(.stepperChanged(Int(stepper.value))) } @IBAction private func tapAPIButton(_ sender: Any) { if viewStore.isTriviaRequestInFlight { viewStore.send(.cancelButtonTapped) } else { viewStore.send(.triviaButtonTapped) } }今回の場合、Stepper で値が変更された時と、API ボタンがタップされた時に Action を発火させたいため、上記のようなコードになります。
(おまけ)
UIViewControllerRepresentableに適合させた WrapperView を作る最後におまけですが、今回は SwiftUI 製の ListView からこの UIKit 製の View に遷移させたいため、
UIViewControllerRepresentableに適合させた WrapperView を作っておきます。EffectsCancellationViewController.swiftstruct EffectsCancellationViewControllerWrapper: UIViewControllerRepresentable { let store: Store<EffectsCancellationState, EffectsCancellationAction> init(store: Store<EffectsCancellationState, EffectsCancellationAction>) { self.store = store } typealias UIViewControllerType = EffectsCancellationViewController func makeUIViewController(context: Context) -> UIViewControllerType { return EffectsCancellationViewController(store: store) } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} }上記のように定義しておけば、SwiftUI 製の ListView からは以下のように UIKit 製の View に遷移させることができます。
TCASampleCancellationApp.swiftNavigationLink( "UIKitView", destination: EffectsCancellationViewControllerWrapper( store: Store( initialState: EffectsCancellationState(), reducer: effectsCancellationReducer, environment: EffectsCancellationEnvironment( mainQueue: DispatchQueue.main.eraseToAnyScheduler(), numbersClient: NumbersAPIClient.live ) ) ) )以上で UIKit + TCA についての説明は終了になります?
おわりに
非常に長くなってしまいましたが、今回は実際に API と通信するアプリを題材に 「SwiftUI + TCA」と「UIKit + TCA」について説明してみました。
TCA について興味がある方にとってこの記事が参考になれば嬉しいです。また、今回は「SwiftUI + TCA」でできたものを「UIKit + TCA」に移行するという流れでの紹介になってしまいましたが、この逆も簡単にできそうであることは想像しやすいと思います。
まだ iOS12 以下を切ることができないプロダクトが多いとは思いますが、徐々に移行していく際は
- UIKit 製の View の状態管理を TCA で行うようにする
- その状態の View を UIKit から SwiftUI に置き換える
という流れも個人的にはありかもと感じているため、選択肢を増やす意味でも TCA を理解しておくメリットはあると思います。
自分が今関わっているプロダクトではアーキテクチャとして VIPER を採用しているため、VIPER の状態管理を SwiftUI に移行していく方法も TCA を頭に入れながら探っていきたいと個人的には思っています。改めて長い文章となってしまいましたが、読んでいただきありがとうございました!(間違っているところがあればぜひ教えていただけますと幸いです? )
- 投稿日:2020-12-15T12:15:31+09:00
Mintをプロジェクトディレクトリにインストールする
はじめに
Swift製コマンドラインツールのMintですが、複数のプロジェクトで使用されているビルドマシンにインストールせずにプロジェクトディレクトリ内にインストールし、プロジェクトごとに管理する様にしてみました。
Mintとは
https://github.com/yonaskolb/MintSwift製コマンドラインツールのインストールと実行を行うパッケージマネージャーです。
以下の様なパッケージを管理できます。
- XcodeGen
- SwiftLint
- SwiftGen
- Carthage
- LicensePlist
普通にインストールした場合は、
packageは/usr/local/lib/mintにインストールされ、
packageをbuildしたcommandのリンクは/usr/local/binにインストールされます。プロジェクトディレクトリにインストール
MintのREADME.mdによると、以下の環境変数でインストール先を指定できるという事なのでこれらを使用します。
MINT_PATH: packageのインストール先ディレクトリMINT_LINK_PATH: packageをbuildしたcommandのリンクのインストール先ディレクトリ1. Mintのディレクトリを作成しクローンする
プロジェクトディレクトリ内にMint用のディレクトリを作成しその中にクローンします。
$ mkdir mint $ tree . ├── Mintfile └── mint 1 directory, 1 file $ cd mint $ git clone https://github.com/yonaskolb/Mint.git Cloning into 'Mint'... remote: Enumerating objects: 36, done. remote: Counting objects: 100% (36/36), done. remote: Compressing objects: 100% (28/28), done. remote: Total 1952 (delta 10), reused 17 (delta 6), pack-reused 1916 Receiving objects: 100% (1952/1952), 357.89 KiB | 669.00 KiB/s, done. Resolving deltas: 100% (1113/1113), done. $ cd .. $ tree . ├── Mintfile └── mint └── Mint ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Sources │ ├── Mint │ │ └── main.swift (一部省略) 10 directories, 42 files2. パスを指定してインストール
MINT_PATH,MINT_LINK_PATHを指定してインストールする$ cd mint/Mint $ export MINT_PATH="../lib" MINT_LINK_PATH="../bin" $ swift run mint install yonaskolb/mint Fetching https://github.com/jakeheis/SwiftCLI.git (一部省略) [70/70] Linking mint ? Finding latest version of mint ? Cloning mint 0.16.0 ? Resolving package ? Building package ? Installed mint 0.16.0 ? Linked mint 0.16.0 to /Users/User_Name/projects/mint_test/mint/bin $ cd ../.. $ tree . ├── Mintfile └── mint ├── Mint │ ├── CHANGELOG.md (一部省略) ├── bin │ └── mint -> /Users/User_Name/projects/mint_test/mint/lib/packages/github.com_yonaskolb_mint/build/0.16.0/mint └── lib ├── metadata.json └── packages └── github.com_yonaskolb_mint └── build └── 0.16.0 └── mint 16 directories, 45 files3. シンボリックリンクを変更
上のログを確認してもらうと
mint/bin/mintのリンクが絶対パスになっているのが分かると思います。
ビルドマシンなどで実行できなくなるので、これを相対パスに変更します。$ cd mint/bin $ export mint_bin_path=$(find ../lib/packages/github.com_yonaskolb_mint/build/*/mint) $ ln -sf $mint_bin_path mint $ tree . └── mint -> ../lib/packages/github.com_yonaskolb_mint/build/0.16.0/mint4. Packagesをインストール
Mintfileに設定してあるpackageをインストールします。
$ export MINT_PATH="mint/lib" MINT_LINK_PATH="mint/bin" $ mint/bin/mint bootstrap ? Cloning SwiftLint 0.41.0 ? Resolving package ? Building package ? Installed SwiftLint 0.41.0 ? Cloning XcodeGen 2.18.0 ? Resolving package ? Building package ? Copying resources for XcodeGen: SettingPresets ... ? Installed XcodeGen 2.18.0 ? Installed 2/2 packages $ mint/bin/mint run xcodegen --version Version: 2.18.0シェルスクリプト
MINT_PATH,MINT_LINK_PATHを指定してインストールしたり実行するのが面倒なのでシェルスクリプトを作成しました。
https://github.com/yd2x/mint_sh使用方法:
$ tree . ├── Mintfile ├── mint └── mint.sh 1 directory, 2 files [インストール] $ sh mint.sh --install [packageインストール] $ sh mint.sh bootstrap [package実行] $ sh mint.sh run xcodegenReferences
- 投稿日:2020-12-15T09:56:07+09:00
[SwiftUI] 初回画面のチュートリアルを実装する
自作で作成する時を考える
完成例
初回起動時を探知する
Enum&Structs.swiftstruct CurrentUserDefaults{ // 初回起動時判定 static let isFirstVisit = "is_first_visit" }構造体として参照できるようにしておくと便利らしい?
方針としては、UserDefaultsに記述しておくと識別できるとのことらしい
ContentView.swift.onAppear(){ firstVisitSetup() } .fullScreenCover(isPresented: $isShowTutorialView, content: { // Tutorial Viewに飛ばす TutorialView() }) func firstVisitSetyp() { let visit = UserDefaults.standard.bool(forkey: CurrentUserDefaults.isFirstVisit) if visit { print("Two times") //MARK: 以下はプレビュー用 UserDefaults.standard.set(false, forKey: CurrentUserDefautls.isFirstVisit) }else{ print("First Access") self.isShowTutorialView.toggle() UserDefaults.standard.set(true, forKey: CurrentUserDefaults.isFirstVisit) } }スライドの実装
以下 TabViewのみ抜粋
TutorialView.swift// Slide Tutorial View TabView(selection: $selection, content: { Image("tutorial_image_1") .resizable() .scaledToFit() .tag(1) Image("tutorial_image_1") .resizable() .scaledToFit() .tag(2) Image("tutorial_image_1") .resizable() .scaledToFit() .tag(3) Button(action: { print("BUTTON CLICKED") presentaionMode.wrappedValue.dismiss() }, label: { Text("Let's Start".uppercased()) .font(.title3) .fontWeight(.bold) .foregroundColor(.white) .padding(.all, 30) .background(Color.MyTheme.blueColor) .cornerRadius(10) .shadow(radius: 20) }) .tag(4) }) .tabViewStyle(PageTabViewStyle()) .frame(height: 500)まとめ
意外とすんなり実装することができました!
最後まで読んでいただき、ありがとうございます!Twitter (https://twitter.com/Ryosuke_Kamimur)
Github (https://github.com/Ryosukekamimura)参考
メモ:Swift5で初回起動判定(https://qiita.com/Luke1220/items/60ac8061e39c9704a9ae)
- 投稿日:2020-12-15T09:41:08+09:00
didSetとwillSetの挙動
didSetについて理解するために作成したものを載せます。
- プロパティの値が変更されるときに呼びだすことができる。
didSetは変更された値に応じて、再設定することができる。class Test { var num: Int = 0 { willSet { print("numの値が\(newValue)に変更されそう") } didSet { print("numの値が\(self.num)に変更された") if self.num > 100 { print("numが100以上だったから、20に戻す") self.num = 20 } } } } var test = Test() test.num = 102 print(test.num)//20
- 投稿日:2020-12-15T08:14:30+09:00
【Swift】型の構成要素〜型のネスト〜
型のネスト とは
Swiftでは、型の中に型を定義することができ、それを型のネストと言います。
ネストされた型はネストする型の名前を引き継ぐことになるため、
型名をより明確かつ簡潔にできるというメリットがあります。定義方法
型のネストをするには、型の定義の中に型を定義します。
例として以下のようなコードがあったとします。
スポーツをやっていた人のステータスを表すSports型と、
そのスポーツの種類を表すSportsKind型が存在したとします。enum SportsKind { case tennis case soccer case baseball } struct Sports { var name: String var history: Int let kind: SportsKind }この場合、SportsKind型は、Sports型の種類を表していることは推測できますが、
命名で縛っているにすぎません。次のサンプルコードのように
Sports型の中にSportsKind型をネストしKindにリネームすると、
Sports.Kind型となります。Sports.Kintd型は、SportsKind型と比べると、
Sports型との関連性が取り明確になっています。また、
let kind: Kindを見てわかるように、
Sports.Kind型はSports型の内部ではKind型として参照できるため
型名もより簡潔に記述できるようになりました。struct Sports { enum Kind { case tennis case soccer case baseball } var name: String var history: Int let kind: Kind init(name: String, kind: Kind, history: Int) { self.name = name self.kind = kind self.history = history } func status() { // "\n" は改行を表しています。 print("名前:\(name)\nスポーツ:\(kind)\n経験歴:\(history)年") } } let humanAKind = Sports.Kind.baseball let humanBKind = Sports.Kind.soccer let humanA = Sports(name: "坂本", kind: humanAKind, history: 30) let humanB = Sports(name: "本田", kind: humanBKind, history: 25) humanA.status() print("---") humanB.status() 実行結果 名前:坂本 スポーツ:baseball 経験歴:30年 --- 名前:本田 スポーツ:soccer 経験歴:25年以上で型のネストについての説明を終了します!
型の中に型を入れるだけなの記事のボリュームは小さいですが、
型のネストは結構重要な内容です。余談ですがString型やInt型も型の中にさらに型が存在します。
そのくらい色々なところで当たり前に使われている機能なので、
ぜひ使えるようになってください!他の記事でも型の構成要素について記載しているものがあるので、ぜひそちらもご覧ください。
・【Swift】型の構成要素〜型の基本〜
・【Swift】型の構成要素〜プロパティ前編〜
・【Swift】型の構成要素〜イニシャライザ〜
・【Swift】型の構成要素〜メソッド〜
・【Swift】型の構成要素〜サブスクリプト〜
・【Swift】型の構成要素〜エクステンション〜最後までご覧いただきありがとうございました。
- 投稿日:2020-12-15T08:03:03+09:00
[swift5]URLを指定してアプリから外部サイトへ遷移する方法
投稿のポイント
今回は、開発中のアプリケーションから開発者のSNSやホームページに遷移する機能を紹介します。
実装コード
ViewController.swift// url = 遷移したいサイトのURLをString型で指定 let url = NSURL(string: "") if UIApplication.shared.canOpenURL(url! as URL) { UIApplication.shared.open(url! as URL, options: [:], completionHandler: nil) }これでOK!
ボタンやTableViewのCellをタップすると遷移するようにしてみるとシンプルでユーザーも直感的に理解しやすいかなと思います。
- 投稿日:2020-12-15T06:10:31+09:00
【Swift】型の構成要素〜エクステンション〜
エクステンションとは
エクステンションは、すでに存在している型に要素を追加し、型を拡張することを言います。
追加できる要素としては、プロパティやメソッド、イニシャライザなどが挙げられます。定義方法
エクステンションは
extensionキーワードで宣言することができ、
{ }内に型を構成する要素を定義します。extesion エクステンションを定義する対象の型 { 対象の型に追加したい要素 }メソッドの追加
エクステンションで追加したメソッドは通常のメソッドと同様に使用できます。
次のサンプルコードでは、標準ライブラリにある
String型を拡張しています。extension String { func printSelf() { print(self) } } let string = "abc" string.printSelf() 実行結果 abcコンピューテッドプロパティの追加
エクステンションでは、ストアドプロパティは追加することができませんが、
コンピューテッドプロパティなら追加することができます。コンピューテッドプロパティを追加すれば、
アプリケーション内で既存の型に対して頻繁に行われる処理を型自身に定義できます。サンプルコードでは、
String型に「」(鍵括弧)で囲んだ値を返すコンピューテッドプロパティを作成しました。extension String { var kakko: String { return "「\(self)」" } } let title = "吾輩は猫である".kakko print(title) 実行結果 「吾輩は猫である」イニシャライザの追加
エクステンションではイニシャライザを追加することも可能です。
既存の型にイニシャライザを追加することで、
アプリケーション固有の情報から既存の型のインスタンスを生成することも可能になります。サンプルコードでBook型を作成しました。
このBook型は、イニシャライザで本のタイトルと登場キャラを引数に指定しています。
let book1 = Book(title: "NARUTO", char: "日向ヒナタ")が
既存のBook型でインスタンス化した処理になります。
(ヒナタが好きなのでヒナタにしました。サクラじゃなくてすみません。)インスタンス化された際にヒナタはイニシャライザでプロパティcharactersに追加されました。
なのでprint(book1.characters)の結果が["日向ヒナタ"]になります。ですが、通常のインスタンス化ではキャラクターを一人しか追加できず面倒なので、
一気に複数人のキャラクターを追加したくなると思います。そういった場合に、エクステンションでイニシャライザを追加します。
init(title: String, charArray: [String])が新しく追加したイニシャライザです。こちらは、既存のイニシャライザと違い、
Array<String>型、つまり、String型の配列を引数に指定しています。なので、事前に配列を作成しそれを引数に渡します。
配列の定義
var charArray1: [String] = ["ルフィ", "ゾロ", "サンジ", "ウソップ"]インスタンス化
let book2 = Book(title: "ONE PIECE", charArray: charArray1)出力結果
["ルフィ", "ゾロ", "サンジ", "ウソップ"]struct Book { var title: String var characters: [String] = [] init(title: String, char: String) { self.title = title self.characters.append(char) } } extension Book { init(title: String, charArray: [String]) { self.title = title self.characters = charArray } } var charArray1: [String] = ["ルフィ", "ゾロ", "サンジ", "ウソップ"] var charArray2: [String] = ["竈門炭治郎", "竈門禰豆子", "冨岡義勇", "煉?獄杏寿郎"] // 通常のイニシャライザを使用 let book1 = Book(title: "NARUTO", char: "日向ヒナタ") //エクステンションで追加されたイニシャライザを使用 let book2 = Book(title: "ONE PIECE", charArray: charArray1) let book3 = Book(title: "鬼滅の刃", charArray: charArray2) print(book1.characters) print(book2.characters) print(book3.characters) 実行結果 ["日向ヒナタ"] ["ルフィ", "ゾロ", "サンジ", "ウソップ"] ["竈門炭治郎", "竈門禰豆子", "冨岡義勇", "煉?獄杏寿郎"]以上がエクステンションの説明のなります!
すぐに思いつく簡単なエクステンションの例はこれでしたので、
あまりいい機能を実装できていませんが許してください。後、ヒナタじゃなくてサクラファンの方も許してください・・・。
他にも、型の構成要素の記事がございますのでぜひご覧ください。
・【Swift】型の構成要素〜型の基本〜
・【Swift】型の構成要素〜プロパティ前編〜
・【Swift】型の構成要素〜イニシャライザ〜
・【Swift】型の構成要素〜メソッド〜
・【Swift】型の構成要素〜サブスクリプト〜
・【Swift】型の構成要素〜型のネスト〜最後までご覧いただきありがとうございました。
- 投稿日:2020-12-15T00:00:26+09:00
SwiftでViewの状態をenumで管理する
この記事はクラスター Advent Calendar 2020 15日目の記事です。
昨日は noir_neoさんの「ARKit で Face Tracking して左右を正しくアバターを動かす」でした。Face Trackingでアバターを表示させたくなったらとても参考になりそうです...!こんにちは、クラスター社の橋本です。今年の前半まではUnityのC#を書いてましたが、最近では専らSwiftを書いています。
clusterのモバイルアプリでは最近UIをネイティブ化したんですが、それを開発しているときにSwiftのenum便利だな〜と感じたことをViewの状態管理を題材に書いていきます。はじめに
clusterのモバイルアプリのネイティブ部分では、MVVMアーキテクチャを採用していて、RxSwift を使ったデータバインディングを行っています。
なので、View(Swiftで言うところのViewController)の状態はViewModelが持っています。当初のコード
当初はこんな感じのViewModelを書いて、ViewでBindしていました。(※冗長になるのでprivateな定義等は省略しています)
ViewModel.swiftenum HogeViewStatus { case processing case empty case idle } final class HogeViewModel { let updateViewStatus: Observable<HogeViewStatus> var hoges: [Hoge] { return hogesRelay.value } init(hogeRepository: HogeRepository) { self.hogeRepository = hogeRepository updateViewStatus = hogeViewStatusRelay.asObservable() refreshRelay .flatMapLatest { _ in hogeRepository.get() } .subscribe(onNext: { [weak self] hoges in self?.hogesRelay.accept(hoges) let status: HogeViewStatus = hoges.isEmpty ? .empty : .idle self?.hogeViewStatusRelay.accept(status) }) .disposed(by: disposeBag) } func refresh() { refreshRelay.accept(()) } }このコードで気になっていたことは、
HogeViewStatusのidle状態(リストが表示されているべき状態)のときにリストで表示されるデータと紐付いていないことでした。例えばですが、コードを改修していく中でHogeViewStatusが.emptyのときにhogesがisEmptyじゃないみたいなコードを書きうるので、画面の仕様によってはViewで予期しないものを表示してしまうみたいなことが予想されます。Associated Valuesを使って解決する
Swiftには上記のような問題を解決してくれるAssociated Valuesという仕組みが用意されています。(これがめっちゃ便利!!)
雑な説明ですが、Associated Valuesはenumのcase毎に自由な型を付与することができるというものです。今回はidleに[Hoge]を付与できるようにしています。enum HogeViewStatus { case processing case empty case idle([Hoge]) }これを使って、先程のViewModelを書き直してみます。
ViewModel.swiftenum HogeViewStatus { case processing case empty case idle([Hoge]) } final class HogeViewModel { let updateViewStatus: Observable<HogeViewStatus> // HogeViewStatusに付与されるようになるので不要になる // var hoges: [Hoge] { // return hogesRelay.value // } init(hogeRepository: HogeRepository) { self.hogeRepository = hogeRepository updateViewStatus = hogeViewStatusRelay.asObservable() refreshRelay .flatMapLatest { _ in hogeRepository.get() } .subscribe(onNext: { [weak self] hoges in // hogesはidleに付与するように変更 // self?.hogesRelay.accept(hoges) let status: HogeViewStatus = hoges.isEmpty ? .empty : .idle(hoges) // hogesをassocate self?.hogeViewStatusRelay.accept(status) }) .disposed(by: disposeBag) } func refresh() { refreshRelay.accept(()) } }これで別々にacceptしなくて良くなったので、状態と配列の実態が異なることもなくなるようになりました。
ただこれだけだとView側で扱いづらいので、HogeViewStatusから[Hoge]を取り出すextensionを書いてあげます。HogeViewModel.swiftextension HogeViewModel { var hoges: [Hoge] { // (このパターンマッチの書き方も便利ですよね) if case .idle(let hoges) = hogeViewStatusRelay.value { return hoges } return [] } }以上で状態と配列をView側で安全に扱えるようになりました。また、Viewの状態が増えたり、
[Hoge]以外のデータを表示したくなっても複雑なコードを書かなくて済みそうですね。今回紹介したSwiftのenumの使い方はほんの一例ですが、便利さが伝わっていたら幸いです!明日は YOSHIOKA_Ko57さんの「UnityでプラットフォームごとにUIの判定エリアを変える」 です。楽しみですね...!
参考リンク
































