diff --git a/Images/MVC/Views.key b/Images/MVC/Views.key index 7577acb..1372a2e 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..10bee02 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..09f3690 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..c6ff046 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..bdb78cc Binary files /dev/null and b/Images/search.png differ diff --git a/Images/structure.png b/Images/structure.png index be1a725..3f7b2d3 100644 Binary files a/Images/structure.png and b/Images/structure.png differ diff --git a/Images/user_reposiroty.png b/Images/user_reposiroty.png new file mode 100644 index 0000000..927b396 Binary files /dev/null and b/Images/user_reposiroty.png differ diff --git a/README.md b/README.md index a491218..efa42ce 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_reposiroty.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 c26bcd3..fd8c69c 100644 --- a/iOSDesignPatternSamples.xcodeproj/project.pbxproj +++ b/iOSDesignPatternSamples.xcodeproj/project.pbxproj @@ -7,9 +7,14 @@ objects = { /* Begin PBXBuildFile section */ - 37086C1F2017848900D625CA /* NSObjectProtocol.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37086C1E2017848900D625CA /* NSObjectProtocol.extension.swift */; }; - 37086C212017850600D625CA /* ApiSession.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37086C202017850600D625CA /* ApiSession.extension.swift */; }; - 372936AE1F54538A00762D15 /* FavoriteModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372936AD1F54538A00762D15 /* FavoriteModel.swift */; }; + 37086C2620178F1C00D625CA /* ApiSession.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37086C2320178F1C00D625CA /* ApiSession.extension.swift */; }; + 37086C2820178F1C00D625CA /* NSObjectProtocol.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37086C2520178F1C00D625CA /* NSObjectProtocol.extension.swift */; }; + 375C54291F65073900310929 /* SearchViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C54281F65073900310929 /* SearchViewDataSource.swift */; }; + 375C542B1F65079A00310929 /* FavoriteViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C542A1F65079A00310929 /* FavoriteViewPresenter.swift */; }; + 375C542D1F6509E900310929 /* FavoriteViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C542C1F6509E900310929 /* FavoriteViewDataSource.swift */; }; + 375C542F1F650EA900310929 /* SearchViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C542E1F650EA900310929 /* SearchViewPresenter.swift */; }; + 375C54311F65454000310929 /* UserRepositoryViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C54301F65454000310929 /* UserRepositoryViewPresenter.swift */; }; + 375C54331F65455700310929 /* UserRepositoryViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375C54321F65455700310929 /* UserRepositoryViewDataSource.swift */; }; 37BE2ABB1F3745D0003DC1F8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 37BE2AB31F3745D0003DC1F8 /* Assets.xcassets */; }; 37BE2ABC1F3745D0003DC1F8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 37BE2AB41F3745D0003DC1F8 /* LaunchScreen.storyboard */; }; 37BE2ABD1F3745D0003DC1F8 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 37BE2AB61F3745D0003DC1F8 /* Main.storyboard */; }; @@ -30,12 +35,18 @@ 37BE2AEF1F3748EF003DC1F8 /* UIKeyboardWillShow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE2AEE1F3748EF003DC1F8 /* UIKeyboardWillShow.swift */; }; 37BE2AF51F3759E7003DC1F8 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37BE2AF41F3759E7003DC1F8 /* LoadingView.swift */; }; 37BE2AF71F3759F0003DC1F8 /* LoadingView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 37BE2AF61F3759F0003DC1F8 /* LoadingView.xib */; }; + 37D5D20B1F6599A900FA46DF /* RepositoryViewPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37D5D20A1F6599A900FA46DF /* RepositoryViewPresenter.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 37086C1E2017848900D625CA /* NSObjectProtocol.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSObjectProtocol.extension.swift; sourceTree = ""; }; - 37086C202017850600D625CA /* ApiSession.extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiSession.extension.swift; sourceTree = ""; }; - 372936AD1F54538A00762D15 /* FavoriteModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteModel.swift; sourceTree = ""; }; + 37086C2320178F1C00D625CA /* ApiSession.extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApiSession.extension.swift; sourceTree = ""; }; + 37086C2520178F1C00D625CA /* NSObjectProtocol.extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NSObjectProtocol.extension.swift; sourceTree = ""; }; + 375C54281F65073900310929 /* SearchViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewDataSource.swift; sourceTree = ""; }; + 375C542A1F65079A00310929 /* FavoriteViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewPresenter.swift; sourceTree = ""; }; + 375C542C1F6509E900310929 /* FavoriteViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewDataSource.swift; sourceTree = ""; }; + 375C542E1F650EA900310929 /* SearchViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewPresenter.swift; sourceTree = ""; }; + 375C54301F65454000310929 /* UserRepositoryViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryViewPresenter.swift; sourceTree = ""; }; + 375C54321F65455700310929 /* UserRepositoryViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryViewDataSource.swift; sourceTree = ""; }; 37817D031F373F8B00EC69C6 /* iOSDesignPatternSamples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOSDesignPatternSamples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 37BE2AB31F3745D0003DC1F8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 37BE2AB51F3745D0003DC1F8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; @@ -58,6 +69,7 @@ 37BE2AEE1F3748EF003DC1F8 /* UIKeyboardWillShow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIKeyboardWillShow.swift; sourceTree = ""; }; 37BE2AF41F3759E7003DC1F8 /* LoadingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 37BE2AF61F3759F0003DC1F8 /* LoadingView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = LoadingView.xib; sourceTree = ""; }; + 37D5D20A1F6599A900FA46DF /* RepositoryViewPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryViewPresenter.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -77,11 +89,11 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 37086C1D2017847A00D625CA /* Extension */ = { + 37086C2220178F1C00D625CA /* Extension */ = { isa = PBXGroup; children = ( - 37086C202017850600D625CA /* ApiSession.extension.swift */, - 37086C1E2017848900D625CA /* NSObjectProtocol.extension.swift */, + 37086C2320178F1C00D625CA /* ApiSession.extension.swift */, + 37086C2520178F1C00D625CA /* NSObjectProtocol.extension.swift */, ); path = Extension; sourceTree = ""; @@ -135,8 +147,7 @@ isa = PBXGroup; children = ( 37BE2AC11F37460C003DC1F8 /* AppDelegate.swift */, - 372936AD1F54538A00762D15 /* FavoriteModel.swift */, - 37086C1D2017847A00D625CA /* Extension */, + 37086C2220178F1C00D625CA /* Extension */, 37BE2AE91F374889003DC1F8 /* NotieObserver */, ); path = Common; @@ -159,6 +170,8 @@ isa = PBXGroup; children = ( 37BE2AC81F37468C003DC1F8 /* FavoriteViewController.swift */, + 375C542A1F65079A00310929 /* FavoriteViewPresenter.swift */, + 375C542C1F6509E900310929 /* FavoriteViewDataSource.swift */, ); path = Favorite; sourceTree = ""; @@ -167,6 +180,7 @@ isa = PBXGroup; children = ( 37BE2ACC1F3746E2003DC1F8 /* RepositoryViewController.swift */, + 37D5D20A1F6599A900FA46DF /* RepositoryViewPresenter.swift */, ); path = Repository; sourceTree = ""; @@ -175,6 +189,8 @@ isa = PBXGroup; children = ( 37BE2ACA1F374699003DC1F8 /* SearchViewController.swift */, + 375C542E1F650EA900310929 /* SearchViewPresenter.swift */, + 375C54281F65073900310929 /* SearchViewDataSource.swift */, ); path = Search; sourceTree = ""; @@ -183,6 +199,8 @@ isa = PBXGroup; children = ( 37BE2AD01F3746FA003DC1F8 /* UserRepositoryViewController.swift */, + 375C54301F65454000310929 /* UserRepositoryViewPresenter.swift */, + 375C54321F65455700310929 /* UserRepositoryViewDataSource.swift */, 37BE2AD11F3746FA003DC1F8 /* UserRepositoryViewController.xib */, ); path = UserRepository; @@ -318,16 +336,22 @@ buildActionMask = 2147483647; files = ( 37BE2AEB1F3748B6003DC1F8 /* UIKeyboardInfo.swift in Sources */, - 372936AE1F54538A00762D15 /* FavoriteModel.swift in Sources */, - 37086C212017850600D625CA /* ApiSession.extension.swift in Sources */, + 375C542B1F65079A00310929 /* FavoriteViewPresenter.swift in Sources */, + 375C542F1F650EA900310929 /* SearchViewPresenter.swift in Sources */, 37BE2ACE1F3746E2003DC1F8 /* RepositoryViewController.swift in Sources */, 37BE2AF51F3759E7003DC1F8 /* LoadingView.swift in Sources */, + 375C542D1F6509E900310929 /* FavoriteViewDataSource.swift in Sources */, 37BE2ACB1F374699003DC1F8 /* SearchViewController.swift in Sources */, + 375C54291F65073900310929 /* SearchViewDataSource.swift in Sources */, 37BE2AEF1F3748EF003DC1F8 /* UIKeyboardWillShow.swift in Sources */, 37BE2AD21F3746FA003DC1F8 /* UserRepositoryViewController.swift in Sources */, + 37086C2620178F1C00D625CA /* ApiSession.extension.swift in Sources */, + 375C54311F65454000310929 /* UserRepositoryViewPresenter.swift in Sources */, + 37D5D20B1F6599A900FA46DF /* RepositoryViewPresenter.swift in Sources */, 37BE2AED1F3748D9003DC1F8 /* UIKeyboardWillHide.swift in Sources */, - 37086C1F2017848900D625CA /* NSObjectProtocol.extension.swift in Sources */, 37BE2AC91F37468C003DC1F8 /* FavoriteViewController.swift in Sources */, + 37086C2820178F1C00D625CA /* NSObjectProtocol.extension.swift in Sources */, + 375C54331F65455700310929 /* UserRepositoryViewDataSource.swift in Sources */, 37BE2AC21F37460C003DC1F8 /* AppDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift index 75c41b1..fe90c41 100644 --- a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift +++ b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift @@ -24,7 +24,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let favoriteVC = viewControllers.flatMap({ ($0 as? UINavigationController)?.topViewController as? FavoriteViewController }).first { - searchVC.favoriteModel = favoriteVC.favoriteModel + searchVC.favoritePresenter = favoriteVC.presenter } return true diff --git a/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift b/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift deleted file mode 100644 index 8f18ba9..0000000 --- a/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// FavoriteModel.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/08/28. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import GithubKit - -@objc protocol FavoriteModelDelegate: class { - @objc optional func favoriteDidChange() -} - -final class FavoriteModel { - private(set) var favorites: [Repository] = [] { - didSet { - delegate?.favoriteDidChange?() - } - } - - weak var delegate: FavoriteModelDelegate? - - func addFavorite(_ repository: Repository) { - if favorites.lazy.index(where: { $0.url == repository.url }) != nil { - return - } - favorites.append(repository) - } - - func removeFavorite(_ repository: Repository) { - guard let index = favorites.lazy.index(where: { $0.url == repository.url }) else { - return - } - favorites.remove(at: index) - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift index 010426c..58e0e57 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift @@ -9,10 +9,16 @@ import UIKit import GithubKit -final class FavoriteViewController: UIViewController { +protocol FavoriteView: class { + func reloadData() + func showRepository(with repository: Repository) +} + +final class FavoriteViewController: UIViewController, FavoriteView { @IBOutlet weak var tableView: UITableView! - let favoriteModel = FavoriteModel() + private(set) lazy var presenter: FavoritePresenter = FavoriteViewPresenter(view: self) + private lazy var dataSource: FavoriteViewDataSource = .init(presenter: self.presenter) override func viewDidLoad() { super.viewDidLoad() @@ -20,50 +26,15 @@ final class FavoriteViewController: UIViewController { title = "On Memory Favorite" automaticallyAdjustsScrollViewInsets = false - favoriteModel.delegate = self - configure(with: tableView) - } - - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(RepositoryViewCell.self) + dataSource.configure(with: tableView) } - fileprivate func showRepository(with repository: Repository) { - let vc = RepositoryViewController(repository: repository, favoriteModel: favoriteModel) + func showRepository(with repository: Repository) { + let vc = RepositoryViewController(repository: repository, favoritePresenter: presenter) 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 - } - - 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 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..a376b4c --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift @@ -0,0 +1,64 @@ +// +// 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 { + init(view: FavoriteView) + var numberOfFavorites: Int { get } + func addFavorite(_ repository: Repository) + func removeFavorite(_ repository: Repository) + func favoriteRepository(at index: Int) -> Repository + func showFavoriteRepository(at index: Int) + func contains(_ repository: Repository) -> Bool +} + +final class FavoriteViewPresenter: FavoritePresenter { + private weak var view: FavoriteView? + private var favorites: [Repository] = [] { + didSet { + view?.reloadData() + } + } + + var numberOfFavorites: Int { + return favorites.count + } + + init(view: FavoriteView) { + self.view = view + } + + func favoriteRepository(at index: Int) -> Repository { + return favorites[index] + } + + func addFavorite(_ repository: Repository) { + if favorites.lazy.index(where: { $0.url == repository.url }) != nil { + return + } + favorites.append(repository) + } + + func removeFavorite(_ repository: Repository) { + guard let index = favorites.lazy.index(where: { $0.url == repository.url }) else { + return + } + favorites.remove(at: index) + } + + func contains(_ repository: Repository) -> Bool { + return favorites.lazy.index { $0.url == repository.url } != nil + } + + func showFavoriteRepository(at index: Int) { + let repository = favorites[index] + view?.showRepository(with: repository) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift index 8a96e4b..4eead2c 100644 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift @@ -10,27 +10,27 @@ 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 repository: Repository - private let favoriteModel: FavoriteModel + private let presenter: RepositoryPresenter init(repository: Repository, - favoriteModel: FavoriteModel, + favoritePresenter: FavoritePresenter, entersReaderIfAvailable: Bool = true) { - self.repository = repository - self.favoriteModel = favoriteModel - + self.presenter = RepositoryViewPresenter(repository: repository, + favoritePresenter: favoritePresenter) super.init(url: repository.url, entersReaderIfAvailable: entersReaderIfAvailable) hidesBottomBarWhenPushed = true + self.presenter.view = self } override func viewDidLoad() { @@ -40,12 +40,10 @@ final class RepositoryViewController: SFSafariViewController { } @objc private func favoriteButtonTap(_ sender: UIBarButtonItem) { - if favoriteModel.favorites.index(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..ed35b01 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift @@ -0,0 +1,42 @@ +// +// 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 { + init(repository: Repository, favoritePresenter: FavoritePresenter) + weak var view: RepositoryView? { get set } + var favoriteButtonTitle: String { get } + func favoriteButtonTap() +} + +final class RepositoryViewPresenter: RepositoryPresenter { + weak var view: RepositoryView? + private let favoritePresenter: FavoritePresenter + private let repository: Repository + + var favoriteButtonTitle: String { + return favoritePresenter.contains(repository) ? "Remove" : "Add" + } + + init(repository: Repository, favoritePresenter: FavoritePresenter) { + self.repository = repository + self.favoritePresenter = favoritePresenter + } + + func favoriteButtonTap() { + if favoritePresenter.contains(repository) { + favoritePresenter.removeFavorite(repository) + view?.updateFavoriteButtonTitle(favoriteButtonTitle) + } else { + favoritePresenter.addFavorite(repository) + view?.updateFavoriteButtonTitle(favoriteButtonTitle) + } + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift index dc82641..3444a9b 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift @@ -8,10 +8,18 @@ import UIKit import GithubKit -import NoticeObserveKit -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() +} + +final class SearchViewController: UIViewController, SearchView { @IBOutlet weak var totalCountLabel: UILabel! @IBOutlet weak var tableView: UITableView! @IBOutlet weak var tableViewBottomConstraint: NSLayoutConstraint! @@ -21,66 +29,12 @@ final class SearchViewController: UIViewController { return searchBar }() - fileprivate var query: String = "" { - didSet { - if query != oldValue { - users.removeAll() - pageInfo = nil - totalCount = 0 - } - task?.cancel() - task = nil - fetchUsers() - } - } - private var task: URLSessionTask? = nil - private var pageInfo: PageInfo? = nil - private var totalCount: Int = 0 { - didSet { - totalCountLabel.text = "\(users.count) / \(totalCount)" - } - } - fileprivate var users: [User] = [] { - didSet { - totalCountLabel.text = "\(users.count) / \(totalCount)" - tableView.reloadData() - } - } - fileprivate let debounce: (_ action: @escaping () -> ()) -> () = { - var lastFireTime: DispatchTime = .now() - let delay: DispatchTimeInterval = .milliseconds(500) - return { [delay] action in - let deadline: DispatchTime = .now() + delay - lastFireTime = .now() - DispatchQueue.global().asyncAfter(deadline: deadline) { [delay] in - let now: DispatchTime = .now() - let when: DispatchTime = lastFireTime + delay - if now < when { return } - lastFireTime = .now() - DispatchQueue.main.async { - action() - } - } - } - }() - fileprivate var isFetchingUsers = false { - didSet { - tableView.reloadData() - } - } - private var pool = NoticeObserverPool() - fileprivate let loadingView = LoadingView.makeFromNib() - fileprivate var isReachedBottom: Bool = false { - didSet { - if isReachedBottom && isReachedBottom != oldValue { - fetchUsers() - } - } - } + var favoritePresenter: FavoritePresenter? - var favoriteModel: FavoriteModel? + private lazy var presenter: SearchPresenter = SearchViewPresenter(view: self) + private lazy var dataSource: SearchViewDataSource = .init(presenter: self.presenter) override func viewDidLoad() { super.viewDidLoad() @@ -88,12 +42,12 @@ final class SearchViewController: UIViewController { navigationItem.titleView = searchBar searchBar.placeholder = "Input user name" - configure(with: tableView) + dataSource.configure(with: tableView) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - observeKeyboard() + presenter.viewWillAppear() } override func viewWillDisappear(_ animated: Bool) { @@ -101,75 +55,56 @@ final class SearchViewController: UIViewController { if searchBar.isFirstResponder { searchBar.resignFirstResponder() } - pool = NoticeObserverPool() + presenter.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() { - UIKeyboardWillShow.observe { [weak self] in - self?.view.layoutIfNeeded() - let extra = self?.tabBarController?.tabBar.bounds.height ?? 0 - self?.tableViewBottomConstraint.constant = $0.frame.size.height - extra - UIView.animate(withDuration: $0.animationDuration, delay: 0, options: $0.animationCurve, animations: { - self?.view.layoutIfNeeded() - }, completion: nil) - } - .disposed(by: pool) - - UIKeyboardWillHide.observe { [weak self] in - self?.view.layoutIfNeeded() - self?.tableViewBottomConstraint.constant = 0 - UIView.animate(withDuration: $0.animationDuration, delay: 0, options: $0.animationCurve, animations: { - self?.view.layoutIfNeeded() - }, completion: nil) - } - .disposed(by: pool) + 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) } - private func fetchUsers() { - if query.isEmpty || task != nil { return } - if let pageInfo = pageInfo, !pageInfo.hasNextPage || pageInfo.endCursor == nil { return } - isFetchingUsers = true - let request = SearchUserRequest(query: query, after: pageInfo?.endCursor) - self.task = ApiSession.shared.send(request) { [weak self] in - switch $0 { - case .success(let value): - DispatchQueue.main.async { - self?.pageInfo = value.pageInfo - self?.users.append(contentsOf: value.nodes) - self?.totalCount = value.totalCount - } - case .failure(let error): - if case .emptyToken? = (error as? ApiSession.Error) { - DispatchQueue.main.async { - guard let me = self else { return } - let message = "\"Github Personal Access Token\" is Required.\n Please set it in ApiSession.extension.swift!" - let alert = UIAlertController(title: "Access Token Error", - message: message, - preferredStyle: .alert) - me.present(alert, animated: false, completion: nil) - } - } - } - DispatchQueue.main.async { - self?.isFetchingUsers = false - } - self?.task = 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) } - fileprivate func showUserRepository(with user: User) { - guard let favoriteModel = favoriteModel else { return } - let vc = UserRepositoryViewController(user: user, favoriteModel: favoriteModel) + func showUserRepository(with user: User) { + guard let presenter = favoritePresenter else { return } + let vc = UserRepositoryViewController(user: user, favoritePresenter: presenter) 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() { + let alert = UIAlertController(title: "Access Token Error", + message: "\"Github Personal Access Token\" is Required.\n Please set it in ApiSession.extension.swift!", + preferredStyle: .alert) + present(alert, animated: false, completion: nil) + } } extension SearchViewController: UISearchBarDelegate { @@ -188,60 +123,6 @@ extension SearchViewController: UISearchBarDelegate { } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - debounce { [weak self] in - self?.query = searchText - } - } -} - -extension SearchViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return users.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeue(UserViewCell.self, for: indexPath) - cell.configure(with: 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 = 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 = users[indexPath.row] - showUserRepository(with: user) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UserViewCell.calculateHeight(with: users[indexPath.row], and: tableView) - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return .leastNormalMagnitude - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) - isReachedBottom = maxScrollDistance <= scrollView.contentOffset.y + presenter.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..058618d --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift @@ -0,0 +1,163 @@ +// +// SearchViewPresenter.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Foundation +import GithubKit +import NoticeObserveKit + +protocol SearchPresenter: class { + init(view: SearchView) + 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 { + private weak var view: SearchView? + private var query: String = "" { + didSet { + if query != oldValue { + users.removeAll() + pageInfo = nil + totalCount = 0 + } + task?.cancel() + task = nil + fetchUsers() + } + } + private var task: URLSessionTask? = nil + private var pageInfo: PageInfo? = nil + private var totalCount: Int = 0 { + didSet { + DispatchQueue.main.async { [weak self] in + guard let me = self else { return } + me.view?.updateTotalCountLabel("\(me.users.count) / \(me.totalCount)") + me.view?.reloadData() + } + } + } + private var users: [User] = [] { + didSet { + DispatchQueue.main.async { [weak self] in + guard let me = self else { return } + me.view?.updateTotalCountLabel("\(me.users.count) / \(me.totalCount)") + me.view?.reloadData() + } + } + } + private let debounce: (_ action: @escaping () -> ()) -> () = { + var lastFireTime: DispatchTime = .now() + let delay: DispatchTimeInterval = .milliseconds(500) + return { [delay] action in + let deadline: DispatchTime = .now() + delay + lastFireTime = .now() + DispatchQueue.global().asyncAfter(deadline: deadline) { [delay] in + let now: DispatchTime = .now() + let when: DispatchTime = lastFireTime + delay + if now < when { return } + lastFireTime = .now() + DispatchQueue.main.async { + action() + } + } + } + }() + private var isReachedBottom: Bool = false { + didSet { + if isReachedBottom && isReachedBottom != oldValue { + fetchUsers() + } + } + } + private(set) var isFetchingUsers = false { + didSet { + DispatchQueue.main.async { [weak self] in + self?.view?.reloadData() + } + } + } + private var pool = NoticeObserverPool() + + var numberOfUsers: Int { + return users.count + } + + init(view: SearchView) { + self.view = view + } + + private func fetchUsers() { + if query.isEmpty || task != nil { return } + if let pageInfo = pageInfo, !pageInfo.hasNextPage || pageInfo.endCursor == nil { return } + isFetchingUsers = true + let request = SearchUserRequest(query: query, after: pageInfo?.endCursor) + self.task = ApiSession.shared.send(request) { [weak self] in + switch $0 { + case .success(let value): + self?.pageInfo = value.pageInfo + self?.users.append(contentsOf: value.nodes) + self?.totalCount = value.totalCount + + case .failure(let error): + if case .emptyToken? = (error as? ApiSession.Error) { + DispatchQueue.main.async { + self?.view?.showEmptyTokenError() + } + } + } + self?.isFetchingUsers = false + self?.task = nil + } + } + + func search(queryIfNeeded qeury: String) { + debounce { [weak self] in + self?.query = qeury + } + } + + func user(at index: Int) -> User { + return users[index] + } + + func showUser(at index: Int) { + let user = users[index] + view?.showUserRepository(with: user) + } + + func setIsReachedBottom(_ isReachedBottom: Bool) { + self.isReachedBottom = isReachedBottom + } + + func viewWillAppear() { + UIKeyboardWillShow.observe { [weak self] in + self?.view?.keyboardWillShow(with: $0) + } + .disposed(by: pool) + + UIKeyboardWillHide.observe { [weak self] in + self?.view?.keyboardWillHide(with: $0) + } + .disposed(by: pool) + } + + func viewWillDisappear() { + pool = NoticeObserverPool() + } + + func showLoadingView(on view: UIView) { + self.view?.updateLoadingView(with: view, isLoading: isFetchingUsers) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift index 016981d..c5f597f 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift @@ -9,48 +9,29 @@ import UIKit import GithubKit -final class UserRepositoryViewController: UIViewController { - +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 { @IBOutlet weak var tableView: UITableView! @IBOutlet weak var totalCountLabel: UILabel! - fileprivate let loadingView = LoadingView.makeFromNib() - - fileprivate var isReachedBottom: Bool = false { - didSet { - if isReachedBottom && isReachedBottom != oldValue { - fetchRepositories() - } - } - } - fileprivate var isFetchingRepositories = false { - didSet { - tableView.reloadData() - } - } - private var totalCount: Int = 0 { - didSet { - totalCountLabel.text = "\(repositories.count) / \(totalCount)" - } - } - fileprivate var repositories: [Repository] = [] { - didSet { - totalCountLabel.text = "\(repositories.count) / \(totalCount)" - tableView.reloadData() - } - } - private var pageInfo: PageInfo? = nil - private var task: URLSessionTask? = nil + private let loadingView = LoadingView.makeFromNib() + private let favoritePresenter: FavoritePresenter + private let presenter: UserRepositoryPresenter - private let user: User - private let favoriteModel: FavoriteModel + private lazy var dataSource: UserRepositoryViewDataSource = .init(presenter: self.presenter) - init(user: User, favoriteModel: FavoriteModel) { - self.user = user - self.favoriteModel = favoriteModel - + init(user: User, favoritePresenter: FavoritePresenter) { + self.favoritePresenter = favoritePresenter + self.presenter = UserRepositoryViewPresenter(user: user) super.init(nibName: UserRepositoryViewController.className, bundle: nil) hidesBottomBarWhenPushed = true + presenter.view = self } required init?(coder aDecoder: NSCoder) { @@ -60,99 +41,29 @@ final class UserRepositoryViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - title = "\(user.login)'s Repositories" + title = presenter.title edgesForExtendedLayout = [] - configure(with: tableView) - - fetchRepositories() - } - - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(RepositoryViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) - } - - fileprivate func fetchRepositories() { - if task != nil { return } - if let pageInfo = pageInfo, !pageInfo.hasNextPage || pageInfo.endCursor == nil { return } - isFetchingRepositories = true - let request = UserNodeRequest(id: user.id, after: pageInfo?.endCursor) - self.task = ApiSession.shared.send(request) { [weak self] in - switch $0 { - case .success(let value): - DispatchQueue.main.async { - self?.pageInfo = value.pageInfo - self?.repositories.append(contentsOf: value.nodes) - self?.totalCount = value.totalCount - } - case .failure(let error): - print(error) - } - DispatchQueue.main.async { - self?.isFetchingRepositories = false - } - self?.task = nil - } + dataSource.configure(with: tableView) + presenter.fetchRepositories() } - fileprivate func showRepository(with repository: Repository) { - let vc = RepositoryViewController(repository: repository, favoriteModel: favoriteModel) + func showRepository(with repository: Repository) { + let vc = RepositoryViewController(repository: repository, favoritePresenter: favoritePresenter) navigationController?.pushViewController(vc, animated: true) } -} - -extension UserRepositoryViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return repositories.count - } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - cell.configure(with: 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 = 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 = repositories[indexPath.row] - showRepository(with: repository) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return RepositoryViewCell.calculateHeight(with: repositories[indexPath.row], and: tableView) - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return .leastNormalMagnitude - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return 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..4a6b0e7 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift @@ -0,0 +1,110 @@ +// +// UserRepositoryViewPresenter.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Foundation +import GithubKit + +protocol UserRepositoryPresenter: class { + init(user: User) + weak 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? + private let user: User + + private var pageInfo: PageInfo? = nil + private var task: URLSessionTask? = nil + private var repositories: [Repository] = [] { + didSet { + DispatchQueue.main.async { [weak self] in + guard let me = self else { return } + me.view?.updateTotalCountLabel("\(me.repositories.count) / \(me.totalCount)") + me.view?.reloadData() + } + } + } + private var totalCount: Int = 0 { + didSet { + DispatchQueue.main.async { [weak self] in + guard let me = self else { return } + me.view?.updateTotalCountLabel("\(me.repositories.count) / \(me.totalCount)") + me.view?.reloadData() + } + } + } + private(set) var isFetchingRepositories = false { + didSet { + DispatchQueue.main.async { [weak self] in + self?.view?.reloadData() + } + } + } + private var isReachedBottom: Bool = false { + didSet { + if isReachedBottom && isReachedBottom != oldValue { + fetchRepositories() + } + } + } + + var numberOfRepositories: Int { + return repositories.count + } + var title: String { + return "\(user.login)'s Repositories" + } + + init(user: User) { + self.user = user + } + + func fetchRepositories() { + if task != nil { return } + if let pageInfo = pageInfo, !pageInfo.hasNextPage || pageInfo.endCursor == nil { return } + isFetchingRepositories = true + let request = UserNodeRequest(id: user.id, after: pageInfo?.endCursor) + self.task = ApiSession.shared.send(request) { [weak self] in + switch $0 { + case .success(let value): + self?.pageInfo = value.pageInfo + self?.repositories.append(contentsOf: value.nodes) + self?.totalCount = value.totalCount + case .failure(let error): + print(error) + } + self?.isFetchingRepositories = false + self?.task = nil + } + } + + func repository(at index: Int) -> Repository { + return repositories[index] + } + + func showRepository(at index: Int) { + let repository = repositories[index] + view?.showRepository(with: repository) + } + + func showLoadingView(on view: UIView) { + self.view?.updateLoadingView(with: view, isLoading: isFetchingRepositories) + } + + func setIsReachedBottom(_ isReachedBottom: Bool) { + self.isReachedBottom = isReachedBottom + } +}