Money Forward Developers Blog

株式会社マネーフォワード公式開発者向けブログです。技術や開発手法、イベント登壇などを発信します。サービスに関するご質問は、各サービス窓口までご連絡ください。

20230215130734

それ、Swift Bondでくっつけちゃおう

こんにちは マネーフォワードでiOSアプリ開発をしています西信です

アプリはサーバーとの通信処理やユーザー操作により目まぐるしく状態が変わります そして、その状態の変化に追従してUIが変わります

複雑な状態変化が起こりうるアプリ開発で状態ごとにUIを更新するのは苦労しますよね

  • 状態ごとにUIが変化するコードを書いていたつもりだけど、この状態の時のUI更新ができていなかったとか・・
  • 状態ごとにUIが変化するコードを書いたんだけど、全パターンを網羅するためにコードが散らばっていて挙動を把握しづらいとか・・

こんな悩みを抱えたことはないでしょうか

そこで本日紹介するSwift Bondです みなさんはこのライブラリをご存知でしょうか

Swift製のBindingライブラリで、現在2,200以上のスターがついています 私は以前、下記を読んでSwift Bondを知りました(有名な記事です) MVVMをベースに複雑な振る舞いをしっかり把握できるアプリ開発

Swift Bondを使って複雑な状態変化とUIをくっつけることで先ほどの悩みを解決してくれます

下記のようなメッセージ送信画面の実装を見ながら、Swift Bondを使うと何が良いのか説明します  

要件

未入力 送信可能 文字数オーバー 通信中
ScreenShot1 ScreenShot2 ScreenShot3 ScreenShot4
  • 宛先、件名、メッセージが全て入力されており、メッセージが30文字以下かつ通信中でなければ送信ボタンをタップできる
  • メッセージ欄に入力するごとに、あと何文字入力できるか文字数が表示されるカウントラベルの値が変化し、上限である30を越えると文字数の色が赤くなる
  • メッセージ送信中はインディケーターを出し、送信結果(成功or失敗)に応じてアラートを表示する  

実装

Unused Swift Bond

まずはSwift Bondを使わない場合の実装です 分かりやすいように、とりあえずレイヤーは意識せずViewControllerに全て実装するものとします

import UIKit

class ViewController: UIViewController {

    enum RequestState {
        case None
        case Requesting
        case Success
        case Error
        
        func isRequesting() -> Bool {
            return self == .Requesting
        }
    }
    
    @IBOutlet weak var destinationTextField: UITextField!
    @IBOutlet weak var subjectTextField: UITextField!
    @IBOutlet weak var messageTextView: UITextView!
    @IBOutlet weak var characterLimitLabel: UILabel!
    @IBOutlet weak var sendButton: UIButton!
    @IBOutlet weak var requestIndicator: UIActivityIndicatorView!
    
    let CharacterLimit = 30
    var requestState: RequestState = .None {
        didSet {
            updateRequestIndicator()
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
    }


}

//MARK: Unused Bond
extension ViewController {
    
    func setupUI() {
        destinationTextField.addTarget(self, action: Selector("destinationTextChange:"), forControlEvents: .EditingChanged)
        subjectTextField.addTarget(self, action: Selector("subjectTextChange:"), forControlEvents: .EditingChanged)
        messageTextView.delegate = self
        disableSendButton()
        sendButton.addTarget(self, action: Selector("sendMessage"), forControlEvents: .TouchUpInside)
        endRequestIndicator()
    }
    
    func disableSendButton() {
        sendButton.enabled = false
        sendButton.alpha = 0.5
    }
    
    func enableSendButton() {
        sendButton.enabled = true
        sendButton.alpha = 1.0
    }
    
    func startRequestIndicator() {
        requestIndicator.hidden = false
        requestIndicator.startAnimating()
    }
    
    func endRequestIndicator() {
        requestIndicator.hidden = true
        requestIndicator.stopAnimating()
    }
    
    func updateSendButtonState() {
        
        if requestState == .Requesting {
            disableSendButton()
            return
        }
        
        let messageCount = messageTextView.text.characters.count
        guard let destinationCount = destinationTextField.text?.characters.count, subjectCount = subjectTextField.text?.characters.count
            where destinationCount > 0 && subjectCount > 0 && (1...CharacterLimit) ~= messageCount else {
                disableSendButton()
                return
        }
        
        enableSendButton()
        
    }
    
    func updateCharacterLimitLabel() {
        
        let messageCount = messageTextView.text.characters.count
        let diffCount = CharacterLimit - messageCount
        characterLimitLabel.text = diffCount.description
        if diffCount >= 0 {
            characterLimitLabel.textColor = UIColor.blackColor()
        }
        else {
            characterLimitLabel.textColor = UIColor.redColor()
        }
        
    }
    
    func updateRequestIndicator() {
        requestState.isRequesting() ? startRequestIndicator() : endRequestIndicator()
    }
    
    func destinationTextChange(destinationText: UITextField) {
        updateSendButtonState()
    }
    
    func subjectTextChange(subjectText: UITextField) {
        updateSendButtonState()
    }
    
    func sendMessage() {
        
        requestState = .Requesting
        let delay = 1.5 * Double(NSEC_PER_SEC)
        let time  = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
        dispatch_after(time, dispatch_get_main_queue()) { [unowned self] in
            //通信処理終了
            if arc4random_uniform(2) == 0 {
                self.requestState = .Success
                self.finishSendMessage("送信成功しました")
                return
            }
            self.requestState = .Error
            self.finishSendMessage("送信失敗しました")
        }
    }
    
    func finishSendMessage(resultMessage: String) {
        
        let alert = UIAlertController(title: "", message: resultMessage, preferredStyle: .Alert)
        let action = UIAlertAction(title: "OK", style: .Default, handler: nil)
        alert.addAction(action)
        presentViewController(alert, animated: true, completion: nil)
        
    }
    
}

extension ViewController: UITextViewDelegate {
    
    func textViewDidChange(textView: UITextView) {
        updateSendButtonState()
        updateCharacterLimitLabel()
    }
    
}

テキストが変化するごとに呼び出されるデリゲートメソッドでUIの更新メソッドを毎度呼び出す必要があります (UITextFieldのfunc textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Boolデリゲートの挙動が怪しいので本記事ではaddTarget:action:forControlEvents:でテキストが変化するごとに処理をするメソッドを定義しています)

Use Swift Bond

次にSwift Bondを使って実装するとどうなるでしょうか

import UIKit
import Bond

class ViewController: UIViewController {

    enum RequestState {
        case None
        case Requesting
        case Success
        case Error
        
        func isRequesting() -> Bool {
            return self == .Requesting
        }
    }
    
    @IBOutlet weak var destinationTextField: UITextField!
    @IBOutlet weak var subjectTextField: UITextField!
    @IBOutlet weak var messageTextView: UITextView!
    @IBOutlet weak var characterLimitLabel: UILabel!
    @IBOutlet weak var sendButton: UIButton!
    @IBOutlet weak var requestIndicator: UIActivityIndicatorView!
    
    let requestState = Observable<RequestState>(.None)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        bindUI()
    }


}


//MARK: Use Bond
extension ViewController {
    
    func bindUI() {
        
        let CharacterLimit = 30
        
        //sendButtonのステータス
        combineLatest(destinationTextField.bnd_text, subjectTextField.bnd_text, messageTextView.bnd_text, requestState).map { (destination, subject, message, reqState) -> (isEnabled: Bool, alpha: CGFloat) in
            
            let disableState: (Bool, CGFloat) = (false, 0.5)
            if reqState == .Requesting {
                return disableState
            }
            
            guard let destinationCount = destination?.characters.count, subjectCount = subject?.characters.count, messageCount = message?.characters.count
                where destinationCount > 0 && subjectCount > 0 && (1...CharacterLimit) ~= messageCount else {
                    
                return disableState
                    
            }
            
            return (true, 1.0)
            
        }.observe { [unowned self] (isEnabled, alpha) -> Void in
            self.sendButton.enabled = isEnabled
            self.sendButton.alpha = alpha
        }
        
        //characterLimitLabelのステータス
        messageTextView.bnd_text.map { message -> (count: String, color: UIColor) in
            
            let messageCount = message?.characters.count ?? 0
            let diffCount = CharacterLimit - messageCount
            if diffCount >= 0 {
                return (diffCount.description, UIColor.blackColor())
            }
            return (diffCount.description, UIColor.redColor())
            
        }.observe { [unowned self] (count, color) -> Void in
            
            self.characterLimitLabel.text = count
            self.characterLimitLabel.textColor = color
            
        }
        
        //requestIndicatorのアニメーション
        requestState.map { reqState -> Bool in
            return reqState == .Requesting
        }.bindTo(requestIndicator.bnd_animating)
        
        //requestIndicatorのhidden状態
        requestState.map { reqState -> Bool in
            return reqState != .Requesting
        }.bindTo(requestIndicator.bnd_hidden)
        
        //requestStateがSuccessの時
        requestState.filter { reqState -> Bool in
            return reqState == .Success
        }.observe { [unowned self] _ -> Void in
            self.finishSendMessage("送信成功しました")
        }
        
        //requestStateがErrorの時
        requestState.filter { reqState -> Bool in
            return reqState == .Error
        }.observe { [unowned self] _ -> Void in
            self.finishSendMessage("送信失敗しました")
        }
        
        //sendButtonがタップされた時
        sendButton.bnd_tap.observe { [unowned self] _ -> Void in
            
            self.requestState.next(.Requesting)
            
            //擬似通信処理
            let delay = 1.5 * Double(NSEC_PER_SEC)
            let time  = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
            dispatch_after(time, dispatch_get_main_queue()) { [unowned self] in
                //通信処理終了
                if arc4random_uniform(2) == 0 {
                    self.requestState.next(.Success)
                    return
                }
                self.requestState.next(.Error)
            }
            
        }
        
    }
    
    func finishSendMessage(resultMessage: String) {
        let alert = UIAlertController(title: "", message: resultMessage, preferredStyle: .Alert)
        let action = UIAlertAction(title: "OK", style: .Default, handler: nil)
        alert.addAction(action)
        presentViewController(alert, animated: true, completion: nil)
    }
    
}

このようになります

プロパティのlet requestState = Observable<RequestState>(.None)では、Swift BondのObservableというクラスのインスタンスを生成しています Observableクラスは、ジェネリクスで指定した型の値を持ち、Binding対象となるクラスです Observableクラスの値の変化を監視して、変化した際の挙動を宣言することができます

そして、bindUIメソッドでBindingを定義しています 処理を1つずつ説明していきます

//sendButtonのステータス
combineLatest(destinationTextField.bnd_text, subjectTextField.bnd_text, messageTextView.bnd_text, requestState).map { (destination, subject, message, reqState) -> (isEnabled: Bool, alpha: CGFloat) in
            
    let disableState: (Bool, CGFloat) = (false, 0.5)
    if reqState == .Requesting {
        return disableState
    }
    
    guard let destinationCount = destination?.characters.count, subjectCount = subject?.characters.count, messageCount = message?.characters.count
        where destinationCount > 0 && subjectCount > 0 && (1...CharacterLimit) ~= messageCount else {
            
        return disableState
            
    }
    
    return (true, 1.0)
    
}.observe { [unowned self] (isEnabled, alpha) -> Void in
    self.sendButton.enabled = isEnabled
    self.sendButton.alpha = alpha
}

この処理では、destinationTextField,subjectTextField,messageTextView,requestStateの値が変化するごとに、宛先と件名が1文字以上入力されており、メッセージが1文字以上30文字以下で、通信中でない場合のみ送信ボタンの状態をenable、alpha値1.0にし、それ以外の場合は送信ボタンの状態をdisable、alpha値0.5にするという挙動を宣言しています これは要件の1つ目を満たすコードです

また、Swift BondはUITextFieldやUITextViewなどUIKitをextensionで拡張してbnd_xxxというObservableクラスのインスタンスを生やして既存のUIKitに対して簡単にBindingできるようにしています 次の処理です

//characterLimitLabelのステータス
messageTextView.bnd_text.map { message -> (count: String, color: UIColor) in
            
    let messageCount = message?.characters.count ?? 0
    let diffCount = CharacterLimit - messageCount
    if diffCount >= 0 {
        return (diffCount.description, UIColor.blackColor())
    }
    return (diffCount.description, UIColor.redColor())
    
}.observe { [unowned self] (count, color) -> Void in
    
    self.characterLimitLabel.text = count
    self.characterLimitLabel.textColor = color
    
}

ここでは、messageTextViewの値が変化するごとに、テキスト入力できる残り文字数の更新と文字数の上限を超えている場合は赤、超えていなければ黒にテキストの色を変える挙動の宣言をしています

//requestIndicatorのアニメーション
requestState.map { reqState -> Bool in
    return reqState == .Requesting
}.bindTo(requestIndicator.bnd_animating)

次にrequestIndicatorのアニメーションとのBindingです requestStateの値が変わるごとに、requestStateが.RequestingであればrequestIndicatorのアニメーションを開始、.Requesting以外であればアニメーションを終了する挙動の宣言です

//requestIndicatorのhidden状態
requestState.map { reqState -> Bool in
    return reqState != .Requesting
}.bindTo(requestIndicator.bnd_hidden)

requestIndicatorのhiddenとのBindingです ここではrequestStateの値が変わるごとに、requestStateが.Requestingだった場合はrequestIndicatorを表示し、Requesting以外であればrequestIndicatorを隠す挙動の宣言です

//requestStateがSuccessの時
requestState.filter { reqState -> Bool in
    return reqState == .Success
}.observe { [unowned self] _ -> Void in
    self.finishSendMessage("送信成功しました")
}

//requestStateがErrorの時
requestState.filter { reqState -> Bool in
    return reqState == .Error
}.observe { [unowned self] _ -> Void in
    self.finishSendMessage("送信失敗しました")
}

requestStateが.Successに変化するごとに、送信成功アラートを表示 requestStateが.Errorに変化するごとに、送信失敗アラートを表示 という挙動を宣言しています

//sendButtonがタップされた時
sendButton.bnd_tap.observe { [unowned self] _ -> Void in
    
    self.requestState.next(.Requesting)
    
    //擬似通信処理
    let delay = 1.5 * Double(NSEC_PER_SEC)
    let time  = dispatch_time(DISPATCH_TIME_NOW, Int64(delay))
    dispatch_after(time, dispatch_get_main_queue()) { [unowned self] in
        //通信処理終了
        if arc4random_uniform(2) == 0 {
            self.requestState.next(.Success)
            return
        }
        self.requestState.next(.Error)
    }
    
}

ここは送信ボタンをタップした時の挙動です requestStateの値を.Requestingに変化させて、擬似通信として1.5秒待機させてから、requestStateを.Successもしくは.Errorに変化させるという挙動を宣言しています(nextメソッドでBinding対象の値を変化させることができます) requestStateが.Requestingになると先ほど宣言したとおり、送信ボタンが押せなくなり、通信中を表すインディケーターが表示されます

また、通信が終わり、requestStateが.Successもしくは.Errorになることで、送信ボタンが押せる状態になり、通信中のインディケーターが隠れます

そして非同期に実行されていた通信処理の結果の成功or失敗に応じてアラートが表示されます このようにSwfit BondはBindingだけでなく、通信処理終了後に次の処理を走らせるというPromiseのようなことも簡単に実現することができます

Swift Bondの有無の比較

Swift Bondを使わない場合だとテキストフィールドの変化を各々のデリゲートで検知して、各々のデリゲートメソッドからUI更新メソッドを呼び出す実装をしていましたが、Swift Bondを使えば一度、状態のパターンに応じてUI更新の挙動を宣言しておけば、以降はプログラマが明示的にUI更新をする必要はありません 状態の変化に応じて宣言した通りにUIが更新されます

デリゲートメソッドや状態のフラグとなるBool型のプロパティなどを定義し始めると、様々な箇所に処理が散らばったりしてプログラマが挙動を把握するのに時間が掛かってしまいますが、Swift Bondを使って状態のパターンに応じた挙動の宣言をしておくことで、宣言部を見れば、状態に応じてどういった挙動になるのかといったことが把握することができ、可読性が上がります

また、このサンプルをMVVMアーキテクチャで作る場合を考えてみてください

  • Swift Bond未使用
    • ユーザーによる入力の変化をViewレイヤー(ViewもしくはViewController)に実装したデリゲートで検知し、テキスト入力値をViewModelのViewに紐づく該当のプロパティに毎度渡すことで同期させたり、送信ボタンをタップした時にViewModelレイヤーからModelレイヤーに通信要求して、その返り値をViewControllerにコールバックで渡すような処理を書いたりと面倒です
  • Swift Bond使用
    • Swift Bondを使ってViewとViewModelのDataBindingをすることで、ユーザーのテキストの入力値はViewModelの該当のプロパティに即同期され、通信要求の結果もBindingによって挙動を宣言しておくことで、通信処理後にViewModelの該当の値を変化させれば、Bindingした宣言通りの挙動になることが分かるので、レイヤーが分かれていても状態の変化に応じた挙動を把握しやすくなります

まとめ

Swift Bondを使うと、この状態の時はこの挙動という宣言的な実装ができる 宣言以降は状態が変わるごとに宣言した通りの処理が実行されるので状態とUIの更新をBindingすることで、状態に応じて毎度UIを更新するメソッド定義して、様々なメソッドから呼び出す必要がなくなる 宣言部分を見れば、その状態に応じた挙動を把握できる

いかがでしょうか? 本記事ではSwift Bondの簡単な使い方とメリットだけを紹介しました もしSwift Bondについてもう少し詳しく知りたいと思った方は、先日、私がQiitaに投稿した下記の記事が役立つかもしれません Swift Bondの魅力 〜概念・仕組み編〜 Swift Bondの魅力 〜実用サンプル編〜

初見殺しな一面があるので慣れるまでは少し大変かもしれません また、中毒性がございますので用法容量お守りください  

最後に

マネーフォワードではSwift BondやRxSwiftの中毒者も募集しています 用法容量を守って一緒に素晴らしいプロダクトを作りませんか? ご応募お待ちしています。

【採用サイト】 ■マネーフォワード採用サイトWantedly | マネーフォワード

【プロダクト一覧】 ■家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 iPhone,iPad家計簿アプリ・クラウド家計簿ソフト『マネーフォワード』 Androidクラウド型会計ソフト『MFクラウド会計』クラウド型請求書管理ソフト『MFクラウド請求書』クラウド型給与計算ソフト『MFクラウド給与』経費精算システム『MFクラウド経費』消込ソフト・システム『MFクラウド消込』マイナンバー対応『MFクラウドマイナンバー』創業支援トータルサービス『MFクラウド創業支援サービス』お金に関する正しい知識やお得な情報を発信するウェブメディア『マネトク!』