diff --git a/Images/MVC/Views.key b/Images/MVC/Views.key index 14c28a5..ccca2ac 100644 Binary files a/Images/MVC/Views.key and b/Images/MVC/Views.key differ diff --git a/Images/MVP/Views.key b/Images/MVP/Views.key new file mode 100644 index 0000000..2597c39 Binary files /dev/null and b/Images/MVP/Views.key differ diff --git a/Images/MVVM/Views.key b/Images/MVVM/Views.key new file mode 100644 index 0000000..ed8b647 Binary files /dev/null and b/Images/MVVM/Views.key differ diff --git a/Images/favorite.png b/Images/favorite.png new file mode 100644 index 0000000..f248450 Binary files /dev/null and b/Images/favorite.png differ diff --git a/Images/repository.png b/Images/repository.png new file mode 100644 index 0000000..498a4fc Binary files /dev/null and b/Images/repository.png differ diff --git a/Images/search.png b/Images/search.png new file mode 100644 index 0000000..c8c6f37 Binary files /dev/null and b/Images/search.png differ diff --git a/Images/structure.png b/Images/structure.png index 85e7cb2..c609fb6 100644 Binary files a/Images/structure.png and b/Images/structure.png differ diff --git a/Images/user_repository.png b/Images/user_repository.png new file mode 100644 index 0000000..06caad4 Binary files /dev/null and b/Images/user_repository.png differ diff --git a/README.md b/README.md index 5254b4b..e92a566 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# iOSDesignPatternSamples (MVC) +# iOSDesignPatternSamples (MVVM) -This is Github user search demo app that made with MVC design pattern. +This is Github user search demo app that made with MVVM design pattern. ## Application Structure @@ -8,10 +8,36 @@ This is Github user search demo app that made with MVC design pattern. ## ViewControllers -- [SearchViewController](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift) -> Search Github user and show user result list -- [FavoriteViewController](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift) -> Show local on memory favorite repositories -- [UserRepositoryViewController](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift) -> Show Github user's repositories -- [RepositoryViewController](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift) -> Show a repository and add / remove local on memory favorites +### [SearchViewController](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift) +Search Github user and show user result list + +![](./Images/search.png) + +- [SearchViewModel](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift) +- [SearchViewDataSource](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift) <- Adapt UITableViewDataSource and UITableViewDelegate + +### [FavoriteViewController](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift) +Show local on memory favorite repositories + +![](./Images/favorite.png) + +- [FavoriteViewModel](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewModel.swift) +- [FavoriteViewDataSource](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift) <- Adapt UITableViewDataSource and UITableViewDelegate + +### [UserRepositoryViewController](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift) +Show Github user's repositories + +![](./Images/user_repository.png) + +- [UserRepositoryViewModel](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewModel.swift) +- [UserRepositoryViewDataSource](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift) <- Adapt UITableViewDataSource and UITableViewDelegate + +### [RepositoryViewController](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift) +Show a repository and add / remove local on memory favorites + +![](./Images/repository.png) + +- [RepositoryViewModel](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewModel.swift) ## How to add / remove favorites diff --git a/iOSDesignPatternSamples.xcodeproj/project.pbxproj b/iOSDesignPatternSamples.xcodeproj/project.pbxproj index fc27d65..d953d3b 100644 --- a/iOSDesignPatternSamples.xcodeproj/project.pbxproj +++ b/iOSDesignPatternSamples.xcodeproj/project.pbxproj @@ -10,15 +10,22 @@ 0706D2760E1B3A990D0C277A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64602919324DEDBC0429D452 /* AppDelegate.swift */; }; 072EB0FFF0B72F37C8CF669A /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED48619F4A0CDE4B2D46702 /* SearchModel.swift */; }; 1791BB0E1AEB38868026578F /* RepositoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */; }; + 1B6B63FE22A5A86F6028991E /* UserRepositoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BA8AC18BFBE203EBECBDF0 /* UserRepositoryViewModel.swift */; }; + 450252B4BA07F8D498E608EF /* RepositoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AAD61E2A4041414E741BC4 /* RepositoryViewModel.swift */; }; 4EF255BBD109181D8AD5058F /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208BE35457B09256BF71DAD1 /* SearchViewController.swift */; }; 5A6EC25DC03393E8F8E064FF /* ApiSession.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34175ABC0E7BB9870D592CEF /* ApiSession.extension.swift */; }; + 651202151F5333A9ABE09B22 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8FB5FFB17FCEE46B7E72E4 /* SearchViewModel.swift */; }; 68266EFC53379F0728F6B00B /* FavoriteViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */; }; 6D22F97989A935ECA42DB3CA /* SearchViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5EDBADE530FE153A9651F109 /* SearchViewController.xib */; }; 6EC13FBC2AE1E8DBEBE88804 /* FavoriteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */; }; 71E4B210FC5945A53739149D /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2B00ED49EA7B8CD6C7C4F5 /* LoadingView.swift */; }; + 72E107687058F53E4B2EF247 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A35DE51E635D96C6FB469DF /* FavoriteViewModel.swift */; }; 7D1CB8434AAE6D1FC50B9D2E /* SafariServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FA63604B63E10BD6DC6520D0 /* SafariServices.framework */; }; + 7F3AC0844FF343E754FD4431 /* SearchViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E02ECB42B97D30045AF6AB2 /* SearchViewDataSource.swift */; }; 811C3B9B712E3B67E5AD73FD /* UIKeyboardInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711633F4B85B3F76D0C88E41 /* UIKeyboardInfo.swift */; }; + 82992D62FE8ED0043356C237 /* FavoriteViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DE1FF29F81CED271FD929C2 /* FavoriteViewDataSource.swift */; }; 9B515DE20E1424AC3D1F08CF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AE92440962235D7ABB12EAFA /* Main.storyboard */; }; + B7B8D28336669C7BF3C29BEC /* UserRepositoryViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B79174D1421B0FCFE7154 /* UserRepositoryViewDataSource.swift */; }; C3F5B4AE9DAFA2095EBCB56A /* NSObjectProtocol.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186C7AADB8679B060E7A2C1B /* NSObjectProtocol.extension.swift */; }; C69B1DDE761E0663FDE1E947 /* UserRepositoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE9204A886B2448F36B9293 /* UserRepositoryViewController.swift */; }; D0CFC875B535D97424BB8589 /* GithubKit in Frameworks */ = {isa = PBXBuildFile; productRef = C2FA27FA77B01E3C42D84622 /* GithubKit */; }; @@ -35,13 +42,20 @@ 208BE35457B09256BF71DAD1 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; 260D4E07190EC496827E1037 /* iOSDesignPatternSamples.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iOSDesignPatternSamples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 34175ABC0E7BB9870D592CEF /* ApiSession.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiSession.extension.swift; sourceTree = ""; }; + 3A35DE51E635D96C6FB469DF /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; }; + 3B8FB5FFB17FCEE46B7E72E4 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryViewController.swift; sourceTree = ""; }; + 42AAD61E2A4041414E741BC4 /* RepositoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryViewModel.swift; sourceTree = ""; }; 482D2D42402C917E5C0069BC /* FavoriteModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteModel.swift; sourceTree = ""; }; + 5DE1FF29F81CED271FD929C2 /* FavoriteViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewDataSource.swift; sourceTree = ""; }; 5EDBADE530FE153A9651F109 /* SearchViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SearchViewController.xib; sourceTree = ""; }; 64602919324DEDBC0429D452 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 6AE9204A886B2448F36B9293 /* UserRepositoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryViewController.swift; sourceTree = ""; }; 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewController.swift; sourceTree = ""; }; 711633F4B85B3F76D0C88E41 /* UIKeyboardInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKeyboardInfo.swift; sourceTree = ""; }; + 7E02ECB42B97D30045AF6AB2 /* SearchViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewDataSource.swift; sourceTree = ""; }; + 85BA8AC18BFBE203EBECBDF0 /* UserRepositoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryViewModel.swift; sourceTree = ""; }; + 969B79174D1421B0FCFE7154 /* UserRepositoryViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryViewDataSource.swift; sourceTree = ""; }; 9ABD5244E170566F15BBA15E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 9B5B08A007452F84452B3F0D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 9E2B00ED49EA7B8CD6C7C4F5 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; @@ -79,6 +93,8 @@ children = ( 6AE9204A886B2448F36B9293 /* UserRepositoryViewController.swift */, 1F905F35ABF1B1579FEE2B5A /* UserRepositoryViewController.xib */, + 969B79174D1421B0FCFE7154 /* UserRepositoryViewDataSource.swift */, + 85BA8AC18BFBE203EBECBDF0 /* UserRepositoryViewModel.swift */, ); path = UserRepository; sourceTree = ""; @@ -88,6 +104,8 @@ children = ( 208BE35457B09256BF71DAD1 /* SearchViewController.swift */, 5EDBADE530FE153A9651F109 /* SearchViewController.xib */, + 7E02ECB42B97D30045AF6AB2 /* SearchViewDataSource.swift */, + 3B8FB5FFB17FCEE46B7E72E4 /* SearchViewModel.swift */, ); path = Search; sourceTree = ""; @@ -112,6 +130,7 @@ isa = PBXGroup; children = ( 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */, + 42AAD61E2A4041414E741BC4 /* RepositoryViewModel.swift */, ); path = Repository; sourceTree = ""; @@ -152,6 +171,8 @@ children = ( 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */, E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */, + 5DE1FF29F81CED271FD929C2 /* FavoriteViewDataSource.swift */, + 3A35DE51E635D96C6FB469DF /* FavoriteViewModel.swift */, ); path = Favorite; sourceTree = ""; @@ -283,14 +304,21 @@ 0706D2760E1B3A990D0C277A /* AppDelegate.swift in Sources */, D84737DB64A88B1888B8465D /* FavoriteModel.swift in Sources */, 6EC13FBC2AE1E8DBEBE88804 /* FavoriteViewController.swift in Sources */, + 82992D62FE8ED0043356C237 /* FavoriteViewDataSource.swift in Sources */, + 72E107687058F53E4B2EF247 /* FavoriteViewModel.swift in Sources */, 71E4B210FC5945A53739149D /* LoadingView.swift in Sources */, C3F5B4AE9DAFA2095EBCB56A /* NSObjectProtocol.extension.swift in Sources */, ED8C1E52A3D6C0097A18601E /* RepositoryModel.swift in Sources */, 1791BB0E1AEB38868026578F /* RepositoryViewController.swift in Sources */, + 450252B4BA07F8D498E608EF /* RepositoryViewModel.swift in Sources */, 072EB0FFF0B72F37C8CF669A /* SearchModel.swift in Sources */, 4EF255BBD109181D8AD5058F /* SearchViewController.swift in Sources */, + 7F3AC0844FF343E754FD4431 /* SearchViewDataSource.swift in Sources */, + 651202151F5333A9ABE09B22 /* SearchViewModel.swift in Sources */, 811C3B9B712E3B67E5AD73FD /* UIKeyboardInfo.swift in Sources */, C69B1DDE761E0663FDE1E947 /* UserRepositoryViewController.swift in Sources */, + B7B8D28336669C7BF3C29BEC /* UserRepositoryViewDataSource.swift in Sources */, + 1B6B63FE22A5A86F6028991E /* UserRepositoryViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift index 7fc18a7..9c981c0 100644 --- a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift +++ b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift @@ -6,15 +6,16 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit +import Combine import GithubKit +import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - let favoriteModel = FavoriteModel() + private let favoriteModel = FavoriteModel() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -23,23 +24,38 @@ class AppDelegate: UIResponder, UIApplicationDelegate { switch value { case let (0, nc as UINavigationController): let searchVC = SearchViewController( - searchModel: SearchModel( - sendRequest: ApiSession.shared.send, - asyncAfter: { DispatchQueue.global().asyncAfter(deadline: $0, execute: $1) }, - mainAsync: { work in DispatchQueue.main.async { work() } } + viewModel: SearchViewModel( + searchModel: SearchModel( + sendRequest: ApiSession.shared.send + ), + notificationCenter: .default ), - makeFavoriteModel: { [favoriteModel] in favoriteModel }, - makeRepositoryModel: { - RepositoryModel( + makeUserRepositoryViewModel: { [favoriteModel] in + UserRepositoryViewModel( user: $0, - sendRequest: ApiSession.shared.send + favoriteModel: favoriteModel, + repositoryModel: RepositoryModel( + user: $0, + sendRequest: ApiSession.shared.send + ) + ) + }, + makeRepositoryViewModel: { [favoriteModel] in + RepositoryViewModel( + repository: $0, + favoritesModel: favoriteModel ) } ) nc.setViewControllers([searchVC], animated: false) case let (1, nc as UINavigationController): - let favoriteVC = FavoriteViewController(favoriteModel: favoriteModel) + let favoriteVC = FavoriteViewController( + viewModel: FavoriteViewModel(favoriteModel: favoriteModel), + makeRepositoryViewModel: { [favoriteModel] in + RepositoryViewModel(repository: $0, favoritesModel: favoriteModel) + } + ) nc.setViewControllers([favoriteVC], animated: false) default: @@ -51,4 +67,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } } - diff --git a/iOSDesignPatternSamples/Sources/Common/Extension/ApiSession.extension.swift b/iOSDesignPatternSamples/Sources/Common/Extension/ApiSession.extension.swift index 1252d2c..f1528e4 100644 --- a/iOSDesignPatternSamples/Sources/Common/Extension/ApiSession.extension.swift +++ b/iOSDesignPatternSamples/Sources/Common/Extension/ApiSession.extension.swift @@ -16,4 +16,4 @@ extension ApiSession { }() } -typealias SendRequest = (T, @escaping (Result) -> ()) -> AnyCancellable +typealias SendRequest = (T) -> AnyPublisher diff --git a/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift b/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift index 8d825f8..84d4685 100644 --- a/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift +++ b/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift @@ -6,43 +6,71 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // +import Combine import GithubKit -protocol FavoriteModelDelegate: AnyObject { - func favoriteDidChange() -} - -extension FavoriteModelDelegate { - func favoriteDidChange() {} -} - protocol FavoriteModelType: AnyObject { var favorites: [Repository] { get } - var delegate: FavoriteModelDelegate? { get set } + var favoritePublisher: Published<[Repository]>.Publisher { get } func addFavorite(_ repository: Repository) func removeFavorite(_ repository: Repository) + func contains(_ repository: Repository) -> AnyPublisher } final class FavoriteModel: FavoriteModelType { - private(set) var favorites: [Repository] = [] { - didSet { - delegate?.favoriteDidChange() - } + @Published + private(set) var favorites: [Repository] = [] + var favoritePublisher: Published<[Repository]>.Publisher { + $favorites } - - weak var delegate: FavoriteModelDelegate? - + + private let _addFavorite = PassthroughSubject() + private let _removeFavorite = PassthroughSubject() + private var cancellables = Set() + + init() { + let favorites1 = _addFavorite + .flatMap { [weak self] repository -> AnyPublisher<[Repository], Never> in + guard let me = self else { + return Empty().eraseToAnyPublisher() + } + var favorites = me.favorites + if favorites.firstIndex(where: { $0.url == repository.url }) != nil { + return Empty().eraseToAnyPublisher() + } + favorites.append(repository) + return Just(favorites).eraseToAnyPublisher() + } + + let favorites2 = _removeFavorite + .flatMap { [weak self] repository -> AnyPublisher<[Repository], Never> in + guard let me = self else { + return Empty().eraseToAnyPublisher() + } + var favorites = me.favorites + guard let index = favorites.firstIndex(where: { $0.url == repository.url }) else { + return Empty().eraseToAnyPublisher() + } + favorites.remove(at: index) + return Just(favorites).eraseToAnyPublisher() + } + + favorites1.merge(with: favorites2) + .assign(to: \.favorites, on: self) + .store(in: &cancellables) + } + func addFavorite(_ repository: Repository) { - if favorites.firstIndex(where: { $0.url == repository.url }) != nil { - return - } - favorites.append(repository) + _addFavorite.send(repository) } - + func removeFavorite(_ repository: Repository) { - guard let index = favorites.firstIndex(where: { $0.url == repository.url }) else { - return - } - favorites.remove(at: index) + _removeFavorite.send(repository) + } + + func contains(_ repository: Repository) -> AnyPublisher { + $favorites + .map { $0.contains { $0.url == repository.url } } + .eraseToAnyPublisher() } } diff --git a/iOSDesignPatternSamples/Sources/Common/RepositoryModel.swift b/iOSDesignPatternSamples/Sources/Common/RepositoryModel.swift index 0b3af5c..ddd9372 100644 --- a/iOSDesignPatternSamples/Sources/Common/RepositoryModel.swift +++ b/iOSDesignPatternSamples/Sources/Common/RepositoryModel.swift @@ -9,17 +9,10 @@ import Combine import GithubKit -protocol RepositoryModelDelegate: AnyObject { - func repositoryModel(_ repositoryModel: RepositoryModel, didChange isFetchingRepositories: Bool) - func repositoryModel(_ repositoryModel: RepositoryModel, didChange repositories: [Repository]) - func repositoryModel(_ repositoryModel: RepositoryModel, didChange totalCount: Int) -} - protocol RepositoryModelType: AnyObject { - var user: User { get } - var delegate: RepositoryModelDelegate? { get set } - var query: String { get } - var totalCount: Int { get } + var repositoriesPublisher: Published<[Repository]>.Publisher { get } + var isFetchingRepositoriesPublisher: Published.Publisher { get } + var totalCountPublisher: Published.Publisher { get } var repositories: [Repository] { get } var isFetchingRepositories: Bool { get } func fetchRepositories() @@ -27,60 +20,88 @@ protocol RepositoryModelType: AnyObject { final class RepositoryModel: RepositoryModelType { - let user: User - weak var delegate: RepositoryModelDelegate? - - private(set) var query: String = "" - private(set) var totalCount: Int = 0 { - didSet { - delegate?.repositoryModel(self, didChange: totalCount) - } + var repositoriesPublisher: Published<[Repository]>.Publisher { + $repositories } - private(set) var repositories: [Repository] = [] { - didSet { - delegate?.repositoryModel(self, didChange: repositories) - } + var isFetchingRepositoriesPublisher: Published.Publisher { + $isFetchingRepositories } - private(set) var isFetchingRepositories = false { - didSet { - delegate?.repositoryModel(self, didChange: isFetchingRepositories) - } + var totalCountPublisher: Published.Publisher { + $totalCount } + @Published + private(set) var repositories: [Repository] = [] + @Published + private(set) var isFetchingRepositories = false + @Published + private var totalCount = 0 + @Published private var pageInfo: PageInfo? - private var cancellable: AnyCancellable? - private let sendRequest: SendRequest + + private var cancellables = Set() + + private let _fetchRepositories = PassthroughSubject() init( user: User, sendRequest: @escaping SendRequest ) { - self.user = user - self.sendRequest = sendRequest - } + let requestTrigger = $pageInfo + .map { (user, $0) } - func fetchRepositories() { - if cancellable != nil { return } - if let pageInfo = pageInfo, !pageInfo.hasNextPage || pageInfo.endCursor == nil { return } - isFetchingRepositories = true - let request = UserNodeRequest(id: user.id, after: pageInfo?.endCursor) - self.cancellable = sendRequest(request) { [weak self] in - guard let me = self else { - return + let initialLoadRequest = _fetchRepositories + .flatMap { _ in + requestTrigger + .prefix(1) } + .filter { $1 == nil } - switch $0 { - case .success(let value): - me.pageInfo = value.pageInfo - me.repositories.append(contentsOf: value.nodes) - me.totalCount = value.totalCount + let loadMoreRequest = _fetchRepositories + .flatMap { _ in + requestTrigger + .prefix(1) + } + .filter { $1 != nil } + + let willStartRequest = initialLoadRequest + .merge(with: loadMoreRequest) + .flatMap { user, pageInfo -> AnyPublisher in + if let pageInfo = pageInfo, !pageInfo.hasNextPage { + return Empty().eraseToAnyPublisher() + } + let request = UserNodeRequest(id: user.id, after: pageInfo?.endCursor) + return Just(request).eraseToAnyPublisher() + } + .removeDuplicates { $0.id == $1.id && $0.after == $1.after } - case .failure(let error): - print(error) + willStartRequest + .handleEvents(receiveOutput: { [weak self] _ in + self?.isFetchingRepositories = true + }) + .flatMap { request -> AnyPublisher, Never> in + sendRequest(request) + .catch { _ -> AnyPublisher, Never> in + Empty().eraseToAnyPublisher() + } + .prefix(1) + .eraseToAnyPublisher() } + .handleEvents(receiveOutput: { [weak self] _ in + self?.isFetchingRepositories = false + }) + .sink { [weak self] response in + guard let me = self else { + return + } + me.pageInfo = response.pageInfo + me.repositories = me.repositories + response.nodes + me.totalCount = response.totalCount + } + .store(in: &cancellables) + } - me.isFetchingRepositories = false - me.cancellable = nil - } + func fetchRepositories() { + _fetchRepositories.send() } } diff --git a/iOSDesignPatternSamples/Sources/Common/SearchModel.swift b/iOSDesignPatternSamples/Sources/Common/SearchModel.swift index 05182bc..d9f0077 100644 --- a/iOSDesignPatternSamples/Sources/Common/SearchModel.swift +++ b/iOSDesignPatternSamples/Sources/Common/SearchModel.swift @@ -10,22 +10,16 @@ import Combine import GithubKit import Foundation -protocol SearchModelDelegate: AnyObject { - func searchModel(_ searchModel: SearchModel, didRecieve errorMessage: ErrorMessage) - func searchModel(_ searchModel: SearchModel, didChange isFetchingUsers: Bool) - func searchModel(_ searchModel: SearchModel, didChange users: [User]) - func searchModel(_ searchModel: SearchModel, didChange totalCount: Int) -} - struct ErrorMessage { let title: String let message: String } protocol SearchModelType: AnyObject { - var delegate: SearchModelDelegate? { get set } - var query: String { get } - var totalCount: Int { get } + var errorMessage: AnyPublisher { get } + var usersPublisher: Published<[User]>.Publisher { get } + var isFetchingUsersPublisher: Published.Publisher { get } + var totalCountPublisher: Published.Publisher { get } var users: [User] { get } var isFetchingUsers: Bool { get } func fetchUsers() @@ -33,105 +27,126 @@ protocol SearchModelType: AnyObject { } final class SearchModel: SearchModelType { + let errorMessage: AnyPublisher - weak var delegate: SearchModelDelegate? - - private(set) var query: String = "" - private(set) var totalCount: Int = 0 { - didSet { - delegate?.searchModel(self, didChange: totalCount) - } + var usersPublisher: Published<[User]>.Publisher { + $users } - private(set) var users: [User] = [] { - didSet { - delegate?.searchModel(self, didChange: users) - } + var isFetchingUsersPublisher: Published.Publisher { + $isFetchingUsers } - private(set) var isFetchingUsers = false { - didSet { - delegate?.searchModel(self, didChange: isFetchingUsers) - } + var totalCountPublisher: Published.Publisher { + $totalCount } + @Published + private(set) var users: [User] = [] + @Published + private(set) var isFetchingUsers = false + @Published + private var totalCount = 0 + @Published private var pageInfo: PageInfo? - private var cancellable: AnyCancellable? - - private lazy var debounce: (_ action: @escaping () -> ()) -> () = { - var lastFireTime: DispatchTime = .now() - let delay: DispatchTimeInterval = .milliseconds(500) - return { [delay, asyncAfter, mainAsync] action in - let deadline: DispatchTime = .now() + delay - lastFireTime = .now() - asyncAfter(deadline) { [delay] in - let now: DispatchTime = .now() - let when: DispatchTime = lastFireTime + delay - if now < when { return } - lastFireTime = .now() - mainAsync(action) - } - } - }() + @Published + private var query: String? + + private var cancellable = Set() - private let sendRequest: SendRequest - private let asyncAfter: (DispatchTime, @escaping @convention(block) () -> Void) -> Void - private let mainAsync: (@escaping () -> Void) -> Void + private let _fetchUsers = PassthroughSubject() + private let _feachUsersWithQuery = PassthroughSubject() init( - sendRequest: @escaping SendRequest, - asyncAfter: @escaping (DispatchTime, @escaping @convention(block) () -> Void) -> Void, - mainAsync: @escaping (@escaping () -> Void) -> Void + sendRequest: @escaping SendRequest ) { - self.sendRequest = sendRequest - self.asyncAfter = asyncAfter - self.mainAsync = mainAsync - } + let _errorMessage = PassthroughSubject() + self.errorMessage = _errorMessage.eraseToAnyPublisher() - func fetchUsers() { - if query.isEmpty || cancellable != nil { return } - if let pageInfo = pageInfo, !pageInfo.hasNextPage || pageInfo.endCursor == nil { return } - isFetchingUsers = true - let request = SearchUserRequest(query: query, after: pageInfo?.endCursor) - self.cancellable = sendRequest(request) { [weak self] in - guard let me = self else { - return + let pageInfo = $pageInfo + + let query = $query + .map { $0 ?? "" } + + let initialLoad = query + .filter { !$0.isEmpty } + .flatMap { query in + pageInfo + .map { (query, $0) } + .prefix(1) } - switch $0 { - case .success(let value): - me.pageInfo = value.pageInfo - me.users.append(contentsOf: value.nodes) - me.totalCount = value.totalCount + let loadMore = _fetchUsers + .flatMap { _ in + query + .combineLatest(pageInfo) + .prefix(1) + } + .filter { !$0.isEmpty && $1 != nil } + + _feachUsersWithQuery + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] in + self?.pageInfo = nil + self?.users = [] + self?.totalCount = 0 + self?.query = $0 + } + .store(in: &cancellable) - case .failure(let error): - if case .emptyToken? = (error as? ApiSession.Error) { + let requestWillStart = initialLoad.merge(with: loadMore) + .flatMap { query, pageInfo -> AnyPublisher in + if let pageInfo = pageInfo, !pageInfo.hasNextPage { + return Empty().eraseToAnyPublisher() + } + let request = SearchUserRequest(query: query, after: pageInfo?.endCursor) + return Just(request).eraseToAnyPublisher() + } + .removeDuplicates { $0.query == $1.query && $0.after == $1.after } + + requestWillStart + .handleEvents(receiveOutput: { [weak self] _ in + self?.isFetchingUsers = true + }) + .flatMap { request -> AnyPublisher, Error>, Never> in + sendRequest(request) + .map { response in + Result, Error>.success(response) + } + .catch { error in + Just(Result, Error>.failure(error)) + } + .prefix(1) + .eraseToAnyPublisher() + } + .handleEvents(receiveOutput: { [weak self] _ in + self?.isFetchingUsers = false + }) + .sink { [weak self] result in + guard let me = self else { + return + } + switch result { + case let .success(response): + me.pageInfo = response.pageInfo + me.users = me.users + response.nodes + me.totalCount = response.totalCount + case let .failure(error): + guard case .emptyToken? = (error as? ApiSession.Error) else { + return + } let title = "Access Token Error" let message = "\"Github Personal Access Token\" is Required.\n Please set it in ApiSession.extension.swift!" - let errorMessage = ErrorMessage(title: title, message: message) - me.delegate?.searchModel(me, didRecieve: errorMessage) + _errorMessage.send(ErrorMessage(title: title, message: message)) } } - - me.isFetchingUsers = false - me.cancellable = nil - } + .store(in: &cancellable) } func fetchUsers(withQuery query: String) { - debounce { [weak self] in - guard let me = self else { - return - } + _feachUsersWithQuery.send(query) + } - let oldValue = me.query - me.query = query - if query != oldValue { - me.users.removeAll() - me.pageInfo = nil - me.totalCount = 0 - } - me.cancellable?.cancel() - me.cancellable = nil - me.fetchUsers() - } + func fetchUsers() { + _fetchUsers.send() } } diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift index 4f172d3..3b2ada3 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift @@ -6,72 +6,65 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit +import Combine import GithubKit +import UIKit final class FavoriteViewController: UIViewController { @IBOutlet private(set) weak var tableView: UITableView! - - let favoriteModel: FavoriteModelType - init(favoriteModel: FavoriteModelType) { - self.favoriteModel = favoriteModel + let viewModel: FavoriteViewModelType + let dataSource: FavoriteViewDataSource + + private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType + private var cancellables = Set() + + init( + viewModel: FavoriteViewModelType, + makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType + ) { + self.makeRepositoryViewModel = makeRepositoryViewModel + self.viewModel = viewModel + self.dataSource = FavoriteViewDataSource(viewModel: viewModel) super.init(nibName: FavoriteViewController.className, bundle: nil) } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() title = "On Memory Favorite" - - favoriteModel.delegate = self - configure(with: tableView) - } - - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(RepositoryViewCell.self) - } - - private func showRepository(with repository: Repository) { - let vc = RepositoryViewController(repository: repository, favoriteModel: favoriteModel) - navigationController?.pushViewController(vc, animated: true) - } -} -extension FavoriteViewController: FavoriteModelDelegate { - func favoriteDidChange() { - tableView.reloadData() - } -} + dataSource.configure(with: tableView) -extension FavoriteViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return favoriteModel.favorites.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - cell.configure(with: favoriteModel.favorites[indexPath.row]) - return cell + viewModel.output.selectedRepository + .receive(on: DispatchQueue.main) + .sink(receiveValue: showRepository) + .store(in: &cancellables) + + viewModel.output.relaodData + .receive(on: DispatchQueue.main) + .sink(receiveValue: reloadData) + .store(in: &cancellables) } -} -extension FavoriteViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: false) - - let repository = favoriteModel.favorites[indexPath.row] - showRepository(with: repository) + private var showRepository: (Repository) -> Void { + { [weak self] repository in + guard let me = self else { + return + } + let vm = me.makeRepositoryViewModel(repository) + let vc = RepositoryViewController(viewModel: vm) + me.navigationController?.pushViewController(vc, animated: true) + } } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return RepositoryViewCell.calculateHeight(with: favoriteModel.favorites[indexPath.row], and: tableView) + + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() + } } } diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift new file mode 100644 index 0000000..cf6b099 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift @@ -0,0 +1,51 @@ +// +// FavoriteViewDataSource.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Foundation +import GithubKit +import UIKit + +final class FavoriteViewDataSource: NSObject { + private let viewModel: FavoriteViewModelType + + init(viewModel: FavoriteViewModelType) { + self.viewModel = viewModel + } + + func configure(with tableView: UITableView) { + tableView.dataSource = self + tableView.delegate = self + + tableView.register(RepositoryViewCell.self) + } +} + +extension FavoriteViewDataSource: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.output.favorites.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) + let repository = viewModel.output.favorites[indexPath.row] + cell.configure(with: repository) + return cell + } +} + +extension FavoriteViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + viewModel.input.selectedIndexPath(indexPath) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let repository = viewModel.output.favorites[indexPath.row] + return RepositoryViewCell.calculateHeight(with: repository, and: tableView) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewModel.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewModel.swift new file mode 100644 index 0000000..8d74356 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewModel.swift @@ -0,0 +1,77 @@ +// +// FavoriteViewModel.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Combine +import Foundation +import GithubKit + +protocol FavoriteViewModelType: AnyObject { + var output: FavoriteViewModel.Output { get } + var input: FavoriteViewModel.Input { get } +} + +final class FavoriteViewModel: FavoriteViewModelType { + let output: Output + let input: Input + + var favorites: [Repository] { + favoriteModel.favorites + } + + private let favoriteModel: FavoriteModelType + private var cancellable = Set() + + init( + favoriteModel: FavoriteModelType + ) { + self.favoriteModel = favoriteModel + let _selectedIndexPath = PassthroughSubject() + let _selectedRepository = PassthroughSubject() + + self.output = Output( + favorites: favoriteModel.favorites, + relaodData: favoriteModel.favoritePublisher.map { _ in }.eraseToAnyPublisher(), + selectedRepository: _selectedRepository.eraseToAnyPublisher() + ) + + self.input = Input(selectedIndexPath: _selectedIndexPath.send) + + _selectedIndexPath + .map { favoriteModel.favorites[$0.row] } + .sink { + _selectedRepository.send($0) + } + .store(in: &cancellable) + + favoriteModel.favoritePublisher + .assign(to: \.favorites, on: output) + .store(in: &cancellable) + } +} + +extension FavoriteViewModel { + struct Input { + let selectedIndexPath: (IndexPath) -> Void + } + + final class Output { + @Published + fileprivate(set) var favorites: [Repository] + let relaodData: AnyPublisher + let selectedRepository: AnyPublisher + init( + favorites: [Repository], + relaodData: AnyPublisher, + selectedRepository: AnyPublisher + ) { + self.favorites = favorites + self.relaodData = relaodData + self.selectedRepository = selectedRepository + } + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift index 5764c9c..097650e 100644 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift @@ -6,44 +6,40 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit -import SafariServices +import Combine import GithubKit +import SafariServices +import UIKit final class RepositoryViewController: SFSafariViewController { - private(set) lazy var favoriteButtonItem: UIBarButtonItem = { - let favorites = self.favoriteModel.favorites - let title = favorites.contains(where: { $0.url == self.repository.url }) ? "Remove" : "Add" - return UIBarButtonItem(title: title, - style: .plain, - target: self, - action: #selector(RepositoryViewController.favoriteButtonTap(_:))) - }() - - let repository: Repository - let favoriteModel: FavoriteModelType - - init(repository: Repository, favoriteModel: FavoriteModelType) { - self.repository = repository - self.favoriteModel = favoriteModel + private var cancellables = Set() + private let viewModel: RepositoryViewModelType - super.init(url: repository.url, configuration: .init()) + init(viewModel: RepositoryViewModelType) { + self.viewModel = viewModel + super.init(url: viewModel.output.url, configuration: .init()) hidesBottomBarWhenPushed = true } - + override func viewDidLoad() { super.viewDidLoad() - + + let favoriteButtonItem = UIBarButtonItem( + title: nil, + style: .plain, + target: self, + action: #selector(self.favoriteButtonTap(_:)) + ) navigationItem.rightBarButtonItem = favoriteButtonItem + + viewModel.output.favoriteButtonTitle + .map(Optional.some) + .receive(on: DispatchQueue.main) + .assign(to: \.title, on: favoriteButtonItem) + .store(in: &cancellables) } - - @objc private func favoriteButtonTap(_ sender: UIBarButtonItem) { - if favoriteModel.favorites.first(where: { $0.url == repository.url }) == nil { - favoriteModel.addFavorite(repository) - favoriteButtonItem.title = "Remove" - } else { - favoriteModel.removeFavorite(repository) - favoriteButtonItem.title = "Add" - } + + @objc private func favoriteButtonTap(_: UIBarButtonItem) { + viewModel.input.favoriteButtonTap() } } diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewModel.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewModel.swift new file mode 100644 index 0000000..150f6e0 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewModel.swift @@ -0,0 +1,73 @@ +// +// RepositoryViewModel.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/11. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Combine +import Foundation +import GithubKit + +protocol RepositoryViewModelType: AnyObject { + var input: RepositoryViewModel.Input { get } + var output: RepositoryViewModel.Output { get } +} + +final class RepositoryViewModel: RepositoryViewModelType { + let input: Input + let output: Output + + private var cancellables = Set() + + init( + repository: Repository, + favoritesModel: FavoriteModelType + ) { + let favoriteButtonTitle = favoritesModel.contains(repository) + .map { $0 ? "Remove" : "Add" } + .eraseToAnyPublisher() + + self.output = Output( + url: repository.url, + favoriteButtonTitle: favoriteButtonTitle + ) + + let favoriteButtonTap = PassthroughSubject() + self.input = Input(favoriteButtonTap: favoriteButtonTap.send) + + favoriteButtonTap + .map { _ in + favoritesModel.contains(repository).prefix(1) + } + .switchToLatest() + .sink { contains in + if contains { + favoritesModel.removeFavorite(repository) + } else { + favoritesModel.addFavorite(repository) + } + } + .store(in: &cancellables) + } +} + +extension RepositoryViewModel { + struct Input { + let favoriteButtonTap: () -> Void + } + + final class Output { + @Published + fileprivate(set) var url: URL + let favoriteButtonTitle: AnyPublisher + init( + url: URL, + favoriteButtonTitle: AnyPublisher + ) { + self.url = url + self.favoriteButtonTitle = favoriteButtonTitle + } + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift index cc8ccdb..7a76076 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift @@ -11,7 +11,7 @@ import GithubKit import UIKit final class SearchViewController: UIViewController { - + @IBOutlet private(set) weak var totalCountLabel: UILabel! @IBOutlet private(set) weak var tableView: UITableView! @IBOutlet private(set) weak var tableViewBottomConstraint: NSLayoutConstraint! @@ -19,27 +19,22 @@ final class SearchViewController: UIViewController { let searchBar = UISearchBar(frame: .zero) let loadingView = LoadingView() - private var cancelllables = Set() - private var isReachedBottom: Bool = false { - didSet { - if isReachedBottom && isReachedBottom != oldValue { - searchModel.fetchUsers() - } - } - } + let viewModel: SearchViewModelType + let dataSource: SearchViewDataSource - let searchModel: SearchModelType - private let makeFavoriteModel: () -> FavoriteModelType - private let makeRepositoryModel: (User) -> RepositoryModelType + private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType + private let makeUserRepositoryViewModel: (User) -> UserRepositoryViewModelType + private var cancellables = Set() init( - searchModel: SearchModelType, - makeFavoriteModel: @escaping () -> FavoriteModelType, - makeRepositoryModel: @escaping (User) -> RepositoryModelType + viewModel: SearchViewModelType, + makeUserRepositoryViewModel: @escaping (User) -> UserRepositoryViewModelType, + makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType ) { - self.searchModel = searchModel - self.makeFavoriteModel = makeFavoriteModel - self.makeRepositoryModel = makeRepositoryModel + self.makeRepositoryViewModel = makeRepositoryViewModel + self.makeUserRepositoryViewModel = makeUserRepositoryViewModel + self.viewModel = viewModel + self.dataSource = SearchViewDataSource(viewModel: viewModel) super.init(nibName: SearchViewController.className, bundle: nil) } @@ -51,174 +46,153 @@ final class SearchViewController: UIViewController { super.viewDidLoad() navigationItem.titleView = searchBar - searchBar.delegate = self searchBar.placeholder = "Input user name" - - configure(with: tableView) + searchBar.delegate = self + + dataSource.configure(with: tableView) + + // observe viewModel + viewModel.output.accessTokenAlert + .receive(on: DispatchQueue.main) + .sink(receiveValue: showAccessTokenAlert) + .store(in: &cancellables) + + viewModel.output.keyboardWillShow + .receive(on: DispatchQueue.main) + .sink(receiveValue: keyboardWillShow) + .store(in: &cancellables) + + viewModel.output.keyboardWillHide + .receive(on: DispatchQueue.main) + .sink(receiveValue: keyboardWillHide) + .store(in: &cancellables) + + viewModel.output.countString + .map(Optional.some) + .receive(on: DispatchQueue.main) + .assign(to: \.text, on: totalCountLabel) + .store(in: &cancellables) + + viewModel.output.reloadData + .receive(on: DispatchQueue.main) + .sink(receiveValue: reloadData) + .store(in: &cancellables) + + viewModel.output.selectedUser + .receive(on: DispatchQueue.main) + .sink(receiveValue: showUserRepository) + .store(in: &cancellables) - searchModel.delegate = self + viewModel.output.updateLoadingView + .receive(on: DispatchQueue.main) + .sink(receiveValue: updateLoadingView) + .store(in: &cancellables) } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - observeKeyboard() + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + viewModel.input.viewDidAppear() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + viewModel.input.viewDidDisappear() } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if searchBar.isFirstResponder { - searchBar.resignFirstResponder() - } - cancelllables.removeAll() - } - - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(UserViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) - } - - private func observeKeyboard() { - NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) - .sink { [weak self] notification in - guard let info = UIKeyboardInfo(notification: notification) else { - return - } - self?.view.layoutIfNeeded() - let extra = self?.tabBarController?.tabBar.bounds.height ?? 0 - self?.tableViewBottomConstraint.constant = info.frame.size.height - extra - UIView.animate(withDuration: info.animationDuration, delay: 0, options: info.animationCurve, animations: { - self?.view.layoutIfNeeded() - }, completion: nil) - } - .store(in: &cancelllables) - - NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) - .sink { [weak self] notification in - guard let info = UIKeyboardInfo(notification: notification) else { - return - } - self?.view.layoutIfNeeded() - self?.tableViewBottomConstraint.constant = 0 - UIView.animate(withDuration: info.animationDuration, delay: 0, options: info.animationCurve, animations: { - self?.view.layoutIfNeeded() - }, completion: nil) + searchBar.resignFirstResponder() + } + + private var showAccessTokenAlert: (ErrorMessage) -> Void { + { [weak self] error in + guard let me = self else { + return } - .store(in: &cancelllables) + let alert = UIAlertController(title: error.title, message: error.message, preferredStyle: .alert) + me.present(alert, animated: false, completion: nil) + } } - - private func showUserRepository(with user: User) { - let repositoryModel = makeRepositoryModel(user) - let vc = UserRepositoryViewController( - repositoryModel: repositoryModel, - makeFavoriteModel: makeFavoriteModel - ) - navigationController?.pushViewController(vc, animated: true) + + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() + } } -} -extension SearchViewController: SearchModelDelegate { - func searchModel(_ searchModel: SearchModel, didRecieve errorMessage: ErrorMessage) { - DispatchQueue.main.async { - let alert = UIAlertController(title: errorMessage.title, - message: errorMessage.message, - preferredStyle: .alert) - self.present(alert, animated: false, completion: nil) + private var keyboardWillShow: (UIKeyboardInfo) -> Void { + { [weak self] keyboardInfo in + guard let me = self else { + return + } + me.view.layoutIfNeeded() + let extra = me.tabBarController?.tabBar.bounds.height ?? 0 + me.tableViewBottomConstraint.constant = keyboardInfo.frame.size.height - extra + UIView.animate(withDuration: keyboardInfo.animationDuration, + delay: 0, + options: keyboardInfo.animationCurve, + animations: { me.view.layoutIfNeeded() }, + completion: nil) } } - func searchModel(_ searchModel: SearchModel, didChange isFetchingUsers: Bool) { - DispatchQueue.main.async { - self.tableView.reloadData() + private var keyboardWillHide: (UIKeyboardInfo) -> Void { + { [weak self] keyboardInfo in + guard let me = self else { + return + } + me.view.layoutIfNeeded() + me.tableViewBottomConstraint.constant = 0 + UIView.animate(withDuration: keyboardInfo.animationDuration, + delay: 0, + options: keyboardInfo.animationCurve, + animations: { me.view.layoutIfNeeded() }, + completion: nil) } } - func searchModel(_ searchModel: SearchModel, didChange users: [User]) { - let totalCount = searchModel.totalCount - DispatchQueue.main.async { - self.totalCountLabel.text = "\(users.count) / \(totalCount)" - self.tableView.reloadData() + private var showUserRepository: (User) -> Void { + { [weak self] user in + guard let me = self else { + return + } + let vm = me.makeUserRepositoryViewModel(user) + let vc = UserRepositoryViewController( + viewModel: vm, + makeRepositoryViewModel: me.makeRepositoryViewModel + ) + me.navigationController?.pushViewController(vc, animated: true) } } - func searchModel(_ searchModel: SearchModel, didChange totalCount: Int) { - let users = searchModel.users - DispatchQueue.main.async { - self.totalCountLabel.text = "\(users.count) / \(totalCount)" + private var updateLoadingView: (UIView, Bool) -> Void { + { [weak self] view, isLoading in + guard let me = self else { + return + } + me.loadingView.removeFromSuperview() + me.loadingView.isLoading = isLoading + me.loadingView.add(to: view) } } } extension SearchViewController: UISearchBarDelegate { - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - searchBar.showsCancelButton = false + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.showsCancelButton = true } - + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { searchBar.resignFirstResponder() searchBar.showsCancelButton = false } - - func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - searchBar.showsCancelButton = true - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - searchModel.fetchUsers(withQuery: searchText) - } -} -extension SearchViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return searchModel.users.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeue(UserViewCell.self, for: indexPath) - cell.configure(with: searchModel.users[indexPath.row]) - return cell - } - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return nil - } - - func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { - return nil - } - loadingView.removeFromSuperview() - loadingView.isLoading = searchModel.isFetchingUsers - loadingView.add(to: view) - return view + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + searchBar.showsCancelButton = false } -} -extension SearchViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: false) - - let user = searchModel.users[indexPath.row] - showUserRepository(with: user) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UserViewCell.calculateHeight(with: searchModel.users[indexPath.row], and: tableView) - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return .leastNormalMagnitude - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return searchModel.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) - isReachedBottom = maxScrollDistance <= scrollView.contentOffset.y + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + viewModel.input.searchText(searchBar.text) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift new file mode 100644 index 0000000..654e2b2 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift @@ -0,0 +1,79 @@ +// +// SearchViewDataSource.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Foundation +import GithubKit +import UIKit + +final class SearchViewDataSource: NSObject { + + private let viewModel: SearchViewModelType + + init(viewModel: SearchViewModelType) { + self.viewModel = viewModel + } + + func configure(with tableView: UITableView) { + tableView.dataSource = self + tableView.delegate = self + + tableView.register(UserViewCell.self) + tableView.register(UITableViewHeaderFooterView.self, + forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) + } +} + +extension SearchViewDataSource: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.output.users.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(UserViewCell.self, for: indexPath) + let user = viewModel.output.users[indexPath.row] + cell.configure(with: user) + return cell + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return nil + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { + return nil + } + viewModel.input.headerFooterView(view) + return view + } +} + +extension SearchViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + viewModel.input.selectedIndexPath(indexPath) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let user = viewModel.output.users[indexPath.row] + return UserViewCell.calculateHeight(with: user, and: tableView) + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return .leastNormalMagnitude + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return viewModel.output.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) + viewModel.input.isReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift new file mode 100644 index 0000000..2d251d5 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift @@ -0,0 +1,175 @@ +// +// SearchViewModel.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Combine +import Foundation +import GithubKit +import UIKit + +protocol SearchViewModelType: AnyObject { + var input: SearchViewModel.Input { get } + var output: SearchViewModel.Output { get } +} + +final class SearchViewModel: SearchViewModelType{ + let output: Output + let input: Input + + private var cancellables = Set() + + init( + searchModel: SearchModelType, + notificationCenter: NotificationCenter + ) { + let viewDidAppear = PassthroughSubject() + let viewDidDisappear = PassthroughSubject() + let searchText = PassthroughSubject() + let isReachedBottom = PassthroughSubject() + let selectedIndexPath = PassthroughSubject() + let headerFooterView = PassthroughSubject() + + 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 { searchModel.users[$0.row] } + .eraseToAnyPublisher() + + let updateLoadingView = headerFooterView + .combineLatest(searchModel.isFetchingUsersPublisher) + .eraseToAnyPublisher() + + let countString = searchModel.totalCountPublisher + .combineLatest(searchModel.usersPublisher) + .map { "\($1.count) / \($0)" } + .eraseToAnyPublisher() + + let reloadData = searchModel.usersPublisher.map { _ in } + .merge(with: searchModel.totalCountPublisher.map { _ in }, + searchModel.isFetchingUsersPublisher.map { _ in }) + .eraseToAnyPublisher() + + // keyboard notification + let isViewAppearing = viewDidAppear.map { true } + .merge(with: viewDidDisappear.map { false }) + .eraseToAnyPublisher() + + let makeKeyboardObservable: (Notification.Name, Bool) -> AnyPublisher = { name, isViewAppearing in + guard isViewAppearing else { + return Empty().eraseToAnyPublisher() + } + return notificationCenter.publisher(for: name) + .flatMap { notification -> AnyPublisher in + guard let info = UIKeyboardInfo(notification: notification) else { + return Empty().eraseToAnyPublisher() + } + return Just(info).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + + let keyboardWillShow = isViewAppearing + .map { makeKeyboardObservable(UIResponder.keyboardWillShowNotification, $0) } + .switchToLatest() + .eraseToAnyPublisher() + + let keyboardWillHide = isViewAppearing + .map { makeKeyboardObservable(UIResponder.keyboardWillHideNotification, $0) } + .switchToLatest() + .eraseToAnyPublisher() + + 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 { + searchModel.fetchUsers(withQuery: $0) + } + .store(in: &cancellables) + + isReachedBottom + .removeDuplicates() + .filter { $0 } + .sink { _ in + 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) + } +} + +extension SearchViewModel { + struct Input { + let viewDidAppear: () -> Void + let viewDidDisappear: () -> Void + let searchText: (String?) -> Void + let isReachedBottom: (Bool) -> Void + let selectedIndexPath: (IndexPath) -> Void + let headerFooterView: (UIView) -> Void + } + + final class Output { + @Published + fileprivate(set) var users: [User] + @Published + fileprivate(set) var isFetchingUsers: Bool + let accessTokenAlert: AnyPublisher + let updateLoadingView: AnyPublisher<(UIView, Bool), Never> + let selectedUser: AnyPublisher + let keyboardWillShow: AnyPublisher + let keyboardWillHide: AnyPublisher + let countString: AnyPublisher + let reloadData: AnyPublisher + init( + users: [User], + isFetchingUsers: Bool, + accessTokenAlert: AnyPublisher, + updateLoadingView: AnyPublisher<(UIView, Bool), Never>, + selectedUser: AnyPublisher, + keyboardWillShow: AnyPublisher, + keyboardWillHide: AnyPublisher, + countString: AnyPublisher, + reloadData: AnyPublisher + ) { + 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 + } + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift index fa5880a..1bb7b2d 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift @@ -6,139 +6,91 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit +import Combine import GithubKit +import UIKit final class UserRepositoryViewController: UIViewController { - + @IBOutlet private(set) weak var tableView: UITableView! @IBOutlet private(set) weak var totalCountLabel: UILabel! let loadingView = LoadingView() - - private var isReachedBottom: Bool = false { - didSet { - if isReachedBottom && isReachedBottom != oldValue { - repositoryModel.fetchRepositories() - } - } - } - let repositoryModel: RepositoryModelType - private let makeFavoriteModel: () -> FavoriteModelType - + let viewModel: UserRepositoryViewModelType + let dataSource: UserRepositoryViewDataSource + + private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType + private var cacellables = Set() + init( - repositoryModel: RepositoryModelType, - makeFavoriteModel: @escaping () -> FavoriteModelType + viewModel: UserRepositoryViewModelType, + makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType ) { - self.repositoryModel = repositoryModel - self.makeFavoriteModel = makeFavoriteModel - + self.makeRepositoryViewModel = makeRepositoryViewModel + self.viewModel = viewModel + self.dataSource = UserRepositoryViewDataSource(viewModel: viewModel) super.init(nibName: UserRepositoryViewController.className, bundle: nil) hidesBottomBarWhenPushed = true } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - - title = "\(repositoryModel.user.login)'s Repositories" - - configure(with: tableView) - repositoryModel.delegate = self - repositoryModel.fetchRepositories() - } - - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(RepositoryViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) - } - - private func showRepository(with repository: Repository) { - let favoriteModel = makeFavoriteModel() - let vc = RepositoryViewController(repository: repository, favoriteModel: favoriteModel) - navigationController?.pushViewController(vc, animated: true) - } -} + title = viewModel.output.title -extension UserRepositoryViewController: RepositoryModelDelegate { - func repositoryModel(_ repositoryModel: RepositoryModel, didChange isFetchingRepositories: Bool) { - DispatchQueue.main.async { - self.tableView.reloadData() - } - } + dataSource.configure(with: tableView) - func repositoryModel(_ repositoryModel: RepositoryModel, didChange repositories: [Repository]) { - let totalCount = repositoryModel.totalCount - DispatchQueue.main.async { - self.totalCountLabel.text = "\(repositories.count) / \(totalCount)" - self.tableView.reloadData() - } + viewModel.output.showRepository + .receive(on: DispatchQueue.main) + .sink(receiveValue: showRepository) + .store(in: &cacellables) + + viewModel.output.reloadData + .receive(on: DispatchQueue.main) + .sink(receiveValue: reloadData) + .store(in: &cacellables) + + viewModel.output.countString + .map(Optional.some) + .receive(on: DispatchQueue.main) + .assign(to: \.text, on: totalCountLabel) + .store(in: &cacellables) + + viewModel.output.updateLoadingView + .receive(on: DispatchQueue.main) + .sink(receiveValue: updateLoadingView) + .store(in: &cacellables) + + viewModel.input.fetchRepositories() } - func repositoryModel(_ repositoryModel: RepositoryModel, didChange totalCount: Int) { - let repositories = repositoryModel.repositories - DispatchQueue.main.async { - self.totalCountLabel.text = "\(repositories.count) / \(totalCount)" + private var showRepository: (Repository) -> Void { + { [weak self] repository in + guard let me = self else { + return + } + let vm = me.makeRepositoryViewModel(repository) + let vc = RepositoryViewController(viewModel: vm) + me.navigationController?.pushViewController(vc, animated: true) } } -} -extension UserRepositoryViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return repositoryModel.repositories.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - cell.configure(with: repositoryModel.repositories[indexPath.row]) - return cell - } - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return nil - } - - func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { - return nil + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() } - loadingView.removeFromSuperview() - loadingView.isLoading = repositoryModel.isFetchingRepositories - loadingView.add(to: view) - return view } -} -extension UserRepositoryViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: false) - - let repository = repositoryModel.repositories[indexPath.row] - showRepository(with: repository) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return RepositoryViewCell.calculateHeight(with: repositoryModel.repositories[indexPath.row], and: tableView) - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return .leastNormalMagnitude - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return repositoryModel.isFetchingRepositories ? LoadingView.defaultHeight : .leastNormalMagnitude - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) - isReachedBottom = maxScrollDistance <= scrollView.contentOffset.y + private var updateLoadingView: (UIView, Bool) -> Void { + { [weak self] view, isLoading in + self?.loadingView.removeFromSuperview() + self?.loadingView.isLoading = isLoading + self?.loadingView.add(to: view) + } } } diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift new file mode 100644 index 0000000..34760a6 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift @@ -0,0 +1,79 @@ +// +// UserRepositoryViewDataSource.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Foundation +import GithubKit +import UIKit + +final class UserRepositoryViewDataSource: NSObject { + + private let viewModel: UserRepositoryViewModelType + + init(viewModel: UserRepositoryViewModelType) { + self.viewModel = viewModel + } + + func configure(with tableView: UITableView) { + tableView.dataSource = self + tableView.delegate = self + + tableView.register(RepositoryViewCell.self) + tableView.register(UITableViewHeaderFooterView.self, + forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) + } +} + +extension UserRepositoryViewDataSource: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.output.repositories.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) + let repository = viewModel.output.repositories[indexPath.row] + cell.configure(with: repository) + return cell + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + return nil + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { + return nil + } + viewModel.input.headerFooterView(view) + return view + } +} + +extension UserRepositoryViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + viewModel.input.selectedIndexPath(indexPath) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let repository = viewModel.output.repositories[indexPath.row] + return RepositoryViewCell.calculateHeight(with: repository, and: tableView) + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return .leastNormalMagnitude + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + return viewModel.output.isFetchingRepositories ? LoadingView.defaultHeight : .leastNormalMagnitude + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) + viewModel.input.isReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewModel.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewModel.swift new file mode 100644 index 0000000..b440743 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewModel.swift @@ -0,0 +1,129 @@ +// +// UserRepositoryViewModel.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Combine +import Foundation +import GithubKit +import UIKit + +protocol UserRepositoryViewModelType: AnyObject { + var input: UserRepositoryViewModel.Input { get } + var output: UserRepositoryViewModel.Output { get } +} + +final class UserRepositoryViewModel: UserRepositoryViewModelType { + let input: Input + let output: Output + + private var cancellables = Set() + + init( + user: User, + favoriteModel: FavoriteModelType, + repositoryModel: RepositoryModelType + ) { + let _fetchRepositories = PassthroughSubject() + let _selectedIndexPath = PassthroughSubject() + let _isReachedBottom = PassthroughSubject() + let _headerFooterView = PassthroughSubject() + + self.input = Input( + fetchRepositories: _fetchRepositories.send, + selectedIndexPath: _selectedIndexPath.send, + isReachedBottom: _isReachedBottom.send, + headerFooterView: _headerFooterView.send + ) + + do { + let updateLoadingView = _headerFooterView + .combineLatest(repositoryModel.isFetchingRepositoriesPublisher) + .eraseToAnyPublisher() + + let showRepository = _selectedIndexPath + .map { repositoryModel.repositories[$0.row] } + .eraseToAnyPublisher() + + let countString = repositoryModel.totalCountPublisher + .combineLatest(repositoryModel.repositoriesPublisher) + .map { "\($1.count) / \($0)" } + .eraseToAnyPublisher() + + let reloadData = repositoryModel.repositoriesPublisher.map { _ in } + .merge(with: repositoryModel.totalCountPublisher.map { _ in }, + repositoryModel.isFetchingRepositoriesPublisher.map { _ in }) + .eraseToAnyPublisher() + + self.output = Output( + title: "\(user.login)'s Repositories", + repositories: repositoryModel.repositories, + isFetchingRepositories: repositoryModel.isFetchingRepositories, + updateLoadingView: updateLoadingView, + showRepository: showRepository, + countString: countString, + reloadData: reloadData + ) + } + + _isReachedBottom + .removeDuplicates() + .filter { $0 } + .sink { _ in + repositoryModel.fetchRepositories() + } + .store(in: &cancellables) + + repositoryModel.repositoriesPublisher + .assign(to: \.repositories, on: output) + .store(in: &cancellables) + + repositoryModel.isFetchingRepositoriesPublisher + .assign(to: \.isFetchingRepositories, on: output) + .store(in: &cancellables) + + repositoryModel.fetchRepositories() + } +} + +extension UserRepositoryViewModel { + struct Input { + let fetchRepositories: () -> Void + let selectedIndexPath: (IndexPath) -> Void + let isReachedBottom: (Bool) -> Void + let headerFooterView: (UIView) -> Void + } + + final class Output { + @Published + fileprivate(set) var title: String + @Published + fileprivate(set) var repositories: [Repository] + @Published + fileprivate(set) var isFetchingRepositories: Bool + let updateLoadingView: AnyPublisher<(UIView, Bool), Never> + let showRepository: AnyPublisher + let countString: AnyPublisher + let reloadData: AnyPublisher + init( + title: String, + repositories: [Repository], + isFetchingRepositories: Bool, + updateLoadingView: AnyPublisher<(UIView, Bool), Never>, + showRepository: AnyPublisher, + countString: AnyPublisher, + reloadData: AnyPublisher + ) { + self.title = title + self.repositories = repositories + self.isFetchingRepositories = isFetchingRepositories + self.updateLoadingView = updateLoadingView + self.showRepository = showRepository + self.countString = countString + self.reloadData = reloadData + } + } +}