20201215のSwiftに関する記事は21件です。

CoreGraphicsのあれこれ

UIImage+CoreGraphics.swift
import 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)
    }
  }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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 がぼんやりしてる
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[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練習帳

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

Xcode12.3 への XVim2 導入メモ

Xcode へ Vim ライクな機能を入れるプラグイン XVim2 をインストールする方法を毎回探しに行くのが面倒だったので、備忘録としてここにまとめようと思います。

実行環境

MacOS 11.0.1(Intel)
Xcode12.3

参考

ここに記載する内容は本家 GitHubリポジトリ を参考にしています。
https://github.com/XVimProject/XVim2

1.証明書の作成

キーチェーンアクセス.app を開き、メニューから 「キーチェーンアクセス」 > 「証明書アシスタント」 > 「証明書を作成...」 を選択。

スクリーンショット 2020-12-15 15.58.54.png

名前と証明書のタイプを変更し、次へ次へと進んで作成を完了させます。

スクリーンショット_2020-12-15_16_01_44.png

2.Xcodeを自己署名

続いて次のコマンドをターミナルで実行します。

sudo codesign -f -s XcodeSigner /Applications/Xcode.app

AppStoreからインストールした場合は上記ディレクトリにあるはず。
※ Xcodeがある場所が異なる場合は適宜パスを変更してください。
※ 完了まで結構時間がかかります。。

3.XVim2 を clone し、 make を実行

git clone git@github.com:XVimProject/XVim2.git

↑の 2.Xcodeを自己署名 が完了していることを確認し、clone したディレクトリに移動して make を実行です。

cd XVim2
make

4.Xcode起動 -> Load Bundle を選択

Xcode を起動するとダイアログが出てくるので「Load Bundle」を選択します。
(「Skip Bundle」がアクティブになっているので注意!)

スクリーンショット_2020-12-15_12_22_58.png

以上でカーソルがブロックになって Vim と同じような操作が可能になります。
自分のプロジェクトを開いて確認してみましょう!

(番外編) ちなみに、Xcodeアップデート後に必要なことはある?

Xcodeのアップデートをインストールした場合は、再び Xcode の自己署名、XVim2 ディレクトリでの make 操作が必要です。

この記事を書くタイミングで AppStore でインストールした Xcode12.2 を 12.3 へアップデートをしましたが、アップデート直後に起動すると機能が解除されていました。XVim2 ディレクトリへ移動し、 make を実行しても機能せず。。

しかし、2.Xcodeを自己署名 からやり直したところ、今まで通り動作することが確認できました。

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

iOS14から使用できるUIMenuの実装について

はじめに

iOS Advent Calendar 2020 の18日目になります!

今回は、WWDC20にて紹介されていましたUIMenuの新機能について焦点を当ててみようと思います。

UIMenu自体はiOS13から使用できるのですが、
iOS14からはUIButtonUIBarButtonItemにも使用可能となった内容がありましたので、おさらい的な紹介ができればと思います?‍♂️
とはいっても、すでにとても分かりやすくまとめられている記事がありましたので、細かい説明は割愛させていただきます。
本記事では、実際にコードで書いてみて使い心地を感じられればと思います。

UIMenu
https://developer.apple.com/documentation/uikit/uimenu
WWDC20
https://developer.apple.com/videos/play/wwdc2020/10205

1. 新機能について

すでに分かりやすくまとめられている記事が多々ありますのでリンクを貼らせていただきました?‍♂️
(これ以上上手にまとめられる気がしませんでした。。)
https://medium.com/better-programming/whats-new-in-ios-14s-uimenu-and-contextmenu-433cd2037c37

2. UIMenuのメリット

スクリーンショット 2020-12-16 2.09.31.png

UIMenuと比較する機能としてUIAlertController(ActionSheet)が挙げられると思います。
WWDC20でもこちらを比較して紹介されていましたので以下にまとめてみました。

UIMenuのメリットとして以下が挙げられていました。

  • iPad表示するためにポップオーバー表示するための実装をする必要がない
  • 表示の際に背景を暗くする処理がなくなったため、その分軽量感のある遷移になる
  • 閉じるための「キャンセル」ボタンが不要(Menu外をタップすると閉じる)
  • タップした箇所からMenuが表示されるので操作しやすい(操作性) etc...

3. 実際に書いてみました

「百聞は一見にしかず」と言うことで、実際にコードを書いてみました。

  • [3-1] HIGH, MID, LOWを切り替える
  • [3-1] UIMenuを開いた際には、選択した項目にチェックマークがついている
  • [3-2] UIMenuを非同期で構築する場合

と言う内容を実装してみたいと思います。

3-1. 完成画面

uimenu.gif

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を設定

UIMenuUIButtonに設定するために、以下のメソッドを作成しました。

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を使用することで実装することができます。

uimenu_load.gif

var actions = [UIMenuElement]()

let deferredMenuElement = UIDeferredMenuElement({ completion in
    // 時間がかかる処理
   ....
    completion(actions)
})
menuButton.menu = UIMenu(title: "UIDeferredMenuElement", options: .displayInline, children: [deferredMenuElement])
menuButton.showsMenuAsPrimaryAction = true 

UIDeferredMenuElement
https://developer.apple.com/documentation/uikit/uideferredmenuelement

4. まとめ

これらの実装は、iOS14から対応なので実際にはiOS13以下の場合の分岐処理を書かなければならず、面倒ではあります。
ただ、操作感が良くや使い所が多岐に渡りそうな気がするのでとても魅力的な機能でした。
個人的にはUIAlertController(ActionSheet)実装時にiPad用に処理を書き忘れてしまうと、クラッシュする原因にもなるのでこの辺り考えなくて良くなるのは嬉しい部分ではありました。

実装してみましたが、説明やコードで誤りなどございましたらご指摘いただけると幸いです。
ご協力のほどよろしくお願いいたします?‍♂️

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

[AutoLayout] Intrinsic Content Sizeを活用しよう〜その2 Self Sizing編〜

はじめに

こんにちは!Life is Tech ! #2 Advent Calendar 2020の22日目を担当しますふみっちです。

この記事は〜その1 概要編〜の続きとなっていて、Intrinsic Content Sizeに関する具体的な実装を解説を行います!
プロジェクト全体はこちらからダウンロードできるので是非手元で動かしてみてください?


Intrinsic Content Sizeを用いると動的なコンテンツの変化によるサイズ調整を自動で実装できるようになるという話を〜その1 概要編〜に書かせてもらいましたが、おそらく動的なサイズ調整が必要になるケースとしてはUITableViewUICollectionViewを使用するが多いと思います。

そこで今回は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.swift
protocol TableViewCellDelegate: AnyObject {
    func didTapChangeVisibleButton(cell: TableViewCell)
}

このプロトコルにはdidTapChangeVisibleButtonというメソッドを定義していますが、引数にセル自身を渡してあげられるようなメソッドであれば他の名前でも大丈夫です!

ボタンタップ時にデリゲートメソッドを呼ぶ

次に上のTableViewCellDelegateを使用してセルでボタンがタップされたことを受け取ったら、ViewController(デリゲート)側に処理を伝えます。

TableViewCell.swift
import 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.swift
import 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) -> Int
  • tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell

tableView(_ 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変数のセルに該当する要素を取得しています。

もしセルは画像を表示するべき(isVisibletrue)であればセルに画像を表示します。

一方セルは画像を表示するべきではない(isVisiblefalse)であればセルも画像はnilになります。

UIImageViewのIntrinsic Content Sizeimageプロパティの値によって変わるため、imageをnilにすると自動的にUIImageViewの高さも0になり、画像が代入されるとその画像に適する高さにリサイズされます。

このようにして自分で高さの調整をせずとも表示するデータに応じてセルが自動的にサイズを調整してくれるわけです!

完成イメージ

最後に

この記事では〜その1 概要編〜で説明したIntrinsic Content Sizeを交えた実装例を紹介しました。
Intrinsic Content Sizeはマイナーな内容かもしれないですが、実は普段から意識しなくても使っているということもあると思うのでこの機会に興味を持ってもらえると嬉しいです!

最後まで読んでいただきありがとうございました‼️

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

[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"]]
--------------------
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ITMS-90626エラーの解決方法

ITMS-90626エラーの解決方法

■問題

バイナリをAppStoreConnectにアップロードした後、ITMS-90626エラーが発生した。
※アップロード自体は成功したが、メールにて通知がきた。
(環境:XCode 12.2 / macOS Catalina)

スクリーンショット 2020-12-15 16.46.33.png

◇実際のエラー内容
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ファイル)のローカライズがされていないための警告だった。

スクリーンショット 2020-12-15 16.38.31.png

■対策

今回は、このSiri Intent Definitionを特に意識的に使用していないのだが、Widget側のコードにもConfigurationIntentが登場しており、単純にターゲットから外す訳にはいかないので、Siri Intent Definitionのローカライズを追加することで対応した。

スクリーンショット 2020-12-15 17.16.40 2.png

しかし、よくよく考えてみると、既にウィジェット機能を追加したアプリは公開済で、前回まではこのエラーは発生していなかった。
今回のアップデートで追加した機能が関係するとすると、
アプリ側の永続化データをウィジェット側で取得したくなり、
UserDefaultsの連携ができるように、App Groupsの追加をしたので、
そのことが関係するのかもしれない。

※ローカライズの話なので、日本語化、多言語対応とかした場合に直接顕在化する問題だと思うが、その対応は前回以前にしていたので、このタイミングで出たエラー理由ではなさそう。
ま、あとはApple側のチェック機構がこのタイミングでなされたかですかね。

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

iOSアプリで環境ごとに設定を変えるベストプラクティス(Swift)

はじめに

本記事は Swift/Kotlin愛好会 Advent Calendar 2020 の5日目の記事です。
空いていたので参加しました。

iOSアプリ開発において、環境ごとに変数の値を切り替えるベストプラクティスを紹介します。

背景

私が開発しているアプリで、APIの接続先が3つ(開発用・ステージング用・リリース用)必要になりました。
Build Configurations(以下「ビルド構成」と呼ぶ)」に Staging を追加し、 #if で分岐する方法をよく見かけます。

EnvironmentVariables.swift
enum 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のビルドシステムが DebugRelease の2種類を前提としており、ビルド構成を追加するとライブラリ周りで問題が発生するようです。


XcodeGenを使っている場合、環境ごとに設定を変えてプロジェクトを生成するのがいいとのことなので、その方法を紹介します。

最も伝えたいこと

本記事で最も伝えたいことは、 ビルド構成をいじらず、それ以外の方法で環境ごとに設定を変えよう です。
「それ以外の方法」として、XcodeGenを使うと比較的かんたんに実現できる、ということです。

といいつつも、実はビルド構成をいじることによる具体的な問題を把握していないので、知っていたら教えていただけると嬉しいです :bow:

ただ、私の認識は以下のツイートの通りなので、問題の有無にかかわらずビルド構成とは別の方法で設定を変えるのに賛成です。

前提条件

  • XcodeGenを使っている
    使っていない場合、おまけが参考になるかもしれない

環境

  • OS:macOS Catalina 10.15.7
  • Xcode:12.2 (12B45b)
  • Swift:5.3.1
  • XcodeGen:2.18.0

実装

環境ごとに設定を変えられるよう実装します。

Makefileの作成(任意)

プロジェクト内で使いたい値を環境変数としてエクスポートし、XcodeGenでプロジェクトを生成するコマンドを準備します。
エクスポートする環境変数を環境ごとに変えるため、 make などを使ってタスク化するのがオススメです。

私は以下のような Makefile を作成しています。

Makefile
DEBUG_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.yml
targets:
  {製品ターゲット名}:
    # {中略}
    settings:
      base:
+       ENVIRONMENT: ${ENVIRONMENT}

XcodeGenでは、環境変数を ${環境変数名} で取得できます。
ここでは ENVIRONMENT という名前でUser-Definedの設定を作成し、先ほどエクスポートした環境変数を注入しています。

ここまで実装して make generate-xcodeproj-release を実行すると、以下のUser-Definedが作成されます。
スクリーンショット_2020-12-15_17_09_31.jpg

見てわかる通り、ビルド構成にかかわらずすべて RELEASE の値が入っています。
つまり 環境とビルド構成は互いに独立している ということであり、「リリース環境でデバッグビルド」や「ステージング環境でリリースビルド」などができるようになります。

Info.plistにUser-Definedの設定を追加

Info.plistproject.yml で定義したUser-Definedの設定を追加します。
スクリーンショット 2020-12-15 17.18.29.png

Key Type Value
任意 String $(User-Definedの設定名)

Keyは任意ですが、わかりやすいようにUser-Definedの設定名に近い名前がいいと思います。

EnvironmentVariables.swiftの追加

Info.plist に追加したことで、 Bundle.main.object(forInfoDictionaryKey: "キー") を呼び出してSwiftファイルから設定を取得できるようになりました。

私は環境変数を一元管理したいため、1ファイルにまとめています。

EnvironmentVariables.swift
import 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 をケースなしの列挙型にしているのは、単純に名前空間が欲しいためです。

使い方

使い方は以下の通りです。

  1. make generate-xcodeproj-○○ で環境変数のエクスポートとプロジェクトを生成する
  2. 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/{ユーザー名}/{中略}/{プロジェクト名}.xcodeproj
AppDelefate.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.swiftenvironmentprivate にしないことで、環境ごとに処理を分岐することができます。

発展: プロトコルを噛ませてモック化できるようにする

上記の実装だと単体テスト時に値を差し替えづらいので、プロトコルを噛ませてモック化できるようにします。
列挙型だとケースがないとインスタンス化できないので、構造体に変更しています。

私は Mockolo というモック生成ライブラリを使っているため、プロトコルに /// @mockable コメントを付けています。

EnvironmentVariables.swift
import 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.swift
final class ApiClient {
    private let apiBaseUri: String

    init(environmentVariables: EnvironmentVariablesProtocol) {
        self.apiBaseUri = environmentVariables.apiBaseUri
    }
}

これだと EnvironmentVariablesProtocol の全プロパティやメソッドがイニシャライザ内で呼び出せるため、 apiBaseUri のみDIするのもありだと思います。

environment のみ使いたい場合、 Environment をDIすると余計なプロパティやメソッド(今回だと apiBaseUri プロパティ)を呼び出せなくなってわかりやすいです。

Foo.swift
final 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

おわりに

これで環境ごとに設定を変えてほしい要求が来ても安心です!
他にいい方法があれば、コメントなどで教えていただけると嬉しいです :relaxed:

以上、 Swift/Kotlin愛好会 Advent Calendar 2020 の5日目の記事でした。
翌日も @uhooi の記事です。

参考リンク

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

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接続してるものが含まれる文章に書いてあるんだったら、
関連付けできてるか確認しましょう。

スクリーンショット 2020-06-13 10.47.39.png
お、こんなとこに白丸が!!

スクリーンショット 2020-12-13 15.01.58.png

XCodeは便利なもので、
関連付けしてあったら、◉
してなかったら、○
が行のところに表示してくれます。
(たまに関連付けしてるのに、○表示になってることもあるけど、、)

ってことで関連付けして終了です。

ちなみに@IBOutletをつけるとき("@IBOutletオプションの時"って言う)、"!"をつけるの!!!って人はこちら参照!
IBOutletはなぜ" ! "で定義すべきか

(4割方の原因)変数がnilの状態のままその変数をつかって処理をしようとしてクラッシュしてる

直訳通りです。

ます頭に入れといて欲しいのは

変数は箱だってこと。

箱の中身には値が入ります。


属性

この画像で言うなら、"a"って言う箱に何かしらの値が入ってるって意味っす。

変数についての復習はこちら。

この箱の中には値が入ってなくて(つまりnilの状態)、その状態のまま処理しようとしてるからアウトってパターンです!

どう直せばいいのか

空になってる変数に値を入れればいいのです。
いかに注意して、変数に値を入れてください。
①関数内のプログラミングコードはは上から下に読まれる
②大抵の場合、viewDidLoad関数は一番最初に読まれる。

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

XcodeのStoryboardで指定するUIViewのBGColorがカラーピッカーから取得したカラーコードに色が合わなくて困った

AdobeXDで作られたスマホアプリのデザインを実装していたのですが、XDのスポイトで取得したカラーコードを実装してもいまいち色が合わなかったので調べました。
※ 色は同じディスプレイでみてます。

XDの色

スクリーンショット 2020-12-15 15.41.07.png

ピックすると#83C449のカラーで取得。

StoryboadのUIViewのBackgroundに指定

スクリーンショット 2020-12-15 15.41.37.png

XDで取得したカラーコードをUIViewのBGカラーとして設定したのですが、いまいちくすんで色が合わない。
シミュレータで実行後のUIViewの色がこちら。

スクリーンショット 2020-12-15 15.41.52.png

カラープロファイルを指定する

調査したところディスプレイやデジタルカメラ、スキャナなどは、それぞれ様々な規格でRGBの色を表示しており、デバイスによっては表現できる色の幅が変わるとのこと。
UIViewのデフォルトはGeneric RGBが指定されており、これをsRGBで指定すれば大差なく表示できるとあったので、設定を変えてみました。

スクリーンショット 2020-12-15 15.42.06.png

変更してシミュレータで実行した結果がこちら。

スクリーンショット 2020-12-15 15.53.37.png

あんまり変わってない。。。

結論

スクリーンショット 2020-12-15 15.43.09.png

解決方法がわからなかったので、Googleでカラーコードを入力し、そこから目視で"#86D442"を設定しました。
シミュレータでの"#86D442"の結果がこちら。

スクリーンショット 2020-12-15 15.43.00.png

ということで、結局微調整は必要なのかもです。。。
どなたか詳しい方いればコメントいただけるとうれしいです。

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

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導入後

参考リンク

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

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導入後

参考リンク

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

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 が何かという厳密な説明は省略しますが、(正確ではない部分はありますが)イメージは ↓ の図のように

tca.drawio.png

  • 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 は ↓ になります。

スクリーンショット 2020-12-15 12.43.59.png

非常にシンプルな API で、今回は以下のように利用しています。

  • typetrivia で固定
  • 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 と通信するための APIClient 

TCA の要素それぞれについて説明する前に、まず Numbers API と通信するための APIClient の実装について説明します。

APIClient

APIClient Interface

NumbersAPIClient.swift
struct 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.swift
extension 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 を利用できるようになります。

trivianumber という Int 型の変数を受け取って、その変数を利用して URLSession で通信を行っています。
dataTaskPublisher の内部では主に以下のようなことが行われています。

  • "http://numbersapi.com/\(number)/trivia" でリクエスト
  • map 内で、受け取った data を元に String として decode
  • catch によって、エラーがあれば単純な "\(number) is a good number Brent" という文字列を返すようにしている
  • mapError によって、エラーがあれば、 TriviaApiError() を返すようにしている
  • eraseToEffect は Publisher の OutputFailure を 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.swift
struct 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.swift
enum 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>) は少し特殊です。
具体的には triviaButtonTapped Action が発火することによって 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()) という形で一意に識別したリクエストをキャンセルできる

以上を踏まえて、.cancelButtonTapped Action では以下のような処理が行われています。

  • State の isTriviaRequestInFlightfalse にして、リクエスト中ではないという状態を保持する
  • .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.swift
public 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 の currentTrivianil にし、画面に表示する用のトリビアテキストが表示されないようにする
  • State の isTriviaRequestInFlightfalse にして、リクエスト中ではないという状態を保持する
  • .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 の currentTrivianil にして、一旦 View に表示されるトリビアを非表示にする
  • State の isTriviaRequestInFlighttrue にして、リクエスト中であるという状態を保持する

[Effect に関わる処理]

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 の結果によって triviaResponse Action を発火させる
  • cancellable(id: TriviaRequestId()) という形で一意にリクエストを識別できるように保持しておきつつ、Effect を返却している

複雑には見えますが、一つ一つの operator を追っていけば何とか処理の流れは理解できるかなと思います。
重要なのは catchToEffect によって <Result<String, TriviaApiError>, Never> という型で扱えるようにしている部分だと思います。
この Result で EffectsCancellationAction.triviaResponse を発火させることによって、Result が success の場合であれば、.triviaResponse(.success(response)) が発火し、Result が failure であれば、.triviaResponse(.failure) が発火するという流れになっています。

それぞれの triviaResponse Action が発火した後の動作は簡単ですが、一応説明します。

3-2 case let .triviaResponse(.success(response))
  • リクエストが終了した状態なので、State の isTriviaRequestInFlightfalse にする
  • State の currentTrivia に返却された response を代入する
  • none Effect(何も Effect を返却する必要がない場合はこれを使う) を返却する
3-3 .triviaResponse(.failure)
  • リクエストが終了した状態なので、State の isTriviaRequestInFlightfalse にする
  • none Effect を返却する

View

最後に View について説明します。ここまで理解できていればそれほど難しくないと思います。
まず全体像を示します。

EffectsCancellationView.swift
struct 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.swift
let store: Store<EffectsCancellationState, EffectsCancellationAction>

まず、View 内で Store を定義します。
Store ではこれまでに作成した EffectsCancellationStateEffectsCancellationAction を利用するようにします。

View のイニシャライズ時にこの store に値を設定するため、View のイニシャライズは以下のように行います。

TCASampleCancellationApp.swift
EffectsCancellationView(
  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.swift
var body: some View {
  WithViewStore(self.store) { viewStore in
    // ...
  }
}

先ほど Store を定義しましたが、View 内部では Store を直接扱うのではなく、ViewStore というものを用いて Action の送信や State へのアクセスを行います
↑のように WithViewStore に定義していた Store を入れてあげると ViewStore が View から扱えるようになります。
(ちなみに Store を与えるだけで利用できるようになっているのは、State を Equatable に適合させているからになります。)

③ トリビアボタン(リクエスト・リクエストキャンセル)の表示

次はトリビアボタンの表示に関わる部分について説明します。
画面では以下がそれぞれ対応します。

リクエストボタン リクエストキャンセルボタン

コードは以下になります。

EffectsCancellationView.swift
if viewStore.isTriviaRequestInFlight {
  HStack {
    Button("Cancel") { viewStore.send(.cancelButtonTapped) }
    Spacer()
    ActivityIndicator()
  }
} else {
  Button("Number fact") { viewStore.send(.triviaButtonTapped) }
    .disabled(viewStore.isTriviaRequestInFlight)
}

難しいことはしていませんが、以下のような処理を行っています。

  • isTriviaRequestInFlighttrue なら
    • 通信中ということなので「Cancel ボタン」を表示する
  • isTriviaRequestInFlightfalse なら
    • 通信中ではないということなので「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.swift
Button("Cancel") { viewStore.send(.cancelButtonTapped) }

Button("Number fact") { viewStore.send(.triviaButtonTapped) }

このように send に Action 名を指定すれば、View 内では状態を気にせず実装を行うことができます。

④ Stepper の表示

Stepper で数字を切り替える部分に関するコードは以下になります。

EffectsCancellationView.swift
Stepper(
  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 を利用して、currentTrivia State に入っている値を Text として表示しているだけになります。

EffectsCancellationView.swift
viewStore.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.swift
final 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.swift
final 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 を EffectsCancellationStateEffectsCancellationAction を利用する形で定義しています。
イニシャライズ時に 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.swift
override 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.swift
struct 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.swift
NavigationLink(
    "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 を頭に入れながら探っていきたいと個人的には思っています。

改めて長い文章となってしまいましたが、読んでいただきありがとうございました!(間違っているところがあればぜひ教えていただけますと幸いです? )

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

Mintをプロジェクトディレクトリにインストールする

はじめに

Swift製コマンドラインツールのMintですが、複数のプロジェクトで使用されているビルドマシンにインストールせずにプロジェクトディレクトリ内にインストールし、プロジェクトごとに管理する様にしてみました。

Mintとは

mint_logo.png
https://github.com/yonaskolb/Mint

Swift製コマンドラインツールのインストールと実行を行うパッケージマネージャーです。
以下の様なパッケージを管理できます。

  • 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 files

2. パスを指定してインストール

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 files

3. シンボリックリンクを変更

上のログを確認してもらうと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/mint

4. 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 xcodegen

References

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

[SwiftUI] 初回画面のチュートリアルを実装する

自作で作成する時を考える

完成例

初回起動時を探知する

Enum&Structs.swift
struct 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)

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

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
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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】型の構成要素〜エクステンション〜

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

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

[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をタップすると遷移するようにしてみるとシンプルでユーザーも直感的に理解しやすいかなと思います。

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

【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】型の構成要素〜型のネスト〜

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

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

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.swift
enum 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(())
    }
}

このコードで気になっていたことは、HogeViewStatusidle状態(リストが表示されているべき状態)のときにリストで表示されるデータと紐付いていないことでした。例えばですが、コードを改修していく中でHogeViewStatus.emptyのときにhogesisEmptyじゃないみたいなコードを書きうるので、画面の仕様によってはViewで予期しないものを表示してしまうみたいなことが予想されます。

Associated Valuesを使って解決する

Swiftには上記のような問題を解決してくれるAssociated Valuesという仕組みが用意されています。(これがめっちゃ便利!!)
雑な説明ですが、Associated Valuesはenumのcase毎に自由な型を付与することができるというものです。今回はidle[Hoge]を付与できるようにしています。

enum HogeViewStatus {
    case processing
    case empty
    case idle([Hoge])
}

これを使って、先程のViewModelを書き直してみます。

ViewModel.swift
enum 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.swift
extension HogeViewModel {
    var hoges: [Hoge] {
        // (このパターンマッチの書き方も便利ですよね)
        if case .idle(let hoges) = hogeViewStatusRelay.value {
            return hoges
        }
        return []
    }
}

以上で状態と配列をView側で安全に扱えるようになりました。また、Viewの状態が増えたり、[Hoge]以外のデータを表示したくなっても複雑なコードを書かなくて済みそうですね。今回紹介したSwiftのenumの使い方はほんの一例ですが、便利さが伝わっていたら幸いです!

明日は YOSHIOKA_Ko57さんの「UnityでプラットフォームごとにUIの判定エリアを変える」 です。楽しみですね...!

参考リンク

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