はじめに
FatなViewControllerを生成してしまったので、Combineを使いViewとViewModelに分けてみました。
開発環境
Xcode 13.2.1
Swift 5.5.2
Combineって(使ったものだけざっくりと)
Combineを使うと
イベント処理のコードを一元化し、 ネストしたクロージャや規約ベースのコールバックといった面倒なテクニックを排除し、 コードを読みやすく、保守しやすくすることができる。
CurrentValueSubjectって
値を保持して、値が変わるたびに新しい要素を公開する
保持した値は、.value
で取得できる
final class CurrentValueSubject<Output, Failure> where Failure : Error
PassthroughSubjectって
値を保持せず、値が入ってきたら都度公開する
final class PassthroughSubject<Output, Failure> where Failure : Error
.send / .sink って
CurrentValueSubject
、PassthroughSubject
に
値を送る場合は.send
、
final func send(_ input: Output)
値を購読する場合は.sink
を使う
func sink(receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void),
receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable
.assing って
流れてくるデータをオブジェクトにバインディング(代入)する時に使う
func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>,
on object: Root) -> AnyCancellable
実装
1. ボタンの色を変える処理
ViewController
import UIKit
import Combine
final class TestViewController: UIViewController {
@IBOutlet weak var mainButton: UIBarButtonItem!
private let viewModel = TestViewModel()
private var subscriptions = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
func bind() {
viewModel.allBarButtonIsOn
.map { $0 ? .colorButtonOn : .colorButtonOff }
.assign(to: \.tintColor, on: allBarButton)
.store(in: &subscriptions)
}
@IBAction func mainButtonPressed(_ sender: UIBarButtonItem) {
viewModel.allButtonPressed.send()
}
}
ViewModel
import UIKit
import Combine
class TestViewModel {
let mainButtonPressed = PassthroughSubject<Void, Never>()
private(set) var mainButtonIsOn = CurrentValueSubject<Bool, Never>(true)
var subscriptions = Set<AnyCancellable>()
init() {
mutate()
}
func mutate() {
mainButtonPressed.sink { [weak self] _ in
guard let self = self else { return }
self.mainButtonIsOn.send(!self.mainButtonIsOn.value)
}.store(in: &subscriptions)
}
}
とても無駄の多いことをやっている印象があるが、処理を追加する際は綺麗に整理できるのかも。.assign
が使える時は使ってシンプルにしていきたい。
2. TableViewのデータを更新する処理
ViewController
final class TestViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
@IBOutlet weak var customTableView: UITableView!
private let viewModel = TestViewModel()
private var subscriptions = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
customTableView.delegate = self
customTableView.dataSource = self
customTableView.register(UINib(nibName: "TestCell", bundle: nil), forCellReuseIdentifier: "TestCell")
bind()
}
func bind() {
viewModel.dataArray.sink { [weak self] _ in
guard let self = self else { return }
self.customTableView.reloadData()
}.store(in: &subscriptions)
}
@IBAction func mainButtonPressed(_ sender: UIBarButtonItem) {
viewModel.dataReload.send()
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.dataArray.value.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let customCell = tableView.dequeueReusableCell(withIdentifier: "TestCell", for: indexPath) as! DataTableViewCell
customCell.setData(lesson: viewModel.dataArray.value[indexPath.row])
return customCell
}
}
ViewModel
class TestViewModel {
let dataReload = PassthroughSubject<Void, Never>()
private(set) var lessonsArray = CurrentValueSubject<[TableData], Never>([])
private var subscriptions = Set<AnyCancellable>()
private let coreDataMangaer = CoreDataManager.shared
init() {
mutate()
}
func mutate() {
dataReload.sink { [weak self] _ in
guard let self = self else { return }
self.lessonsArray.send(self.coreDataMangaer.loadAllData())
}.store(in: &subscriptions)
}
}
TableViewのデータ取得をViewModelで全てやる事でとても見やすくなった.
3. 画面遷移の処理
ViewController
final class TestViewController: UIViewController {
@IBOutlet weak var mainButton: UIBarButtonItem!
private let viewModel = TestViewModel()
private var subscriptions = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
func bind() {
viewModel.transiton.sink { [weak self] transition in
guard let self = self else { return }
switch transition {
case .setting:
let storyboard = UIStoryboard(name: "Setting", bundle: nil)
let vc = storyboard.instantiateViewController(identifier: "Setting")
self.navigationController?.pushViewController(vc, animated: true)
case let .detail(tableData):
let storyboard = UIStoryboard(name: "Detail", bundle: nil)
let vc = storyboard.instantiateViewController(identifier: "Detail")
guard let detailVC = vc as? DetailViewController else { return }
detailVC.lessonData = lessonData
detailVC.delegate = self
self.navigationController?.pushViewController(vc, animated: true)
}
}
}
@IBAction func mainButtonPressed(_ sender: UIBarButtonItem) {
viewModel.mainButtonPressed.send()
}
}
ViewModel
enum ViewTransition {
case setting
case detail(TableData)
}
class TestViewModel {
let mainButtonPressed = PassthroughSubject<Void, Never>()
private(set) var transiton = PassthroughSubject<ViewTransition, Never>()
var subscriptions = Set<AnyCancellable>()
init() {
mutate()
}
func mutate() {
mainButtonPressed.sink { [weak self] tableData in
guard let self = self else { return }
self.transiton.send(.setting)
}.store(in: &subscriptions)
}
}
画面の遷移先が一目で分かるように整理できた。遷移先追加もしやすそう。
感想
View-ViewModelに分けた事で機能追加する時にどこを修正すれば良いか見やすくなった気がします。
またテストも書きやすくなっているはず。(まだ書いてないので分からない)
間違いあれば指摘いただけると嬉しいです。
参考記事