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/favorite.png b/Images/favorite.png new file mode 100644 index 0000000..6f9df37 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..dbcc2d4 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..13250a9 Binary files /dev/null and b/Images/search.png differ diff --git a/Images/structure.png b/Images/structure.png index 85e7cb2..00a806c 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..5e26f6d Binary files /dev/null and b/Images/user_repository.png differ diff --git a/README.md b/README.md index 5254b4b..27ea991 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# iOSDesignPatternSamples (MVC) +# iOSDesignPatternSamples (MVP) -This is Github user search demo app that made with MVC design pattern. +This is Github user search demo app that made with MVP design pattern. ## Application Structure @@ -8,10 +8,44 @@ 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) + +- [SearchView](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift) +- [SearchPresenter](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift) +- [SearchViewPresenter](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift) <- Adapt SearchPresenter +- [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) + +- [FavoriteView](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift) +- [FavoritePresenter](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift) +- [FavoriteViewPresenter](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift) <- Adapt FavoritePresenter +- [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) + +- [UserRepositoryView](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift) +- [UserRepositoryPresenter](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift) +- [UserRepositoryViewPresenter](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift) <- Adapt UserRepositoryPresenter +- [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) + +- [RepositoryView](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift) +- [RepositoryPresenter](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift) +- [RepositoryViewPresenter](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift) <- Adapt RepositoryPresenter ## How to add / remove favorites diff --git a/iOSDesignPatternSamples.xcodeproj/project.pbxproj b/iOSDesignPatternSamples.xcodeproj/project.pbxproj index fc27d65..52fc4c8 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 */; }; + 1E393F73F3CAF019A44B3983 /* UserRepositoryViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 520A5CA79771517368B8C2A3 /* UserRepositoryViewPresenter.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 */; }; 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 */; }; 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 */; }; C69B1DDE761E0663FDE1E947 /* UserRepositoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AE9204A886B2448F36B9293 /* UserRepositoryViewController.swift */; }; D0CFC875B535D97424BB8589 /* GithubKit in Frameworks */ = {isa = PBXBuildFile; productRef = C2FA27FA77B01E3C42D84622 /* GithubKit */; }; @@ -37,15 +44,22 @@ 34175ABC0E7BB9870D592CEF /* ApiSession.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiSession.extension.swift; sourceTree = ""; }; 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryViewController.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 = ""; }; + 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 = ""; }; @@ -79,6 +93,8 @@ children = ( 6AE9204A886B2448F36B9293 /* UserRepositoryViewController.swift */, 1F905F35ABF1B1579FEE2B5A /* UserRepositoryViewController.xib */, + 969B79174D1421B0FCFE7154 /* UserRepositoryViewDataSource.swift */, + 520A5CA79771517368B8C2A3 /* UserRepositoryViewPresenter.swift */, ); path = UserRepository; sourceTree = ""; @@ -88,6 +104,8 @@ children = ( 208BE35457B09256BF71DAD1 /* SearchViewController.swift */, 5EDBADE530FE153A9651F109 /* SearchViewController.xib */, + 7E02ECB42B97D30045AF6AB2 /* SearchViewDataSource.swift */, + B6BDED3310BF09C5AD910C87 /* SearchViewPresenter.swift */, ); path = Search; sourceTree = ""; @@ -112,6 +130,7 @@ isa = PBXGroup; children = ( 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */, + 9DEBBF60E2AEFF712AA52C37 /* RepositoryViewPresenter.swift */, ); path = Repository; sourceTree = ""; @@ -152,6 +171,8 @@ children = ( 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */, E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */, + 5DE1FF29F81CED271FD929C2 /* FavoriteViewDataSource.swift */, + 6911B8218A916C540302FD01 /* FavoriteViewPresenter.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 */, + 518405AF0C382898D4127681 /* FavoriteViewPresenter.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 */, 072EB0FFF0B72F37C8CF669A /* SearchModel.swift in Sources */, 4EF255BBD109181D8AD5058F /* SearchViewController.swift in Sources */, + 7F3AC0844FF343E754FD4431 /* SearchViewDataSource.swift in Sources */, + 6A27621B3A685D90F105BD65 /* SearchViewPresenter.swift in Sources */, 811C3B9B712E3B67E5AD73FD /* UIKeyboardInfo.swift in Sources */, C69B1DDE761E0663FDE1E947 /* UserRepositoryViewController.swift in Sources */, + B7B8D28336669C7BF3C29BEC /* UserRepositoryViewDataSource.swift in Sources */, + 1E393F73F3CAF019A44B3983 /* UserRepositoryViewPresenter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift index 7fc18a7..8828395 100644 --- a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift +++ b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift @@ -23,23 +23,43 @@ 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() } } + searchPresenter: SearchViewPresenter( + model: SearchModel( + sendRequest: ApiSession.shared.send, + asyncAfter: { DispatchQueue.global().asyncAfter(deadline: $0, execute: $1) }, + mainAsync: { work in DispatchQueue.main.async { work() } } + ), + mainAsync: { work in DispatchQueue.main.async { work() } }, + notificationCenter: .default ), - makeFavoriteModel: { [favoriteModel] in favoriteModel }, - makeRepositoryModel: { - RepositoryModel( - user: $0, - sendRequest: ApiSession.shared.send + makeRepositoryPresenter: { [favoriteModel] in + RepositoryViewPresenter( + repository: $0, + favoriteModel: favoriteModel + ) + }, + makeUserRepositoryPresenter: { + UserRepositoryViewPresenter( + model: RepositoryModel( + user: $0, + sendRequest: ApiSession.shared.send + ), + mainAsync: { work in DispatchQueue.main.async { work() } } ) } ) nc.setViewControllers([searchVC], animated: false) case let (1, nc as UINavigationController): - let favoriteVC = FavoriteViewController(favoriteModel: favoriteModel) + let favoriteVC = FavoriteViewController( + presenter: FavoriteViewPresenter(model: favoriteModel), + makeRepositoryPresenter: { [favoriteModel] in + RepositoryViewPresenter( + repository: $0, + favoriteModel: favoriteModel + ) + } + ) nc.setViewControllers([favoriteVC], animated: false) default: diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift index 4f172d3..97e422f 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift @@ -9,69 +9,49 @@ import UIKit import GithubKit -final class FavoriteViewController: UIViewController { +protocol FavoriteView: class { + func reloadData() + func showRepository(with repository: Repository) +} + +final class FavoriteViewController: UIViewController, FavoriteView { @IBOutlet private(set) weak var tableView: UITableView! - - let favoriteModel: FavoriteModelType - init(favoriteModel: FavoriteModelType) { - self.favoriteModel = favoriteModel + let presenter: FavoritePresenter + let dataSource: FavoriteViewDataSource + + private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter + + init( + presenter: FavoritePresenter, + makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter + ) { + self.presenter = presenter + self.dataSource = FavoriteViewDataSource(presenter: presenter) + self.makeRepositoryPresenter = makeRepositoryPresenter 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() - } -} - -extension FavoriteViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return favoriteModel.favorites.count + presenter.view = self + dataSource.configure(with: tableView) } - 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 - } -} - -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) + func showRepository(with repository: Repository) { + let repositoryPresenter = makeRepositoryPresenter(repository) + let vc = RepositoryViewController(presenter: repositoryPresenter) + navigationController?.pushViewController(vc, animated: true) } - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return RepositoryViewCell.calculateHeight(with: favoriteModel.favorites[indexPath.row], and: tableView) + func reloadData() { + tableView?.reloadData() } } diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift new file mode 100644 index 0000000..923a57d --- /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 UIKit +import GithubKit + +final class FavoriteViewDataSource: NSObject { + fileprivate let presenter: FavoritePresenter + + init(presenter: FavoritePresenter) { + self.presenter = presenter + } + + 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 presenter.numberOfFavorites + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) + let repository = presenter.favoriteRepository(at: indexPath.row) + cell.configure(with: repository) + return cell + } +} + +extension FavoriteViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + presenter.showFavoriteRepository(at: indexPath.row) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let repository = presenter.favoriteRepository(at: indexPath.row) + return RepositoryViewCell.calculateHeight(with: repository, and: tableView) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift new file mode 100644 index 0000000..49db248 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift @@ -0,0 +1,47 @@ +// +// 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 5764c9c..038e54a 100644 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift @@ -10,40 +10,39 @@ import UIKit import SafariServices import GithubKit -final class RepositoryViewController: SFSafariViewController { +protocol RepositoryView: class { + func updateFavoriteButtonTitle(_ title: String) +} + +final class RepositoryViewController: SFSafariViewController, RepositoryView { 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, + return UIBarButtonItem(title: self.presenter.favoriteButtonTitle, style: .plain, target: self, action: #selector(RepositoryViewController.favoriteButtonTap(_:))) }() + private let presenter: RepositoryPresenter - let repository: Repository - let favoriteModel: FavoriteModelType - - init(repository: Repository, favoriteModel: FavoriteModelType) { - self.repository = repository - self.favoriteModel = favoriteModel - - super.init(url: repository.url, configuration: .init()) + init(presenter: RepositoryPresenter) { + self.presenter = presenter + super.init(url: presenter.url, configuration: .init()) hidesBottomBarWhenPushed = true + } override func viewDidLoad() { super.viewDidLoad() navigationItem.rightBarButtonItem = favoriteButtonItem + + presenter.view = self } @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" - } + presenter.favoriteButtonTap() + } + + func updateFavoriteButtonTitle(_ title: String) { + favoriteButtonItem.title = title } } diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift new file mode 100644 index 0000000..afbd3df --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift @@ -0,0 +1,49 @@ +// +// 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 cc8ccdb..3e93d8a 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift @@ -10,8 +10,18 @@ import Combine import GithubKit import UIKit -final class SearchViewController: UIViewController { - +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 { + @IBOutlet private(set) weak var totalCountLabel: UILabel! @IBOutlet private(set) weak var tableView: UITableView! @IBOutlet private(set) weak var tableViewBottomConstraint: NSLayoutConstraint! @@ -19,27 +29,21 @@ 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 searchPresenter: SearchPresenter + let dataSource: SearchViewDataSource - let searchModel: SearchModelType - private let makeFavoriteModel: () -> FavoriteModelType - private let makeRepositoryModel: (User) -> RepositoryModelType + private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter + private let makeUserRepositoryPresenter: (User) -> UserRepositoryPresenter init( - searchModel: SearchModelType, - makeFavoriteModel: @escaping () -> FavoriteModelType, - makeRepositoryModel: @escaping (User) -> RepositoryModelType + searchPresenter: SearchPresenter, + makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter, + makeUserRepositoryPresenter: @escaping (User) -> UserRepositoryPresenter ) { - self.searchModel = searchModel - self.makeFavoriteModel = makeFavoriteModel - self.makeRepositoryModel = makeRepositoryModel + self.searchPresenter = searchPresenter + self.dataSource = SearchViewDataSource(presenter: searchPresenter) + self.makeRepositoryPresenter = makeRepositoryPresenter + self.makeUserRepositoryPresenter = makeUserRepositoryPresenter super.init(nibName: SearchViewController.className, bundle: nil) } @@ -54,14 +58,14 @@ final class SearchViewController: UIViewController { searchBar.delegate = self searchBar.placeholder = "Input user name" - configure(with: tableView) + dataSource.configure(with: tableView) - searchModel.delegate = self + searchPresenter.view = self } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - observeKeyboard() + searchPresenter.viewWillAppear() } override func viewWillDisappear(_ animated: Bool) { @@ -69,85 +73,58 @@ final class SearchViewController: UIViewController { if searchBar.isFirstResponder { searchBar.resignFirstResponder() } - cancelllables.removeAll() + searchPresenter.viewWillDisappear() } - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(UserViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) + func reloadData() { + tableView.reloadData() } - - 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) - } - .store(in: &cancelllables) + + 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) } - private func showUserRepository(with user: User) { - let repositoryModel = makeRepositoryModel(user) + func showUserRepository(with user: User) { + let presenter = makeUserRepositoryPresenter(user) let vc = UserRepositoryViewController( - repositoryModel: repositoryModel, - makeFavoriteModel: makeFavoriteModel + userRepositoryPresenter: presenter, + makeRepositoryPresenter: makeRepositoryPresenter ) navigationController?.pushViewController(vc, animated: true) } -} - -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) - } - } - - func searchModel(_ searchModel: SearchModel, didChange isFetchingUsers: Bool) { - DispatchQueue.main.async { - self.tableView.reloadData() - } + + func updateTotalCountLabel(_ countText: String) { + totalCountLabel.text = countText } - - func searchModel(_ searchModel: SearchModel, didChange users: [User]) { - let totalCount = searchModel.totalCount - DispatchQueue.main.async { - self.totalCountLabel.text = "\(users.count) / \(totalCount)" - self.tableView.reloadData() - } + + func updateLoadingView(with view: UIView, isLoading: Bool) { + loadingView.removeFromSuperview() + loadingView.isLoading = isLoading + loadingView.add(to: view) } - func searchModel(_ searchModel: SearchModel, didChange totalCount: Int) { - let users = searchModel.users - DispatchQueue.main.async { - self.totalCountLabel.text = "\(users.count) / \(totalCount)" - } + func showEmptyTokenError(errorMessage: ErrorMessage) { + let alert = UIAlertController(title: errorMessage.message, + message: errorMessage.message, + preferredStyle: .alert) + present(alert, animated: false, completion: nil) } } @@ -167,58 +144,6 @@ extension SearchViewController: UISearchBarDelegate { } 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 - } -} - -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 + searchPresenter.search(queryIfNeeded: searchText) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift new file mode 100644 index 0000000..b98fb72 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift @@ -0,0 +1,77 @@ +// +// SearchViewDataSource.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Foundation +import UIKit +import GithubKit + +final class SearchViewDataSource: NSObject { + fileprivate let presenter: SearchPresenter + + init(presenter: SearchPresenter) { + self.presenter = presenter + } + + 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 presenter.numberOfUsers + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(UserViewCell.self, for: indexPath) + let user = presenter.user(at: 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 + } + presenter.showLoadingView(on: view) + return view + } +} + +extension SearchViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + presenter.showUser(at: indexPath.row) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let user = presenter.user(at: 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 presenter.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) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift new file mode 100644 index 0000000..5407516 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift @@ -0,0 +1,138 @@ +// +// 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 fa5880a..91ae1fa 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift @@ -6,34 +6,35 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit 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() - - private var isReachedBottom: Bool = false { - didSet { - if isReachedBottom && isReachedBottom != oldValue { - repositoryModel.fetchRepositories() - } - } - } - let repositoryModel: RepositoryModelType - private let makeFavoriteModel: () -> FavoriteModelType + let userRepositoryPresenter: UserRepositoryPresenter + let dataSource: UserRepositoryViewDataSource + + private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter init( - repositoryModel: RepositoryModelType, - makeFavoriteModel: @escaping () -> FavoriteModelType + userRepositoryPresenter: UserRepositoryPresenter, + makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter ) { - self.repositoryModel = repositoryModel - self.makeFavoriteModel = makeFavoriteModel - + self.userRepositoryPresenter = userRepositoryPresenter + self.dataSource = UserRepositoryViewDataSource(presenter: userRepositoryPresenter) + self.makeRepositoryPresenter = makeRepositoryPresenter super.init(nibName: UserRepositoryViewController.className, bundle: nil) hidesBottomBarWhenPushed = true } @@ -45,100 +46,31 @@ final class UserRepositoryViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - title = "\(repositoryModel.user.login)'s Repositories" + title = userRepositoryPresenter.title - configure(with: tableView) + dataSource.configure(with: tableView) - repositoryModel.delegate = self - repositoryModel.fetchRepositories() + userRepositoryPresenter.view = self + userRepositoryPresenter.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) + func showRepository(with repository: Repository) { + let repositoryPresenter = makeRepositoryPresenter(repository) + let vc = RepositoryViewController(presenter: repositoryPresenter) navigationController?.pushViewController(vc, animated: true) } -} - -extension UserRepositoryViewController: RepositoryModelDelegate { - func repositoryModel(_ repositoryModel: RepositoryModel, didChange isFetchingRepositories: Bool) { - DispatchQueue.main.async { - self.tableView.reloadData() - } - } - - func repositoryModel(_ repositoryModel: RepositoryModel, didChange repositories: [Repository]) { - let totalCount = repositoryModel.totalCount - DispatchQueue.main.async { - self.totalCountLabel.text = "\(repositories.count) / \(totalCount)" - self.tableView.reloadData() - } - } - - func repositoryModel(_ repositoryModel: RepositoryModel, didChange totalCount: Int) { - let repositories = repositoryModel.repositories - DispatchQueue.main.async { - self.totalCountLabel.text = "\(repositories.count) / \(totalCount)" - } - } -} - -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 reloadData() { + tableView.reloadData() } - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return nil + func updateTotalCountLabel(_ countText: String) { + totalCountLabel.text = countText } - func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { - return nil - } + func updateLoadingView(with view: UIView, isLoading: Bool) { loadingView.removeFromSuperview() - loadingView.isLoading = repositoryModel.isFetchingRepositories + loadingView.isLoading = isLoading 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 } } diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift new file mode 100644 index 0000000..21c0044 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift @@ -0,0 +1,77 @@ +// +// UserRepositoryViewDataSource.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Foundation +import UIKit +import GithubKit + +final class UserRepositoryViewDataSource: NSObject { + fileprivate let presenter: UserRepositoryPresenter + + init(presenter: UserRepositoryPresenter) { + self.presenter = presenter + } + + 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 presenter.numberOfRepositories + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) + let repository = presenter.repository(at: 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 + } + presenter.showLoadingView(on: view) + return view + } +} + +extension UserRepositoryViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + presenter.showRepository(at: indexPath.row) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let repository = presenter.repository(at: 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 presenter.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) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift new file mode 100644 index 0000000..06593ea --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift @@ -0,0 +1,102 @@ +// +// 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() + } + } +}