Skip to content

difference between "MVP" and "MVVM" #4

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: mvp
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Fix Search
  • Loading branch information
marty-suzuki committed Feb 12, 2021
commit acc7832e3a51c96521d03fca6f4ade80d232edf0
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,24 @@ final class SearchViewController: UIViewController {
@IBOutlet private(set) weak var tableView: UITableView!
@IBOutlet private(set) weak var tableViewBottomConstraint: NSLayoutConstraint!


let searchBar = UISearchBar(frame: .zero)
let loadingView = LoadingView()

let viewModel: SearchViewModel
let viewModel: SearchViewModelType
let dataSource: SearchViewDataSource

private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType
private let makeUserRepositoryViewModel: (User) -> UserRepositoryViewModelType
private var cancellables = Set<AnyCancellable>()

init(favoritesInput: @escaping ([Repository]) -> Void,
favoritesOutput: AnyPublisher<[Repository], Never>) {
self.viewModel = SearchViewModel(favoritesOutput: favoritesOutput, favoritesInput: favoritesInput)
init(
viewModel: SearchViewModelType,
makeUserRepositoryViewModel: @escaping (User) -> UserRepositoryViewModelType,
makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType
) {
self.makeRepositoryViewModel = makeRepositoryViewModel
self.makeUserRepositoryViewModel = makeUserRepositoryViewModel
self.viewModel = viewModel
self.dataSource = SearchViewDataSource(viewModel: viewModel)
super.init(nibName: SearchViewController.className, bundle: nil)
}
Expand All @@ -45,10 +51,6 @@ final class SearchViewController: UIViewController {

dataSource.configure(with: tableView)

// searchBar.rx.text
// .bind(to: viewModel.input.searchText)
// .disposed(by: disposeBag)

// observe viewModel
viewModel.output.accessTokenAlert
.receive(on: DispatchQueue.main)
Expand Down Expand Up @@ -154,9 +156,11 @@ final class SearchViewController: UIViewController {
guard let me = self else {
return
}
let vc = UserRepositoryViewController(user: user,
favoritesOutput: me.viewModel.output.favorites,
favoritesInput: me.viewModel.input.favorites)
let vm = me.makeUserRepositoryViewModel(user)
let vc = UserRepositoryViewController(
viewModel: vm,
makeRepositoryViewModel: me.makeRepositoryViewModel
)
me.navigationController?.pushViewController(vc, animated: true)
}
}
Expand Down Expand Up @@ -187,4 +191,8 @@ extension SearchViewController: UISearchBarDelegate {
searchBar.resignFirstResponder()
searchBar.showsCancelButton = false
}

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
viewModel.input.searchText(searchBar.text)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import UIKit

final class SearchViewDataSource: NSObject {

private let viewModel: SearchViewModel
private let viewModel: SearchViewModelType

init(viewModel: SearchViewModel) {
init(viewModel: SearchViewModelType) {
self.viewModel = viewModel
}

Expand All @@ -30,12 +30,12 @@ final class SearchViewDataSource: NSObject {

extension SearchViewDataSource: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.users.count
return viewModel.output.users.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeue(UserViewCell.self, for: indexPath)
let user = viewModel.users[indexPath.row]
let user = viewModel.output.users[indexPath.row]
cell.configure(with: user)
return cell
}
Expand All @@ -60,7 +60,7 @@ extension SearchViewDataSource: UITableViewDelegate {
}

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
let user = viewModel.users[indexPath.row]
let user = viewModel.output.users[indexPath.row]
return UserViewCell.calculateHeight(with: user, and: tableView)
}

Expand All @@ -69,7 +69,7 @@ extension SearchViewDataSource: UITableViewDelegate {
}

func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return viewModel.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude
return viewModel.output.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
Expand Down
116 changes: 71 additions & 45 deletions iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,61 +11,53 @@ import Foundation
import GithubKit
import UIKit

final class SearchViewModel {
protocol SearchViewModelType: AnyObject {
var input: SearchViewModel.Input { get }
var output: SearchViewModel.Output { get }
}

final class SearchViewModel: SearchViewModelType{
let output: Output
let input: Input

var users: [User] {
model.users
}

var isFetchingUsers: Bool {
model.isFetchingUsers
}

private let model: SearchModel
private var cancellables = Set<AnyCancellable>()

init(favoritesOutput: AnyPublisher<[Repository], Never>,
favoritesInput: @escaping ([Repository]) -> Void) {
let model = SearchModel()
self.model = model

init(
searchModel: SearchModelType
) {
let viewDidAppear = PassthroughSubject<Void, Never>()
let viewDidDisappear = PassthroughSubject<Void, Never>()
let searchText = PassthroughSubject<String?, Never>()
let isReachedBottom = PassthroughSubject<Bool, Never>()
let selectedIndexPath = PassthroughSubject<IndexPath, Never>()
let headerFooterView = PassthroughSubject<UIView, Never>()

self.input = Input(viewDidAppear: viewDidAppear.send,
viewDidDisappear: viewDidDisappear.send,
searchText: searchText.send,
isReachedBottom: isReachedBottom.send,
selectedIndexPath: selectedIndexPath.send,
headerFooterView: headerFooterView.send,
favorites: favoritesInput)
self.input = Input(
viewDidAppear: viewDidAppear.send,
viewDidDisappear: viewDidDisappear.send,
searchText: searchText.send,
isReachedBottom: isReachedBottom.send,
selectedIndexPath: selectedIndexPath.send,
headerFooterView: headerFooterView.send
)

do {
let selectedUser = selectedIndexPath
.map { model.users[$0.row] }
.map { searchModel.users[$0.row] }
.eraseToAnyPublisher()

let updateLoadingView = headerFooterView
.combineLatest(model.isFetchingUsersPublisher)
.combineLatest(searchModel.isFetchingUsersPublisher)
.eraseToAnyPublisher()

let countString = model.totalCountPublisher
.combineLatest(model.usersPublisher)
let countString = searchModel.totalCountPublisher
.combineLatest(searchModel.usersPublisher)
.map { "\($1.count) / \($0)" }
.eraseToAnyPublisher()

let reloadData = model.usersPublisher.map { _ in }
.merge(
with:
model.totalCountPublisher.map { _ in },
model.isFetchingUsersPublisher.map { _ in }
)
let reloadData = searchModel.usersPublisher.map { _ in }
.merge(with: searchModel.totalCountPublisher.map { _ in },
searchModel.isFetchingUsersPublisher.map { _ in })
.eraseToAnyPublisher()

// keyboard notification
Expand Down Expand Up @@ -97,30 +89,41 @@ final class SearchViewModel {
.switchToLatest()
.eraseToAnyPublisher()

self.output = Output(accessTokenAlert: model.errorMessage,
updateLoadingView: updateLoadingView,
selectedUser: selectedUser,
keyboardWillShow: keyboardWillShow,
keyboardWillHide: keyboardWillHide,
countString: countString,
reloadData: reloadData,
favorites: favoritesOutput)
self.output = Output(
users: searchModel.users,
isFetchingUsers: searchModel.isFetchingUsers,
accessTokenAlert: searchModel.errorMessage,
updateLoadingView: updateLoadingView,
selectedUser: selectedUser,
keyboardWillShow: keyboardWillShow,
keyboardWillHide: keyboardWillHide,
countString: countString,
reloadData: reloadData
)
}

searchText
.map { $0 ?? "" }
.sink {
model.fetchUsers(withQuery: $0)
searchModel.fetchUsers(withQuery: $0)
}
.store(in: &cancellables)

isReachedBottom
.removeDuplicates()
.filter { $0 }
.sink { _ in
model.fetchUsers()
searchModel.fetchUsers()
}
.store(in: &cancellables)

searchModel.usersPublisher
.assign(to: \.users, on: output)
.store(in: &cancellables)

searchModel.isFetchingUsersPublisher
.assign(to: \.isFetchingUsers, on: output)
.store(in: &cancellables)
}
}

Expand All @@ -132,17 +135,40 @@ extension SearchViewModel {
let isReachedBottom: (Bool) -> Void
let selectedIndexPath: (IndexPath) -> Void
let headerFooterView: (UIView) -> Void
let favorites: ([Repository]) -> Void
}

struct Output {
final class Output {
@Published
fileprivate(set) var users: [User]
@Published
fileprivate(set) var isFetchingUsers: Bool
let accessTokenAlert: AnyPublisher<ErrorMessage, Never>
let updateLoadingView: AnyPublisher<(UIView, Bool), Never>
let selectedUser: AnyPublisher<User, Never>
let keyboardWillShow: AnyPublisher<UIKeyboardInfo, Never>
let keyboardWillHide: AnyPublisher<UIKeyboardInfo, Never>
let countString: AnyPublisher<String, Never>
let reloadData: AnyPublisher<Void, Never>
let favorites: AnyPublisher<[Repository], Never>
init(
users: [User],
isFetchingUsers: Bool,
accessTokenAlert: AnyPublisher<ErrorMessage, Never>,
updateLoadingView: AnyPublisher<(UIView, Bool), Never>,
selectedUser: AnyPublisher<User, Never>,
keyboardWillShow: AnyPublisher<UIKeyboardInfo, Never>,
keyboardWillHide: AnyPublisher<UIKeyboardInfo, Never>,
countString: AnyPublisher<String, Never>,
reloadData: AnyPublisher<Void, Never>
) {
self.users = users
self.isFetchingUsers = isFetchingUsers
self.accessTokenAlert = accessTokenAlert
self.updateLoadingView = updateLoadingView
self.selectedUser = selectedUser
self.keyboardWillShow = keyboardWillShow
self.keyboardWillHide = keyboardWillHide
self.countString = countString
self.reloadData = reloadData
}
}
}