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 index 6f9df37..f248450 100644 Binary files a/Images/favorite.png and b/Images/favorite.png differ diff --git a/Images/repository.png b/Images/repository.png index dbcc2d4..498a4fc 100644 Binary files a/Images/repository.png and b/Images/repository.png differ diff --git a/Images/search.png b/Images/search.png index 13250a9..c8c6f37 100644 Binary files a/Images/search.png and b/Images/search.png differ diff --git a/Images/structure.png b/Images/structure.png index 00a806c..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 index 5e26f6d..06caad4 100644 Binary files a/Images/user_repository.png and b/Images/user_repository.png differ diff --git a/README.md b/README.md index 27ea991..e92a566 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# iOSDesignPatternSamples (MVP) +# iOSDesignPatternSamples (MVVM) -This is Github user search demo app that made with MVP design pattern. +This is Github user search demo app that made with MVVM design pattern. ## Application Structure @@ -13,9 +13,7 @@ Search Github user and show user result list ![](./Images/search.png) -- [SearchView](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift) -- [SearchPresenter](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift) -- [SearchViewPresenter](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift) <- Adapt SearchPresenter +- [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) @@ -23,9 +21,7 @@ Show local on memory favorite repositories ![](./Images/favorite.png) -- [FavoriteView](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift) -- [FavoritePresenter](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift) -- [FavoriteViewPresenter](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift) <- Adapt FavoritePresenter +- [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) @@ -33,9 +29,7 @@ Show Github user's repositories ![](./Images/user_repository.png) -- [UserRepositoryView](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift) -- [UserRepositoryPresenter](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift) -- [UserRepositoryViewPresenter](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift) <- Adapt UserRepositoryPresenter +- [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) @@ -43,9 +37,7 @@ Show a repository and add / remove local on memory favorites ![](./Images/repository.png) -- [RepositoryView](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift) -- [RepositoryPresenter](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift) -- [RepositoryViewPresenter](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift) <- Adapt RepositoryPresenter +- [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 52fc4c8..d953d3b 100644 --- a/iOSDesignPatternSamples.xcodeproj/project.pbxproj +++ b/iOSDesignPatternSamples.xcodeproj/project.pbxproj @@ -10,20 +10,20 @@ 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 */; }; - 1E393F73F3CAF019A44B3983 /* UserRepositoryViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 520A5CA79771517368B8C2A3 /* UserRepositoryViewPresenter.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 */; }; - 518405AF0C382898D4127681 /* FavoriteViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6911B8218A916C540302FD01 /* FavoriteViewPresenter.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 */; }; - 6A27621B3A685D90F105BD65 /* SearchViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6BDED3310BF09C5AD910C87 /* SearchViewPresenter.swift */; }; 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 */; }; - 94966F6B922927E4091D087E /* RepositoryViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DEBBF60E2AEFF712AA52C37 /* RepositoryViewPresenter.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 */; }; @@ -42,24 +42,24 @@ 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 = ""; }; - 520A5CA79771517368B8C2A3 /* UserRepositoryViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryViewPresenter.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 = ""; }; - 6911B8218A916C540302FD01 /* FavoriteViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewPresenter.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 = ""; }; - 9DEBBF60E2AEFF712AA52C37 /* RepositoryViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryViewPresenter.swift; sourceTree = ""; }; 9E2B00ED49EA7B8CD6C7C4F5 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 9ED48619F4A0CDE4B2D46702 /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = ""; }; - B6BDED3310BF09C5AD910C87 /* SearchViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewPresenter.swift; sourceTree = ""; }; B7D7B4F908B5F135EC242E10 /* RepositoryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryModel.swift; sourceTree = ""; }; C86990A891A2828A45CDAF7A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FavoriteViewController.xib; sourceTree = ""; }; @@ -94,7 +94,7 @@ 6AE9204A886B2448F36B9293 /* UserRepositoryViewController.swift */, 1F905F35ABF1B1579FEE2B5A /* UserRepositoryViewController.xib */, 969B79174D1421B0FCFE7154 /* UserRepositoryViewDataSource.swift */, - 520A5CA79771517368B8C2A3 /* UserRepositoryViewPresenter.swift */, + 85BA8AC18BFBE203EBECBDF0 /* UserRepositoryViewModel.swift */, ); path = UserRepository; sourceTree = ""; @@ -105,7 +105,7 @@ 208BE35457B09256BF71DAD1 /* SearchViewController.swift */, 5EDBADE530FE153A9651F109 /* SearchViewController.xib */, 7E02ECB42B97D30045AF6AB2 /* SearchViewDataSource.swift */, - B6BDED3310BF09C5AD910C87 /* SearchViewPresenter.swift */, + 3B8FB5FFB17FCEE46B7E72E4 /* SearchViewModel.swift */, ); path = Search; sourceTree = ""; @@ -130,7 +130,7 @@ isa = PBXGroup; children = ( 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */, - 9DEBBF60E2AEFF712AA52C37 /* RepositoryViewPresenter.swift */, + 42AAD61E2A4041414E741BC4 /* RepositoryViewModel.swift */, ); path = Repository; sourceTree = ""; @@ -172,7 +172,7 @@ 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */, E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */, 5DE1FF29F81CED271FD929C2 /* FavoriteViewDataSource.swift */, - 6911B8218A916C540302FD01 /* FavoriteViewPresenter.swift */, + 3A35DE51E635D96C6FB469DF /* FavoriteViewModel.swift */, ); path = Favorite; sourceTree = ""; @@ -305,20 +305,20 @@ D84737DB64A88B1888B8465D /* FavoriteModel.swift in Sources */, 6EC13FBC2AE1E8DBEBE88804 /* FavoriteViewController.swift in Sources */, 82992D62FE8ED0043356C237 /* FavoriteViewDataSource.swift in Sources */, - 518405AF0C382898D4127681 /* FavoriteViewPresenter.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 */, - 94966F6B922927E4091D087E /* RepositoryViewPresenter.swift in Sources */, + 450252B4BA07F8D498E608EF /* RepositoryViewModel.swift in Sources */, 072EB0FFF0B72F37C8CF669A /* SearchModel.swift in Sources */, 4EF255BBD109181D8AD5058F /* SearchViewController.swift in Sources */, 7F3AC0844FF343E754FD4431 /* SearchViewDataSource.swift in Sources */, - 6A27621B3A685D90F105BD65 /* SearchViewPresenter.swift in Sources */, + 651202151F5333A9ABE09B22 /* SearchViewModel.swift in Sources */, 811C3B9B712E3B67E5AD73FD /* UIKeyboardInfo.swift in Sources */, C69B1DDE761E0663FDE1E947 /* UserRepositoryViewController.swift in Sources */, B7B8D28336669C7BF3C29BEC /* UserRepositoryViewDataSource.swift in Sources */, - 1E393F73F3CAF019A44B3983 /* UserRepositoryViewPresenter.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 8828395..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,28 +24,26 @@ class AppDelegate: UIResponder, UIApplicationDelegate { switch value { case let (0, nc as UINavigationController): let searchVC = SearchViewController( - searchPresenter: SearchViewPresenter( - model: 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 ), - mainAsync: { work in DispatchQueue.main.async { work() } }, notificationCenter: .default ), - makeRepositoryPresenter: { [favoriteModel] in - RepositoryViewPresenter( - repository: $0, - favoriteModel: favoriteModel - ) - }, - makeUserRepositoryPresenter: { - UserRepositoryViewPresenter( - model: RepositoryModel( + makeUserRepositoryViewModel: { [favoriteModel] in + UserRepositoryViewModel( + user: $0, + favoriteModel: favoriteModel, + repositoryModel: RepositoryModel( user: $0, sendRequest: ApiSession.shared.send - ), - mainAsync: { work in DispatchQueue.main.async { work() } } + ) + ) + }, + makeRepositoryViewModel: { [favoriteModel] in + RepositoryViewModel( + repository: $0, + favoritesModel: favoriteModel ) } ) @@ -52,12 +51,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { case let (1, nc as UINavigationController): let favoriteVC = FavoriteViewController( - presenter: FavoriteViewPresenter(model: favoriteModel), - makeRepositoryPresenter: { [favoriteModel] in - RepositoryViewPresenter( - repository: $0, - favoriteModel: favoriteModel - ) + viewModel: FavoriteViewModel(favoriteModel: favoriteModel), + makeRepositoryViewModel: { [favoriteModel] in + RepositoryViewModel(repository: $0, favoritesModel: favoriteModel) } ) nc.setViewControllers([favoriteVC], animated: false) @@ -71,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 97e422f..3b2ada3 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift @@ -6,29 +6,26 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit +import Combine import GithubKit +import UIKit -protocol FavoriteView: class { - func reloadData() - func showRepository(with repository: Repository) -} - -final class FavoriteViewController: UIViewController, FavoriteView { +final class FavoriteViewController: UIViewController { @IBOutlet private(set) weak var tableView: UITableView! - let presenter: FavoritePresenter + let viewModel: FavoriteViewModelType let dataSource: FavoriteViewDataSource - private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter + private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType + private var cancellables = Set() init( - presenter: FavoritePresenter, - makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter + viewModel: FavoriteViewModelType, + makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType ) { - self.presenter = presenter - self.dataSource = FavoriteViewDataSource(presenter: presenter) - self.makeRepositoryPresenter = makeRepositoryPresenter + self.makeRepositoryViewModel = makeRepositoryViewModel + self.viewModel = viewModel + self.dataSource = FavoriteViewDataSource(viewModel: viewModel) super.init(nibName: FavoriteViewController.className, bundle: nil) } @@ -41,17 +38,33 @@ final class FavoriteViewController: UIViewController, FavoriteView { title = "On Memory Favorite" - presenter.view = self dataSource.configure(with: tableView) + + 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) } - - func showRepository(with repository: Repository) { - let repositoryPresenter = makeRepositoryPresenter(repository) - let vc = RepositoryViewController(presenter: repositoryPresenter) - navigationController?.pushViewController(vc, animated: true) + + 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 reloadData() { - tableView?.reloadData() + + 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 index 923a57d..cf6b099 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift @@ -7,14 +7,14 @@ // import Foundation -import UIKit import GithubKit +import UIKit final class FavoriteViewDataSource: NSObject { - fileprivate let presenter: FavoritePresenter + private let viewModel: FavoriteViewModelType - init(presenter: FavoritePresenter) { - self.presenter = presenter + init(viewModel: FavoriteViewModelType) { + self.viewModel = viewModel } func configure(with tableView: UITableView) { @@ -27,12 +27,12 @@ final class FavoriteViewDataSource: NSObject { extension FavoriteViewDataSource: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return presenter.numberOfFavorites + return viewModel.output.favorites.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - let repository = presenter.favoriteRepository(at: indexPath.row) + let repository = viewModel.output.favorites[indexPath.row] cell.configure(with: repository) return cell } @@ -41,11 +41,11 @@ extension FavoriteViewDataSource: UITableViewDataSource { extension FavoriteViewDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - presenter.showFavoriteRepository(at: indexPath.row) + viewModel.input.selectedIndexPath(indexPath) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let repository = presenter.favoriteRepository(at: indexPath.row) + 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/Favorite/FavoriteViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift deleted file mode 100644 index 49db248..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// FavoritePresenter.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/10. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Foundation -import GithubKit - -protocol FavoritePresenter: class { - var view: FavoriteView? { get set } - var numberOfFavorites: Int { get } - func favoriteRepository(at index: Int) -> Repository - func showFavoriteRepository(at index: Int) -} - -final class FavoriteViewPresenter: FavoritePresenter { - weak var view: FavoriteView? - - var numberOfFavorites: Int { - return model.favorites.count - } - - private let model: FavoriteModelType - - init(model: FavoriteModelType) { - self.model = model - self.model.delegate = self - } - - func favoriteRepository(at index: Int) -> Repository { - return model.favorites[index] - } - - func showFavoriteRepository(at index: Int) { - let repository = model.favorites[index] - view?.showRepository(with: repository) - } -} - -extension FavoriteViewPresenter: FavoriteModelDelegate { - func favoriteDidChange() { - view?.reloadData() - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift index 038e54a..097650e 100644 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift @@ -6,43 +6,40 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit -import SafariServices +import Combine import GithubKit +import SafariServices +import UIKit -protocol RepositoryView: class { - func updateFavoriteButtonTitle(_ title: String) -} +final class RepositoryViewController: SFSafariViewController { + private var cancellables = Set() + private let viewModel: RepositoryViewModelType -final class RepositoryViewController: SFSafariViewController, RepositoryView { - private(set) lazy var favoriteButtonItem: UIBarButtonItem = { - return UIBarButtonItem(title: self.presenter.favoriteButtonTitle, - style: .plain, - target: self, - action: #selector(RepositoryViewController.favoriteButtonTap(_:))) - }() - private let presenter: RepositoryPresenter - - init(presenter: RepositoryPresenter) { - self.presenter = presenter - super.init(url: presenter.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 - presenter.view = self + viewModel.output.favoriteButtonTitle + .map(Optional.some) + .receive(on: DispatchQueue.main) + .assign(to: \.title, on: favoriteButtonItem) + .store(in: &cancellables) } - - @objc private func favoriteButtonTap(_ sender: UIBarButtonItem) { - presenter.favoriteButtonTap() - } - - func updateFavoriteButtonTitle(_ title: String) { - favoriteButtonItem.title = title + + @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/Repository/RepositoryViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift deleted file mode 100644 index afbd3df..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// RepositoryViewPresenter.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/11. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Foundation -import GithubKit - -protocol RepositoryPresenter: class { - var view: RepositoryView? { get set } - var url: URL { get } - var favoriteButtonTitle: String { get } - func favoriteButtonTap() -} - -final class RepositoryViewPresenter: RepositoryPresenter { - weak var view: RepositoryView? - private let favoriteModel: FavoriteModelType - private let repository: Repository - - var favoriteButtonTitle: String { - return favoriteModel.favorites.contains(repository) ? "Remove" : "Add" - } - - var url: URL { - return repository.url - } - - init( - repository: Repository, - favoriteModel: FavoriteModelType - ) { - self.repository = repository - self.favoriteModel = favoriteModel - } - - func favoriteButtonTap() { - if favoriteModel.favorites.contains(repository) { - favoriteModel.removeFavorite(repository) - view?.updateFavoriteButtonTitle(favoriteButtonTitle) - } else { - favoriteModel.addFavorite(repository) - view?.updateFavoriteButtonTitle(favoriteButtonTitle) - } - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift index 3e93d8a..7a76076 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift @@ -10,17 +10,7 @@ import Combine import GithubKit import UIKit -protocol SearchView: class { - func reloadData() - func keyboardWillShow(with keyboardInfo: UIKeyboardInfo) - func keyboardWillHide(with keyboardInfo: UIKeyboardInfo) - func showUserRepository(with user: User) - func updateTotalCountLabel(_ countText: String) - func updateLoadingView(with view: UIView, isLoading: Bool) - func showEmptyTokenError(errorMessage: ErrorMessage) -} - -final class SearchViewController: UIViewController, SearchView { +final class SearchViewController: UIViewController { @IBOutlet private(set) weak var totalCountLabel: UILabel! @IBOutlet private(set) weak var tableView: UITableView! @@ -29,21 +19,22 @@ final class SearchViewController: UIViewController, SearchView { let searchBar = UISearchBar(frame: .zero) let loadingView = LoadingView() - let searchPresenter: SearchPresenter + let viewModel: SearchViewModelType let dataSource: SearchViewDataSource - private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter - private let makeUserRepositoryPresenter: (User) -> UserRepositoryPresenter + private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType + private let makeUserRepositoryViewModel: (User) -> UserRepositoryViewModelType + private var cancellables = Set() init( - searchPresenter: SearchPresenter, - makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter, - makeUserRepositoryPresenter: @escaping (User) -> UserRepositoryPresenter + viewModel: SearchViewModelType, + makeUserRepositoryViewModel: @escaping (User) -> UserRepositoryViewModelType, + makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType ) { - self.searchPresenter = searchPresenter - self.dataSource = SearchViewDataSource(presenter: searchPresenter) - self.makeRepositoryPresenter = makeRepositoryPresenter - self.makeUserRepositoryPresenter = makeUserRepositoryPresenter + self.makeRepositoryViewModel = makeRepositoryViewModel + self.makeUserRepositoryViewModel = makeUserRepositoryViewModel + self.viewModel = viewModel + self.dataSource = SearchViewDataSource(viewModel: viewModel) super.init(nibName: SearchViewController.className, bundle: nil) } @@ -55,95 +46,153 @@ final class SearchViewController: UIViewController, SearchView { super.viewDidLoad() navigationItem.titleView = searchBar - searchBar.delegate = self searchBar.placeholder = "Input user name" - + searchBar.delegate = self + dataSource.configure(with: tableView) - searchPresenter.view = self + // 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) + + viewModel.output.updateLoadingView + .receive(on: DispatchQueue.main) + .sink(receiveValue: updateLoadingView) + .store(in: &cancellables) } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - searchPresenter.viewWillAppear() + + 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() + searchBar.resignFirstResponder() + } + + private var showAccessTokenAlert: (ErrorMessage) -> Void { + { [weak self] error in + guard let me = self else { + return + } + let alert = UIAlertController(title: error.title, message: error.message, preferredStyle: .alert) + me.present(alert, animated: false, completion: nil) + } + } + + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() + } + } + + 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) + } + } + + 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) + } + } + + 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) + } + } + + 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) } - searchPresenter.viewWillDisappear() - } - - func reloadData() { - tableView.reloadData() - } - - func keyboardWillShow(with keyboardInfo: UIKeyboardInfo) { - view.layoutIfNeeded() - let extra = tabBarController?.tabBar.bounds.height ?? 0 - tableViewBottomConstraint.constant = keyboardInfo.frame.size.height - extra - UIView.animate(withDuration: keyboardInfo.animationDuration, - delay: 0, - options: keyboardInfo.animationCurve, - animations: { self.view.layoutIfNeeded() }, - completion: nil) - } - - func keyboardWillHide(with keyboardInfo: UIKeyboardInfo) { - view.layoutIfNeeded() - tableViewBottomConstraint.constant = 0 - UIView.animate(withDuration: keyboardInfo.animationDuration, - delay: 0, - options: keyboardInfo.animationCurve, - animations: { self.view.layoutIfNeeded() }, - completion: nil) - } - - func showUserRepository(with user: User) { - let presenter = makeUserRepositoryPresenter(user) - let vc = UserRepositoryViewController( - userRepositoryPresenter: presenter, - makeRepositoryPresenter: makeRepositoryPresenter - ) - navigationController?.pushViewController(vc, animated: true) - } - - func updateTotalCountLabel(_ countText: String) { - totalCountLabel.text = countText - } - - func updateLoadingView(with view: UIView, isLoading: Bool) { - loadingView.removeFromSuperview() - loadingView.isLoading = isLoading - loadingView.add(to: view) - } - - func showEmptyTokenError(errorMessage: ErrorMessage) { - let alert = UIAlertController(title: errorMessage.message, - message: errorMessage.message, - preferredStyle: .alert) - present(alert, animated: false, completion: nil) } } 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 searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + searchBar.showsCancelButton = false } - + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - searchPresenter.search(queryIfNeeded: searchText) + viewModel.input.searchText(searchBar.text) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift index b98fb72..654e2b2 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift @@ -7,14 +7,15 @@ // import Foundation -import UIKit import GithubKit +import UIKit final class SearchViewDataSource: NSObject { - fileprivate let presenter: SearchPresenter - - init(presenter: SearchPresenter) { - self.presenter = presenter + + private let viewModel: SearchViewModelType + + init(viewModel: SearchViewModelType) { + self.viewModel = viewModel } func configure(with tableView: UITableView) { @@ -22,18 +23,19 @@ final class SearchViewDataSource: NSObject { tableView.delegate = self tableView.register(UserViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) + tableView.register(UITableViewHeaderFooterView.self, + forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) } } extension SearchViewDataSource: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return presenter.numberOfUsers + return viewModel.output.users.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(UserViewCell.self, for: indexPath) - let user = presenter.user(at: indexPath.row) + let user = viewModel.output.users[indexPath.row] cell.configure(with: user) return cell } @@ -46,7 +48,7 @@ extension SearchViewDataSource: UITableViewDataSource { guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { return nil } - presenter.showLoadingView(on: view) + viewModel.input.headerFooterView(view) return view } } @@ -54,11 +56,11 @@ extension SearchViewDataSource: UITableViewDataSource { extension SearchViewDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - presenter.showUser(at: indexPath.row) + viewModel.input.selectedIndexPath(indexPath) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let user = presenter.user(at: indexPath.row) + let user = viewModel.output.users[indexPath.row] return UserViewCell.calculateHeight(with: user, and: tableView) } @@ -67,11 +69,11 @@ extension SearchViewDataSource: UITableViewDelegate { } func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return presenter.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude + return viewModel.output.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude } func scrollViewDidScroll(_ scrollView: UIScrollView) { let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) - presenter.setIsReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) + 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/Search/SearchViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift deleted file mode 100644 index 5407516..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// SearchViewPresenter.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 SearchPresenter: class { - var view: SearchView? { get set } - var numberOfUsers: Int { get } - var isFetchingUsers: Bool { get } - func search(queryIfNeeded qeury: String) - func user(at index: Int) -> User - func showUser(at index: Int) - func setIsReachedBottom(_ isReachedBottom: Bool) - func viewWillAppear() - func viewWillDisappear() - func showLoadingView(on view: UIView) -} - -final class SearchViewPresenter: SearchPresenter { - weak var view: SearchView? - - var numberOfUsers: Int { - return model.users.count - } - - var isFetchingUsers: Bool { - return model.isFetchingUsers - } - - private let model: SearchModelType - private let mainAsync: (@escaping () -> Void) -> Void - private let notificationCenter: NotificationCenter - - private var isReachedBottom: Bool = false - private var cancellables = Set() - - init( - model: SearchModelType, - mainAsync: @escaping (@escaping () -> Void) -> Void, - notificationCenter: NotificationCenter - ) { - self.model = model - self.mainAsync = mainAsync - self.notificationCenter = notificationCenter - self.model.delegate = self - } - - private func fetchUsers() { - model.fetchUsers() - } - - func search(queryIfNeeded query: String) { - model.fetchUsers(withQuery: query) - } - - func user(at index: Int) -> User { - return model.users[index] - } - - func showUser(at index: Int) { - let user = model.users[index] - view?.showUserRepository(with: user) - } - - func setIsReachedBottom(_ isReachedBottom: Bool) { - let oldValue = self.isReachedBottom - self.isReachedBottom = isReachedBottom - if isReachedBottom && isReachedBottom != oldValue { - fetchUsers() - } - } - - func viewWillAppear() { - notificationCenter.publisher(for: UIResponder.keyboardWillShowNotification) - .sink { [weak self] notification in - guard let info = UIKeyboardInfo(notification: notification) else { - return - } - self?.view?.keyboardWillShow(with: info) - } - .store(in: &cancellables) - - notificationCenter.publisher(for: UIResponder.keyboardWillHideNotification) - .sink { [weak self] notification in - guard let info = UIKeyboardInfo(notification: notification) else { - return - } - self?.view?.keyboardWillHide(with: info) - } - .store(in: &cancellables) - } - - func viewWillDisappear() { - cancellables.removeAll() - } - - func showLoadingView(on view: UIView) { - self.view?.updateLoadingView(with: view, isLoading: isFetchingUsers) - } -} - -extension SearchViewPresenter: SearchModelDelegate { - func searchModel(_ searchModel: SearchModel, didRecieve errorMessage: ErrorMessage) { - mainAsync { - self.view?.showEmptyTokenError(errorMessage: errorMessage) - } - } - - func searchModel(_ searchModel: SearchModel, didChange isFetchingUsers: Bool) { - mainAsync { - self.view?.reloadData() - } - } - - func searchModel(_ searchModel: SearchModel, didChange users: [User]) { - let totalCount = searchModel.totalCount - mainAsync { - self.view?.updateTotalCountLabel("\(users.count) / \(totalCount)") - self.view?.reloadData() - } - } - - func searchModel(_ searchModel: SearchModel, didChange totalCount: Int) { - let users = searchModel.users - mainAsync { - self.view?.updateTotalCountLabel("\(users.count) / \(totalCount)") - self.view?.reloadData() - } - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift index 91ae1fa..1bb7b2d 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift @@ -6,71 +6,91 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // +import Combine import GithubKit import UIKit -protocol UserRepositoryView: class { - func reloadData() - func showRepository(with repository: Repository) - func updateTotalCountLabel(_ countText: String) - func updateLoadingView(with view: UIView, isLoading: Bool) -} - -final class UserRepositoryViewController: UIViewController, UserRepositoryView { +final class UserRepositoryViewController: UIViewController { @IBOutlet private(set) weak var tableView: UITableView! @IBOutlet private(set) weak var totalCountLabel: UILabel! let loadingView = LoadingView() - let userRepositoryPresenter: UserRepositoryPresenter + let viewModel: UserRepositoryViewModelType let dataSource: UserRepositoryViewDataSource - private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter - + private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType + private var cacellables = Set() + init( - userRepositoryPresenter: UserRepositoryPresenter, - makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter + viewModel: UserRepositoryViewModelType, + makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType ) { - self.userRepositoryPresenter = userRepositoryPresenter - self.dataSource = UserRepositoryViewDataSource(presenter: userRepositoryPresenter) - self.makeRepositoryPresenter = makeRepositoryPresenter + 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 = userRepositoryPresenter.title - + + title = viewModel.output.title + dataSource.configure(with: tableView) - userRepositoryPresenter.view = self - userRepositoryPresenter.fetchRepositories() - } - - func showRepository(with repository: Repository) { - let repositoryPresenter = makeRepositoryPresenter(repository) - let vc = RepositoryViewController(presenter: repositoryPresenter) - navigationController?.pushViewController(vc, animated: true) + 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 reloadData() { - tableView.reloadData() + + 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 updateTotalCountLabel(_ countText: String) { - totalCountLabel.text = countText + + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() + } } - - func updateLoadingView(with view: UIView, isLoading: Bool) { - loadingView.removeFromSuperview() - loadingView.isLoading = isLoading - loadingView.add(to: view) + + 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 index 21c0044..34760a6 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift @@ -7,14 +7,15 @@ // import Foundation -import UIKit import GithubKit +import UIKit final class UserRepositoryViewDataSource: NSObject { - fileprivate let presenter: UserRepositoryPresenter + + private let viewModel: UserRepositoryViewModelType - init(presenter: UserRepositoryPresenter) { - self.presenter = presenter + init(viewModel: UserRepositoryViewModelType) { + self.viewModel = viewModel } func configure(with tableView: UITableView) { @@ -22,18 +23,19 @@ final class UserRepositoryViewDataSource: NSObject { tableView.delegate = self tableView.register(RepositoryViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) + tableView.register(UITableViewHeaderFooterView.self, + forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) } } extension UserRepositoryViewDataSource: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return presenter.numberOfRepositories + return viewModel.output.repositories.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - let repository = presenter.repository(at: indexPath.row) + let repository = viewModel.output.repositories[indexPath.row] cell.configure(with: repository) return cell } @@ -46,7 +48,7 @@ extension UserRepositoryViewDataSource: UITableViewDataSource { guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { return nil } - presenter.showLoadingView(on: view) + viewModel.input.headerFooterView(view) return view } } @@ -54,11 +56,11 @@ extension UserRepositoryViewDataSource: UITableViewDataSource { extension UserRepositoryViewDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - presenter.showRepository(at: indexPath.row) + viewModel.input.selectedIndexPath(indexPath) } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let repository = presenter.repository(at: indexPath.row) + let repository = viewModel.output.repositories[indexPath.row] return RepositoryViewCell.calculateHeight(with: repository, and: tableView) } @@ -67,11 +69,11 @@ extension UserRepositoryViewDataSource: UITableViewDelegate { } func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return presenter.isFetchingRepositories ? LoadingView.defaultHeight : .leastNormalMagnitude + return viewModel.output.isFetchingRepositories ? LoadingView.defaultHeight : .leastNormalMagnitude } func scrollViewDidScroll(_ scrollView: UIScrollView) { let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) - presenter.setIsReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) + 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 + } + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift deleted file mode 100644 index 06593ea..0000000 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// UserRepositoryViewPresenter.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/10. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Foundation -import GithubKit -import UIKit - -protocol UserRepositoryPresenter: class { - var view: UserRepositoryView? { get set } - var title: String { get } - var isFetchingRepositories: Bool { get } - var numberOfRepositories: Int { get } - func repository(at index: Int) -> Repository - func showRepository(at index: Int) - func showLoadingView(on view: UIView) - func setIsReachedBottom(_ isReachedBottom: Bool) - func fetchRepositories() -} - -final class UserRepositoryViewPresenter: UserRepositoryPresenter { - weak var view: UserRepositoryView? - - var numberOfRepositories: Int { - return model.repositories.count - } - - var isFetchingRepositories: Bool { - return model.isFetchingRepositories - } - - var title: String { - return "\(model.user.login)'s Repositories" - } - - private let model: RepositoryModelType - private let mainAsync: (@escaping () -> Void) -> Void - - private var isReachedBottom: Bool = false - - init( - model: RepositoryModelType, - mainAsync: @escaping (@escaping () -> Void) -> Void - ) { - self.model = model - self.mainAsync = mainAsync - self.model.delegate = self - } - - func fetchRepositories() { - model.fetchRepositories() - } - - func repository(at index: Int) -> Repository { - return model.repositories[index] - } - - func showRepository(at index: Int) { - let repository = model.repositories[index] - view?.showRepository(with: repository) - } - - func showLoadingView(on view: UIView) { - self.view?.updateLoadingView(with: view, isLoading: isFetchingRepositories) - } - - func setIsReachedBottom(_ isReachedBottom: Bool) { - let oldValue = self.isReachedBottom - self.isReachedBottom = isReachedBottom - if isReachedBottom && isReachedBottom != oldValue { - fetchRepositories() - } - } -} - -extension UserRepositoryViewPresenter: RepositoryModelDelegate { - func repositoryModel(_ repositoryModel: RepositoryModel, didChange isFetchingRepositories: Bool) { - mainAsync { - self.view?.reloadData() - } - } - - func repositoryModel(_ repositoryModel: RepositoryModel, didChange repositories: [Repository]) { - let totalCount = repositoryModel.totalCount - mainAsync { - self.view?.updateTotalCountLabel("\(repositories.count) / \(totalCount)") - self.view?.reloadData() - } - } - - func repositoryModel(_ repositoryModel: RepositoryModel, didChange totalCount: Int) { - let repositories = repositoryModel.repositories - mainAsync { - self.view?.updateTotalCountLabel("\(repositories.count) / \(totalCount)") - self.view?.reloadData() - } - } -}