珈琲とバイクとときどきプログラミング

狐が好きな編入生的な人間のブログです

iOSアプリでのstateごとのviewを出し分ける話

こんにちは

趣味みたいな仕事でstateごとにviewを出し分けたくなったのでその話です。

アプリって基本的に

遷移→APIのコール、loading→成功したらそれを表示、失敗したらエラー画面の表示

の繰り返しになり、

AndroidならViewAnimator | Android Developersを使って

同一階層にstateごとにviewを作成し、viewAnimatorのdisplayChildにbindして上げるとキレイに解決できますが

iOS appの場合それに当たるものなんだろうなーと思い奮闘していました。

正直ベストプラクティスな感じはしませんが一応メモ程度に残します。

やりたいこと

Loading Error
f:id:yosshi0774:20180321015909p:plain f:id:yosshi0774:20180321015924p:plain

みたいな画面を統一的に出したい

できたもの

とりあえず使い回すためにゴリゴリviewを書いていく

まずはloading側

class LoadingStateView: UIView {

    private lazy var indicator: UIActivityIndicatorView = {
        let indicator = UIActivityIndicatorView()
        indicator.color = UIColor.black
        return indicator
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        backgroundColor = UIColor.vcBg
        addSubview(indicator)
        indicator.startAnimating()
        indicator.snp.makeConstraints {
            $0.width.height.equalTo(50)
            $0.center.equalToSuperview()
        }
    }
}

次にError側

import RxSwift
import RxCocoa

class ErrorStateView: UIView {

    private let disposeBag = DisposeBag()

    private lazy var reloadButton: UIButton = {
        let button = UIButton()
        button.setTitle("もう一回読み込む", for: .normal)
        button.titleLabel?.font = UIFont(name: "HiraKakuProN-W3", size: 15)
        button.backgroundColor = // pinkっぽい色
        button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 15, bottom: 10, right: 15)
        button.layer.cornerRadius = button.intrinsicContentSize.height / 2
        return button
    }()

    private lazy var messageLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont(name: "HiraKakuProN-W3", size: 15)
        label.textColor = UIColor.black
        label.text = "通信に失敗しました"
        label.sizeToFit()
        return label
    }()

    private lazy var stackView: UIStackView = {
        let stackView = UIStackView()
        stackView.addArrangedSubview(messageLabel)
        stackView.addArrangedSubview(reloadButton)
        stackView.spacing = 10.0
        stackView.axis = .vertical
        stackView.distribution = .fill
        stackView.alignment = .center
        return stackView
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        backgroundColor = UIColor.vcBg
        addSubview(stackView)

        stackView.snp.makeConstraints {
            $0.right.left.equalToSuperview()
            $0.centerY.equalToSuperview()
        }

    }

    func setupButtonClickListener(handler: @escaping () -> Void) {
        reloadButton.rx.tap
                    .asDriver(onErrorDriveWith: Driver.empty())
                    .drive(onNext: {
                        handler()
        }).disposed(by: disposeBag)
    }
}

snapKitを使ってゴリゴリconstraintsとか書くのが好きなのでこんな感じになりました。

これらを使いまわせばいけそうですね

使い方(storyBoardの設定)

普通はviewのしたにゴリゴリviewを配置していくのところをとりあえず3つのviewを作ってみる(successタイポしてた...)

f:id:yosshi0774:20180321020524p:plain

各Viewの大きさはメインのview(SuccessView)に合わせて適当に配置

f:id:yosshi0774:20180321020715p:plain

LoadingStateView, ErrorStateViewにはそれぞれさっきのcustomClassを当ててあげる

f:id:yosshi0774:20180321020825p:plain

あとはIBから使いたいViewControllerにoutletをつなぎましょう

使い方(コード上の設定)

初心者ながらMVVMっぽく書きたくなったので

class ProductRankingsPageViewController: UIViewController {
    
    private let disposeBag = DisposeBag()
    
    @IBOutlet weak private var loadingView: LoadingStateView!
    @IBOutlet weak private var mainView: UIView!
    @IBOutlet weak private var errorView: ErrorStateView!
    
    private var stateViews: [UIView] {
        return [loadingView, mainView, errorView]
    }

    private let viewModel = TestPageViewModel()

    override func viewDidLoad() {
       super.viewDidLoad()
       viewModel.viewDidLoad()
       setupListener()
    }

    private func setupListener(){
        errorView.setupButtonClickListener { [weak self] in self?.viewModel.reconnect() }

        viewModel.viewState
            .drive(onNext: { [weak self] state in
                guard let strongSelf = self else { return }
                strongSelf.view.bringSubview(toFront: (strongSelf.stateViews[state.rawValue]))
                strongSelf.collectionView.reloadData()
                strongSelf.collectionView.setContentOffset(CGPoint.zero, animated: false)
            }).disposed(by: disposeBag)


    }
}

こんな感じになっており

さっきの3つのviewを配列として返すようなcomputed propertyを作っておき、

viewの状態をsubscribeして、現状のviewを最前列に持ってくるみたいなロジックです。(つねに3つのView持ってるの不健全っぽい)

また、errorの時のボタンを押した時の挙動をセットしてあげています。

viewModelはこんな感じで

import RxSwift
import RxCocoa

class TestPageViewModel {
    private let state: BehaviorRelay<LoadingState> = BehaviorRelay(value: .loading)

    private var busy = false

   var viewState: Driver<LoadingState> {
       return state.asDriver(onErrorDriveWith: Driver.empty())
   }

   func viewDidLoad(){
        state.accept(.loading)
        fetch()
   } 
   
   func reconnect(){
        state.accept(.loading)
        fetch()
   }

   private func fetch() {
      
      let single = // rxMoyaを使ってSingle<T>を返すようなrequestをここに書いています
      single.observeOn(MainScheduler.instance)
                .subscribe { [weak self] event in
                    guard let strongSelf = self else { return }
                    switch event {
                    case .success(let dailyRanking, let categoryRanking):
                        // 成功時の処理
                        strongSelf.state.accept(.success)
                    case .error:
            // 失敗時の処理
                        strongSelf.state.accept(.failure)
                    }
                    strongSelf.busy = false
                    strongSelf.refreshing.accept(false)
                }.disposed(by: disposeBag)
   }
}

stateにerrorを流すことは基本的にないのでviewStateでdriverに変換し、適当にerrorは潰して上げます。

APIのコールが成功→state.accept(.sucess)

APIのコールが失敗→state.accept(.failure)

としてます。

ここでLoadingStateは簡単なEnum

enum LoadingState: Int {
    case loading = 0
    case success = 1
    case failure = 2
}

こんなものです。

これで動くかと思います。

MVVMは状態やインスタンスの変数をVMに吐き出せるのでVCがスッキリしていいですね。

VCにすべて書こうとするならば

private var state: LoadingState = .loading {
    didSet {
            DispatchQueue.main.async {
                self.view.bringSubview(toFront: self.stateViews[self.state.rawValue])
            }
     }
}

みたいにしてあげればよいかなという感じです。