4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Swift】UIKit × CombineでViewとViewModelを分けてみた

Posted at

はじめに

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 って
CurrentValueSubjectPassthroughSubject
値を送る場合は.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に分けた事で機能追加する時にどこを修正すれば良いか見やすくなった気がします。
またテストも書きやすくなっているはず。(まだ書いてないので分からない)
間違いあれば指摘いただけると嬉しいです。

参考記事

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?