iOSアプリでのstateごとのviewを出し分ける話
こんにちは
趣味みたいな仕事でstateごとにviewを出し分けたくなったのでその話です。
アプリって基本的に
遷移→APIのコール、loading→成功したらそれを表示、失敗したらエラー画面の表示
の繰り返しになり、
AndroidならViewAnimator | Android Developersを使って
同一階層にstateごとにviewを作成し、viewAnimatorのdisplayChildにbindして上げるとキレイに解決できますが
iOS appの場合それに当たるものなんだろうなーと思い奮闘していました。
正直ベストプラクティスな感じはしませんが一応メモ程度に残します。
やりたいこと
Loading | Error |
---|---|
みたいな画面を統一的に出したい
できたもの
とりあえず使い回すためにゴリゴリ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タイポしてた...)
各Viewの大きさはメインのview(SuccessView)に合わせて適当に配置
LoadingStateView, ErrorStateViewにはそれぞれさっきのcustomClassを当ててあげる
あとは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]) } } }
みたいにしてあげればよいかなという感じです。