diff --git a/Images/Flux/Views.key b/Images/Flux/Views.key new file mode 100644 index 0000000..f752693 Binary files /dev/null and b/Images/Flux/Views.key differ diff --git a/Images/MVVM/Views.key b/Images/MVVM/Views.key new file mode 100644 index 0000000..ed8b647 Binary files /dev/null and b/Images/MVVM/Views.key differ diff --git a/Images/favorite.png b/Images/favorite.png index 6f9df37..798ad4b 100644 Binary files a/Images/favorite.png and b/Images/favorite.png differ diff --git a/Images/repository.png b/Images/repository.png index dbcc2d4..ce3fa2f 100644 Binary files a/Images/repository.png and b/Images/repository.png differ diff --git a/Images/search.png b/Images/search.png index 13250a9..8a8aaac 100644 Binary files a/Images/search.png and b/Images/search.png differ diff --git a/Images/structure.png b/Images/structure.png index 00a806c..a162f91 100644 Binary files a/Images/structure.png and b/Images/structure.png differ diff --git a/Images/user_repository.png b/Images/user_repository.png index 5e26f6d..9290970 100644 Binary files a/Images/user_repository.png and b/Images/user_repository.png differ diff --git a/README.md b/README.md index 27ea991..4200b61 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# iOSDesignPatternSamples (MVP) +# iOSDesignPatternSamples (Flux) -This is Github user search demo app that made with MVP design pattern. +This is Github user search demo app that made with Flux design pattern. ## Application Structure @@ -13,43 +13,40 @@ 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 +- [SearchAction](./iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchAction.swift) +- [SearchStore](./iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchStore.swift) ### [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 +- [FavoriteAction](./iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteAction.swift) +- [FavoriteStore](./iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteStore.swift) ### [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 +- [UserRepositoryAction](./iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryAction.swift) +- [UserRepositoryStore](./iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryStore.swift) ### [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 +- [RepositoryAction](./iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryAction.swift) +- [RepositoryStore](./iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryStore.swift) + ## How to add / remove favorites -You can add / remove favorite repositories in RepositoryViewController, but an Array of favorite repository is hold by FavoriteViewController. +You can add / remove favorite repositories in RepositoryViewController. Array of favorite repository is hold by FavoriteModel that injected to each actions, therefore you can use its reference everywhere! ## Run diff --git a/iOSDesignPatternSamples.xcodeproj/project.pbxproj b/iOSDesignPatternSamples.xcodeproj/project.pbxproj index 52fc4c8..fd957b4 100644 --- a/iOSDesignPatternSamples.xcodeproj/project.pbxproj +++ b/iOSDesignPatternSamples.xcodeproj/project.pbxproj @@ -7,33 +7,41 @@ objects = { /* Begin PBXBuildFile section */ + 067350409E30C9B7051919A9 /* UserRepositoryDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D4482B74EB628C1ECB704E /* UserRepositoryDispatcher.swift */; }; 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 */; }; + 22986FE76637ACF3E653A512 /* UserRepositoryAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8BAAD1EA3FE6EB29E109261 /* UserRepositoryAction.swift */; }; + 3E9BB9988B02A7DAC10B9F7F /* FavoriteDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CD3CF12DDD7F6C42364BA9C /* FavoriteDispatcher.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 */; }; + 645A35DA317A4471244893CE /* FavoriteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF357511873A96AF5614198 /* FavoriteStore.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 */; }; + 71B682A7678AE7B950DCD0F3 /* FavoriteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92A03189FF8521FF3E71A7D /* FavoriteAction.swift */; }; 71E4B210FC5945A53739149D /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2B00ED49EA7B8CD6C7C4F5 /* LoadingView.swift */; }; + 79C371E4A4C94800222F6518 /* RepositoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A6431841C8E9792FF44760 /* RepositoryStore.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 */; }; + 865569E1E5534840C370E9B9 /* RepositoryAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = C032AEE4D5BB5D771256B52A /* RepositoryAction.swift */; }; 9B515DE20E1424AC3D1F08CF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AE92440962235D7ABB12EAFA /* Main.storyboard */; }; + B5E95E30C4523271F287CBCB /* UserRepositoryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C9D69C6AD033AD869008265 /* UserRepositoryStore.swift */; }; B7B8D28336669C7BF3C29BEC /* UserRepositoryViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 969B79174D1421B0FCFE7154 /* UserRepositoryViewDataSource.swift */; }; + B9DC6F0867CFEFE02CA6D0B8 /* SearchDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 851F1BB66098A309DCBD0080 /* SearchDispatcher.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 */; }; + C89C03ABCFBDDB6561837465 /* SearchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D881D7443F988A4D31900D8 /* SearchAction.swift */; }; + CC83DB0B5E4F04C637367B6D /* RepositoryDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F982A87E9372D61929C99C0 /* RepositoryDispatcher.swift */; }; D0CFC875B535D97424BB8589 /* GithubKit in Frameworks */ = {isa = PBXBuildFile; productRef = C2FA27FA77B01E3C42D84622 /* GithubKit */; }; D84737DB64A88B1888B8465D /* FavoriteModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 482D2D42402C917E5C0069BC /* FavoriteModel.swift */; }; EAAC6C2B3C02E7FBEBC90163 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9ABD5244E170566F15BBA15E /* Assets.xcassets */; }; ED8C1E52A3D6C0097A18601E /* RepositoryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7D7B4F908B5F135EC242E10 /* RepositoryModel.swift */; }; F01EAD7C3991E04F458F774F /* UserRepositoryViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1F905F35ABF1B1579FEE2B5A /* UserRepositoryViewController.xib */; }; F58E4CF535E45CEED3E98229 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 85E0B8C9615DA3BCB623A9E8 /* LaunchScreen.storyboard */; }; + F7DF91BC168C6A65DA5A2FBA /* SearchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92B37E415040318015DC44D /* SearchStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -41,29 +49,37 @@ 1F905F35ABF1B1579FEE2B5A /* UserRepositoryViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = UserRepositoryViewController.xib; sourceTree = ""; }; 208BE35457B09256BF71DAD1 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; 260D4E07190EC496827E1037 /* iOSDesignPatternSamples.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = iOSDesignPatternSamples.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 28A6431841C8E9792FF44760 /* RepositoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryStore.swift; sourceTree = ""; }; 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 = ""; }; + 4C9D69C6AD033AD869008265 /* UserRepositoryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryStore.swift; sourceTree = ""; }; + 4D881D7443F988A4D31900D8 /* SearchAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchAction.swift; sourceTree = ""; }; + 52D4482B74EB628C1ECB704E /* UserRepositoryDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryDispatcher.swift; sourceTree = ""; }; + 5CD3CF12DDD7F6C42364BA9C /* FavoriteDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteDispatcher.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 = ""; }; + 851F1BB66098A309DCBD0080 /* SearchDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchDispatcher.swift; sourceTree = ""; }; + 8F982A87E9372D61929C99C0 /* RepositoryDispatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryDispatcher.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 = ""; }; + C032AEE4D5BB5D771256B52A /* RepositoryAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryAction.swift; sourceTree = ""; }; C86990A891A2828A45CDAF7A /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + D92B37E415040318015DC44D /* SearchStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStore.swift; sourceTree = ""; }; + DBF357511873A96AF5614198 /* FavoriteStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteStore.swift; sourceTree = ""; }; E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FavoriteViewController.xib; sourceTree = ""; }; E18C174EDA4FB880B8A111DF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + E8BAAD1EA3FE6EB29E109261 /* UserRepositoryAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryAction.swift; sourceTree = ""; }; + E92A03189FF8521FF3E71A7D /* FavoriteAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteAction.swift; sourceTree = ""; }; FA63604B63E10BD6DC6520D0 /* SafariServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SafariServices.framework; path = System/Library/Frameworks/SafariServices.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -94,22 +110,52 @@ 6AE9204A886B2448F36B9293 /* UserRepositoryViewController.swift */, 1F905F35ABF1B1579FEE2B5A /* UserRepositoryViewController.xib */, 969B79174D1421B0FCFE7154 /* UserRepositoryViewDataSource.swift */, - 520A5CA79771517368B8C2A3 /* UserRepositoryViewPresenter.swift */, + 5F3E636650F822AF11679FF3 /* Flux */, ); path = UserRepository; sourceTree = ""; }; + 0FF0ADCFE3848C8129BCA358 /* Flux */ = { + isa = PBXGroup; + children = ( + C032AEE4D5BB5D771256B52A /* RepositoryAction.swift */, + 8F982A87E9372D61929C99C0 /* RepositoryDispatcher.swift */, + 28A6431841C8E9792FF44760 /* RepositoryStore.swift */, + ); + path = Flux; + sourceTree = ""; + }; + 10BBB14A2266F3F49ED35B05 /* Flux */ = { + isa = PBXGroup; + children = ( + 4D881D7443F988A4D31900D8 /* SearchAction.swift */, + 851F1BB66098A309DCBD0080 /* SearchDispatcher.swift */, + D92B37E415040318015DC44D /* SearchStore.swift */, + ); + path = Flux; + sourceTree = ""; + }; 4C14D0C3C55EC377569255FC /* Search */ = { isa = PBXGroup; children = ( 208BE35457B09256BF71DAD1 /* SearchViewController.swift */, 5EDBADE530FE153A9651F109 /* SearchViewController.xib */, 7E02ECB42B97D30045AF6AB2 /* SearchViewDataSource.swift */, - B6BDED3310BF09C5AD910C87 /* SearchViewPresenter.swift */, + 10BBB14A2266F3F49ED35B05 /* Flux */, ); path = Search; sourceTree = ""; }; + 5F3E636650F822AF11679FF3 /* Flux */ = { + isa = PBXGroup; + children = ( + E8BAAD1EA3FE6EB29E109261 /* UserRepositoryAction.swift */, + 52D4482B74EB628C1ECB704E /* UserRepositoryDispatcher.swift */, + 4C9D69C6AD033AD869008265 /* UserRepositoryStore.swift */, + ); + path = Flux; + sourceTree = ""; + }; 67400C9F67FDD6315D360767 /* Entity */ = { isa = PBXGroup; children = ( @@ -130,7 +176,7 @@ isa = PBXGroup; children = ( 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */, - 9DEBBF60E2AEFF712AA52C37 /* RepositoryViewPresenter.swift */, + 0FF0ADCFE3848C8129BCA358 /* Flux */, ); path = Repository; sourceTree = ""; @@ -157,6 +203,16 @@ path = Common; sourceTree = ""; }; + C874BA3CE4CCE615BEA46578 /* Flux */ = { + isa = PBXGroup; + children = ( + E92A03189FF8521FF3E71A7D /* FavoriteAction.swift */, + 5CD3CF12DDD7F6C42364BA9C /* FavoriteDispatcher.swift */, + DBF357511873A96AF5614198 /* FavoriteStore.swift */, + ); + path = Flux; + sourceTree = ""; + }; CDEF771CBE64BFD893F76865 /* iOSDesignPatternSamples */ = { isa = PBXGroup; children = ( @@ -172,7 +228,7 @@ 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */, E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */, 5DE1FF29F81CED271FD929C2 /* FavoriteViewDataSource.swift */, - 6911B8218A916C540302FD01 /* FavoriteViewPresenter.swift */, + C874BA3CE4CCE615BEA46578 /* Flux */, ); path = Favorite; sourceTree = ""; @@ -302,23 +358,31 @@ files = ( 5A6EC25DC03393E8F8E064FF /* ApiSession.extension.swift in Sources */, 0706D2760E1B3A990D0C277A /* AppDelegate.swift in Sources */, + 71B682A7678AE7B950DCD0F3 /* FavoriteAction.swift in Sources */, + 3E9BB9988B02A7DAC10B9F7F /* FavoriteDispatcher.swift in Sources */, D84737DB64A88B1888B8465D /* FavoriteModel.swift in Sources */, + 645A35DA317A4471244893CE /* FavoriteStore.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 */, + 865569E1E5534840C370E9B9 /* RepositoryAction.swift in Sources */, + CC83DB0B5E4F04C637367B6D /* RepositoryDispatcher.swift in Sources */, ED8C1E52A3D6C0097A18601E /* RepositoryModel.swift in Sources */, + 79C371E4A4C94800222F6518 /* RepositoryStore.swift in Sources */, 1791BB0E1AEB38868026578F /* RepositoryViewController.swift in Sources */, - 94966F6B922927E4091D087E /* RepositoryViewPresenter.swift in Sources */, + C89C03ABCFBDDB6561837465 /* SearchAction.swift in Sources */, + B9DC6F0867CFEFE02CA6D0B8 /* SearchDispatcher.swift in Sources */, 072EB0FFF0B72F37C8CF669A /* SearchModel.swift in Sources */, + F7DF91BC168C6A65DA5A2FBA /* SearchStore.swift in Sources */, 4EF255BBD109181D8AD5058F /* SearchViewController.swift in Sources */, 7F3AC0844FF343E754FD4431 /* SearchViewDataSource.swift in Sources */, - 6A27621B3A685D90F105BD65 /* SearchViewPresenter.swift in Sources */, 811C3B9B712E3B67E5AD73FD /* UIKeyboardInfo.swift in Sources */, + 22986FE76637ACF3E653A512 /* UserRepositoryAction.swift in Sources */, + 067350409E30C9B7051919A9 /* UserRepositoryDispatcher.swift in Sources */, + B5E95E30C4523271F287CBCB /* UserRepositoryStore.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 8828395..2b362a0 100644 --- a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift +++ b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift @@ -6,8 +6,9 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit +import Combine import GithubKit +import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -22,42 +23,74 @@ class AppDelegate: UIResponder, UIApplicationDelegate { for value in viewControllers.enumerated() { switch value { case let (0, nc as UINavigationController): + let repositoryDispatcher = RepositoryDispatcher() + let searchDispatcher = SearchDispatcher() + let userRepositoryDispatcher = UserRepositoryDispatcher() let searchVC = SearchViewController( - searchPresenter: SearchViewPresenter( - model: SearchModel( - sendRequest: ApiSession.shared.send, - asyncAfter: { DispatchQueue.global().asyncAfter(deadline: $0, execute: $1) }, - mainAsync: { work in DispatchQueue.main.async { work() } } - ), - mainAsync: { work in DispatchQueue.main.async { work() } }, - notificationCenter: .default + action: SearchAction( + notificationCenter: .default, + dispatcher: searchDispatcher, + searchModel: SearchModel( + sendRequest: ApiSession.shared.send + ) + ), + store: SearchStore( + dispatcher: searchDispatcher ), - makeRepositoryPresenter: { [favoriteModel] in - RepositoryViewPresenter( - repository: $0, + makeUserRepositoryAction: { user in + UserRepositoryAction( + dispatcher: userRepositoryDispatcher, + repositoryModel: RepositoryModel( + user: user, + sendRequest: ApiSession.shared.send + ) + ) + }, + makeUserRepositoryStore: { user in + UserRepositoryStore( + user: user, + dispatcher: userRepositoryDispatcher + ) + }, + makeRepositoryAction: { [favoriteModel] repository in + RepositoryAction( + repository: repository, + dispatcher: repositoryDispatcher, favoriteModel: favoriteModel ) }, - makeUserRepositoryPresenter: { - UserRepositoryViewPresenter( - model: RepositoryModel( - user: $0, - sendRequest: ApiSession.shared.send - ), - mainAsync: { work in DispatchQueue.main.async { work() } } + makeRepositoryStore: { repository in + RepositoryStore( + repository: repository, + dispatcher: repositoryDispatcher ) } ) nc.setViewControllers([searchVC], animated: false) case let (1, nc as UINavigationController): + let favoriteDispatcher = FavoriteDispatcher() + let repositoryDispatcher = RepositoryDispatcher() let favoriteVC = FavoriteViewController( - presenter: FavoriteViewPresenter(model: favoriteModel), - makeRepositoryPresenter: { [favoriteModel] in - RepositoryViewPresenter( - repository: $0, + action: FavoriteAction( + dispatcher: favoriteDispatcher, + favoriteModel: favoriteModel + ), + store: FavoriteStore( + dispatcher: favoriteDispatcher + ), + makeRepositoryAction: { [favoriteModel] repository in + RepositoryAction( + repository: repository, + dispatcher: repositoryDispatcher, favoriteModel: favoriteModel ) + }, + makeRepositoryStore: { repository in + RepositoryStore( + repository: repository, + dispatcher: repositoryDispatcher + ) } ) nc.setViewControllers([favoriteVC], animated: false) @@ -71,4 +104,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } } - diff --git a/iOSDesignPatternSamples/Sources/Common/Extension/ApiSession.extension.swift b/iOSDesignPatternSamples/Sources/Common/Extension/ApiSession.extension.swift index 1252d2c..f1528e4 100644 --- a/iOSDesignPatternSamples/Sources/Common/Extension/ApiSession.extension.swift +++ b/iOSDesignPatternSamples/Sources/Common/Extension/ApiSession.extension.swift @@ -16,4 +16,4 @@ extension ApiSession { }() } -typealias SendRequest = (T, @escaping (Result) -> ()) -> AnyCancellable +typealias SendRequest = (T) -> AnyPublisher diff --git a/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift b/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift index 8d825f8..84d4685 100644 --- a/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift +++ b/iOSDesignPatternSamples/Sources/Common/FavoriteModel.swift @@ -6,43 +6,71 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // +import Combine import GithubKit -protocol FavoriteModelDelegate: AnyObject { - func favoriteDidChange() -} - -extension FavoriteModelDelegate { - func favoriteDidChange() {} -} - protocol FavoriteModelType: AnyObject { var favorites: [Repository] { get } - var delegate: FavoriteModelDelegate? { get set } + var favoritePublisher: Published<[Repository]>.Publisher { get } func addFavorite(_ repository: Repository) func removeFavorite(_ repository: Repository) + func contains(_ repository: Repository) -> AnyPublisher } final class FavoriteModel: FavoriteModelType { - private(set) var favorites: [Repository] = [] { - didSet { - delegate?.favoriteDidChange() - } + @Published + private(set) var favorites: [Repository] = [] + var favoritePublisher: Published<[Repository]>.Publisher { + $favorites } - - weak var delegate: FavoriteModelDelegate? - + + private let _addFavorite = PassthroughSubject() + private let _removeFavorite = PassthroughSubject() + private var cancellables = Set() + + init() { + let favorites1 = _addFavorite + .flatMap { [weak self] repository -> AnyPublisher<[Repository], Never> in + guard let me = self else { + return Empty().eraseToAnyPublisher() + } + var favorites = me.favorites + if favorites.firstIndex(where: { $0.url == repository.url }) != nil { + return Empty().eraseToAnyPublisher() + } + favorites.append(repository) + return Just(favorites).eraseToAnyPublisher() + } + + let favorites2 = _removeFavorite + .flatMap { [weak self] repository -> AnyPublisher<[Repository], Never> in + guard let me = self else { + return Empty().eraseToAnyPublisher() + } + var favorites = me.favorites + guard let index = favorites.firstIndex(where: { $0.url == repository.url }) else { + return Empty().eraseToAnyPublisher() + } + favorites.remove(at: index) + return Just(favorites).eraseToAnyPublisher() + } + + favorites1.merge(with: favorites2) + .assign(to: \.favorites, on: self) + .store(in: &cancellables) + } + func addFavorite(_ repository: Repository) { - if favorites.firstIndex(where: { $0.url == repository.url }) != nil { - return - } - favorites.append(repository) + _addFavorite.send(repository) } - + func removeFavorite(_ repository: Repository) { - guard let index = favorites.firstIndex(where: { $0.url == repository.url }) else { - return - } - favorites.remove(at: index) + _removeFavorite.send(repository) + } + + func contains(_ repository: Repository) -> AnyPublisher { + $favorites + .map { $0.contains { $0.url == repository.url } } + .eraseToAnyPublisher() } } diff --git a/iOSDesignPatternSamples/Sources/Common/RepositoryModel.swift b/iOSDesignPatternSamples/Sources/Common/RepositoryModel.swift index 0b3af5c..ddd9372 100644 --- a/iOSDesignPatternSamples/Sources/Common/RepositoryModel.swift +++ b/iOSDesignPatternSamples/Sources/Common/RepositoryModel.swift @@ -9,17 +9,10 @@ import Combine import GithubKit -protocol RepositoryModelDelegate: AnyObject { - func repositoryModel(_ repositoryModel: RepositoryModel, didChange isFetchingRepositories: Bool) - func repositoryModel(_ repositoryModel: RepositoryModel, didChange repositories: [Repository]) - func repositoryModel(_ repositoryModel: RepositoryModel, didChange totalCount: Int) -} - protocol RepositoryModelType: AnyObject { - var user: User { get } - var delegate: RepositoryModelDelegate? { get set } - var query: String { get } - var totalCount: Int { get } + var repositoriesPublisher: Published<[Repository]>.Publisher { get } + var isFetchingRepositoriesPublisher: Published.Publisher { get } + var totalCountPublisher: Published.Publisher { get } var repositories: [Repository] { get } var isFetchingRepositories: Bool { get } func fetchRepositories() @@ -27,60 +20,88 @@ protocol RepositoryModelType: AnyObject { final class RepositoryModel: RepositoryModelType { - let user: User - weak var delegate: RepositoryModelDelegate? - - private(set) var query: String = "" - private(set) var totalCount: Int = 0 { - didSet { - delegate?.repositoryModel(self, didChange: totalCount) - } + var repositoriesPublisher: Published<[Repository]>.Publisher { + $repositories } - private(set) var repositories: [Repository] = [] { - didSet { - delegate?.repositoryModel(self, didChange: repositories) - } + var isFetchingRepositoriesPublisher: Published.Publisher { + $isFetchingRepositories } - private(set) var isFetchingRepositories = false { - didSet { - delegate?.repositoryModel(self, didChange: isFetchingRepositories) - } + var totalCountPublisher: Published.Publisher { + $totalCount } + @Published + private(set) var repositories: [Repository] = [] + @Published + private(set) var isFetchingRepositories = false + @Published + private var totalCount = 0 + @Published private var pageInfo: PageInfo? - private var cancellable: AnyCancellable? - private let sendRequest: SendRequest + + private var cancellables = Set() + + private let _fetchRepositories = PassthroughSubject() init( user: User, sendRequest: @escaping SendRequest ) { - self.user = user - self.sendRequest = sendRequest - } + let requestTrigger = $pageInfo + .map { (user, $0) } - func fetchRepositories() { - if cancellable != nil { return } - if let pageInfo = pageInfo, !pageInfo.hasNextPage || pageInfo.endCursor == nil { return } - isFetchingRepositories = true - let request = UserNodeRequest(id: user.id, after: pageInfo?.endCursor) - self.cancellable = sendRequest(request) { [weak self] in - guard let me = self else { - return + let initialLoadRequest = _fetchRepositories + .flatMap { _ in + requestTrigger + .prefix(1) } + .filter { $1 == nil } - switch $0 { - case .success(let value): - me.pageInfo = value.pageInfo - me.repositories.append(contentsOf: value.nodes) - me.totalCount = value.totalCount + let loadMoreRequest = _fetchRepositories + .flatMap { _ in + requestTrigger + .prefix(1) + } + .filter { $1 != nil } + + let willStartRequest = initialLoadRequest + .merge(with: loadMoreRequest) + .flatMap { user, pageInfo -> AnyPublisher in + if let pageInfo = pageInfo, !pageInfo.hasNextPage { + return Empty().eraseToAnyPublisher() + } + let request = UserNodeRequest(id: user.id, after: pageInfo?.endCursor) + return Just(request).eraseToAnyPublisher() + } + .removeDuplicates { $0.id == $1.id && $0.after == $1.after } - case .failure(let error): - print(error) + willStartRequest + .handleEvents(receiveOutput: { [weak self] _ in + self?.isFetchingRepositories = true + }) + .flatMap { request -> AnyPublisher, Never> in + sendRequest(request) + .catch { _ -> AnyPublisher, Never> in + Empty().eraseToAnyPublisher() + } + .prefix(1) + .eraseToAnyPublisher() } + .handleEvents(receiveOutput: { [weak self] _ in + self?.isFetchingRepositories = false + }) + .sink { [weak self] response in + guard let me = self else { + return + } + me.pageInfo = response.pageInfo + me.repositories = me.repositories + response.nodes + me.totalCount = response.totalCount + } + .store(in: &cancellables) + } - me.isFetchingRepositories = false - me.cancellable = nil - } + func fetchRepositories() { + _fetchRepositories.send() } } diff --git a/iOSDesignPatternSamples/Sources/Common/SearchModel.swift b/iOSDesignPatternSamples/Sources/Common/SearchModel.swift index 05182bc..d9f0077 100644 --- a/iOSDesignPatternSamples/Sources/Common/SearchModel.swift +++ b/iOSDesignPatternSamples/Sources/Common/SearchModel.swift @@ -10,22 +10,16 @@ import Combine import GithubKit import Foundation -protocol SearchModelDelegate: AnyObject { - func searchModel(_ searchModel: SearchModel, didRecieve errorMessage: ErrorMessage) - func searchModel(_ searchModel: SearchModel, didChange isFetchingUsers: Bool) - func searchModel(_ searchModel: SearchModel, didChange users: [User]) - func searchModel(_ searchModel: SearchModel, didChange totalCount: Int) -} - struct ErrorMessage { let title: String let message: String } protocol SearchModelType: AnyObject { - var delegate: SearchModelDelegate? { get set } - var query: String { get } - var totalCount: Int { get } + var errorMessage: AnyPublisher { get } + var usersPublisher: Published<[User]>.Publisher { get } + var isFetchingUsersPublisher: Published.Publisher { get } + var totalCountPublisher: Published.Publisher { get } var users: [User] { get } var isFetchingUsers: Bool { get } func fetchUsers() @@ -33,105 +27,126 @@ protocol SearchModelType: AnyObject { } final class SearchModel: SearchModelType { + let errorMessage: AnyPublisher - weak var delegate: SearchModelDelegate? - - private(set) var query: String = "" - private(set) var totalCount: Int = 0 { - didSet { - delegate?.searchModel(self, didChange: totalCount) - } + var usersPublisher: Published<[User]>.Publisher { + $users } - private(set) var users: [User] = [] { - didSet { - delegate?.searchModel(self, didChange: users) - } + var isFetchingUsersPublisher: Published.Publisher { + $isFetchingUsers } - private(set) var isFetchingUsers = false { - didSet { - delegate?.searchModel(self, didChange: isFetchingUsers) - } + var totalCountPublisher: Published.Publisher { + $totalCount } + @Published + private(set) var users: [User] = [] + @Published + private(set) var isFetchingUsers = false + @Published + private var totalCount = 0 + @Published private var pageInfo: PageInfo? - private var cancellable: AnyCancellable? - - private lazy var debounce: (_ action: @escaping () -> ()) -> () = { - var lastFireTime: DispatchTime = .now() - let delay: DispatchTimeInterval = .milliseconds(500) - return { [delay, asyncAfter, mainAsync] action in - let deadline: DispatchTime = .now() + delay - lastFireTime = .now() - asyncAfter(deadline) { [delay] in - let now: DispatchTime = .now() - let when: DispatchTime = lastFireTime + delay - if now < when { return } - lastFireTime = .now() - mainAsync(action) - } - } - }() + @Published + private var query: String? + + private var cancellable = Set() - private let sendRequest: SendRequest - private let asyncAfter: (DispatchTime, @escaping @convention(block) () -> Void) -> Void - private let mainAsync: (@escaping () -> Void) -> Void + private let _fetchUsers = PassthroughSubject() + private let _feachUsersWithQuery = PassthroughSubject() init( - sendRequest: @escaping SendRequest, - asyncAfter: @escaping (DispatchTime, @escaping @convention(block) () -> Void) -> Void, - mainAsync: @escaping (@escaping () -> Void) -> Void + sendRequest: @escaping SendRequest ) { - self.sendRequest = sendRequest - self.asyncAfter = asyncAfter - self.mainAsync = mainAsync - } + let _errorMessage = PassthroughSubject() + self.errorMessage = _errorMessage.eraseToAnyPublisher() - func fetchUsers() { - if query.isEmpty || cancellable != nil { return } - if let pageInfo = pageInfo, !pageInfo.hasNextPage || pageInfo.endCursor == nil { return } - isFetchingUsers = true - let request = SearchUserRequest(query: query, after: pageInfo?.endCursor) - self.cancellable = sendRequest(request) { [weak self] in - guard let me = self else { - return + let pageInfo = $pageInfo + + let query = $query + .map { $0 ?? "" } + + let initialLoad = query + .filter { !$0.isEmpty } + .flatMap { query in + pageInfo + .map { (query, $0) } + .prefix(1) } - switch $0 { - case .success(let value): - me.pageInfo = value.pageInfo - me.users.append(contentsOf: value.nodes) - me.totalCount = value.totalCount + let loadMore = _fetchUsers + .flatMap { _ in + query + .combineLatest(pageInfo) + .prefix(1) + } + .filter { !$0.isEmpty && $1 != nil } + + _feachUsersWithQuery + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] in + self?.pageInfo = nil + self?.users = [] + self?.totalCount = 0 + self?.query = $0 + } + .store(in: &cancellable) - case .failure(let error): - if case .emptyToken? = (error as? ApiSession.Error) { + let requestWillStart = initialLoad.merge(with: loadMore) + .flatMap { query, pageInfo -> AnyPublisher in + if let pageInfo = pageInfo, !pageInfo.hasNextPage { + return Empty().eraseToAnyPublisher() + } + let request = SearchUserRequest(query: query, after: pageInfo?.endCursor) + return Just(request).eraseToAnyPublisher() + } + .removeDuplicates { $0.query == $1.query && $0.after == $1.after } + + requestWillStart + .handleEvents(receiveOutput: { [weak self] _ in + self?.isFetchingUsers = true + }) + .flatMap { request -> AnyPublisher, Error>, Never> in + sendRequest(request) + .map { response in + Result, Error>.success(response) + } + .catch { error in + Just(Result, Error>.failure(error)) + } + .prefix(1) + .eraseToAnyPublisher() + } + .handleEvents(receiveOutput: { [weak self] _ in + self?.isFetchingUsers = false + }) + .sink { [weak self] result in + guard let me = self else { + return + } + switch result { + case let .success(response): + me.pageInfo = response.pageInfo + me.users = me.users + response.nodes + me.totalCount = response.totalCount + case let .failure(error): + guard case .emptyToken? = (error as? ApiSession.Error) else { + return + } let title = "Access Token Error" let message = "\"Github Personal Access Token\" is Required.\n Please set it in ApiSession.extension.swift!" - let errorMessage = ErrorMessage(title: title, message: message) - me.delegate?.searchModel(me, didRecieve: errorMessage) + _errorMessage.send(ErrorMessage(title: title, message: message)) } } - - me.isFetchingUsers = false - me.cancellable = nil - } + .store(in: &cancellable) } func fetchUsers(withQuery query: String) { - debounce { [weak self] in - guard let me = self else { - return - } + _feachUsersWithQuery.send(query) + } - let oldValue = me.query - me.query = query - if query != oldValue { - me.users.removeAll() - me.pageInfo = nil - me.totalCount = 0 - } - me.cancellable?.cancel() - me.cancellable = nil - me.fetchUsers() - } + func fetchUsers() { + _fetchUsers.send() } } diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift index 97e422f..4930e5d 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift @@ -6,29 +6,34 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit +import Combine import GithubKit +import UIKit -protocol FavoriteView: class { - func reloadData() - func showRepository(with repository: Repository) -} - -final class FavoriteViewController: UIViewController, FavoriteView { +final class FavoriteViewController: UIViewController { @IBOutlet private(set) weak var tableView: UITableView! - let presenter: FavoritePresenter + let action: FavoriteActionType + let store: FavoriteStoreType let dataSource: FavoriteViewDataSource - - private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter + private let makeRepositoryAction: (Repository) -> RepositoryActionType + private let makeRepositoryStore: (Repository) -> RepositoryStoreType + private var cancellables = Set() init( - presenter: FavoritePresenter, - makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter + action: FavoriteActionType, + store: FavoriteStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType ) { - self.presenter = presenter - self.dataSource = FavoriteViewDataSource(presenter: presenter) - self.makeRepositoryPresenter = makeRepositoryPresenter + self.action = action + self.store = store + self.dataSource = FavoriteViewDataSource( + action: action, + store: store + ) + self.makeRepositoryAction = makeRepositoryAction + self.makeRepositoryStore = makeRepositoryStore super.init(nibName: FavoriteViewController.className, bundle: nil) } @@ -40,18 +45,38 @@ final class FavoriteViewController: UIViewController, FavoriteView { super.viewDidLoad() title = "On Memory Favorite" - - presenter.view = self dataSource.configure(with: tableView) + + store.selectedRepository + .receive(on: DispatchQueue.main) + .sink(receiveValue: showRepository) + .store(in: &cancellables) + + store.reloadData + .receive(on: DispatchQueue.main) + .sink(receiveValue: reloadData) + .store(in: &cancellables) + + action.load() } - - func showRepository(with repository: Repository) { - let repositoryPresenter = makeRepositoryPresenter(repository) - let vc = RepositoryViewController(presenter: repositoryPresenter) - navigationController?.pushViewController(vc, animated: true) + + private var showRepository: (Repository) -> Void { + { [weak self] repository in + guard let me = self else { + return + } + + let vc = RepositoryViewController( + action: me.makeRepositoryAction(repository), + store: me.makeRepositoryStore(repository) + ) + me.navigationController?.pushViewController(vc, animated: true) + } } - - func reloadData() { - tableView?.reloadData() + + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() + } } } diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift index 923a57d..0b5a26d 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift @@ -7,32 +7,37 @@ // import Foundation -import UIKit import GithubKit +import UIKit final class FavoriteViewDataSource: NSObject { - fileprivate let presenter: FavoritePresenter - - init(presenter: FavoritePresenter) { - self.presenter = presenter + private let action: FavoriteActionType + private let store: FavoriteStoreType + + init( + action: FavoriteActionType, + store: FavoriteStoreType + ) { + self.action = action + self.store = store } - + 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 + return store.favorites.count } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - let repository = presenter.favoriteRepository(at: indexPath.row) + let repository = store.favorites[indexPath.row] cell.configure(with: repository) return cell } @@ -41,11 +46,11 @@ extension FavoriteViewDataSource: UITableViewDataSource { extension FavoriteViewDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - presenter.showFavoriteRepository(at: indexPath.row) + action.select(from: store.favorites, for: indexPath) } - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let repository = presenter.favoriteRepository(at: indexPath.row) + let repository = store.favorites[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 deleted file mode 100644 index 49db248..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewPresenter.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// FavoritePresenter.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/10. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Foundation -import GithubKit - -protocol FavoritePresenter: class { - var view: FavoriteView? { get set } - var numberOfFavorites: Int { get } - func favoriteRepository(at index: Int) -> Repository - func showFavoriteRepository(at index: Int) -} - -final class FavoriteViewPresenter: FavoritePresenter { - weak var view: FavoriteView? - - var numberOfFavorites: Int { - return model.favorites.count - } - - private let model: FavoriteModelType - - init(model: FavoriteModelType) { - self.model = model - self.model.delegate = self - } - - func favoriteRepository(at index: Int) -> Repository { - return model.favorites[index] - } - - func showFavoriteRepository(at index: Int) { - let repository = model.favorites[index] - view?.showRepository(with: repository) - } -} - -extension FavoriteViewPresenter: FavoriteModelDelegate { - func favoriteDidChange() { - view?.reloadData() - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteAction.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteAction.swift new file mode 100644 index 0000000..880446c --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteAction.swift @@ -0,0 +1,49 @@ +// +// FavoriteAction.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import Foundation +import GithubKit + +protocol FavoriteActionType: AnyObject { + func select( + from repositories: [Repository], + for indexPath: IndexPath + ) + func load() +} + +final class FavoriteAction: FavoriteActionType { + private let _load = PassthroughSubject() + private var cancellables = Set() + private let dispatcher: FavoriteDispatcher + + init( + dispatcher: FavoriteDispatcher, + favoriteModel: FavoriteModelType + ) { + self.dispatcher = dispatcher + + _load + .map { favoriteModel.favoritePublisher } + .switchToLatest() + .sink(receiveValue: dispatcher.favorites.send) + .store(in: &cancellables) + } + + func select( + from repositories: [Repository], + for indexPath: IndexPath + ) { + let repository = repositories[indexPath.row] + dispatcher.selectedRepository.send(repository) + } + + func load() { + _load.send() + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteDispatcher.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteDispatcher.swift new file mode 100644 index 0000000..799001b --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteDispatcher.swift @@ -0,0 +1,14 @@ +// +// FavoriteDispatcher.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import GithubKit + +final class FavoriteDispatcher { + let favorites = PassthroughSubject<[Repository], Never>() + let selectedRepository = PassthroughSubject() +} diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteStore.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteStore.swift new file mode 100644 index 0000000..2d456cb --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/Flux/FavoriteStore.swift @@ -0,0 +1,46 @@ +// +// FavoriteStore.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import GithubKit + +protocol FavoriteStoreType: AnyObject { + var favorites: [Repository] { get } + var reloadData: AnyPublisher { get } + var selectedRepository: AnyPublisher { get } +} + +final class FavoriteStore: FavoriteStoreType { + @Published + private(set) var favorites: [Repository] = [] + + let reloadData: AnyPublisher + let selectedRepository: AnyPublisher + + private var cancellable = Set() + + init( + dispatcher: FavoriteDispatcher + ) { + let reloadData = PassthroughSubject() + + self.reloadData = reloadData + .eraseToAnyPublisher() + + self.selectedRepository = dispatcher.selectedRepository + .eraseToAnyPublisher() + + dispatcher.favorites + .assign(to: \.favorites, on: self) + .store(in: &cancellable) + + $favorites + .map { _ in } + .sink(receiveValue: reloadData.send) + .store(in: &cancellable) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryAction.swift b/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryAction.swift new file mode 100644 index 0000000..9d8d4ef --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryAction.swift @@ -0,0 +1,59 @@ +// +// RepositoryAction.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import Foundation +import GithubKit + +protocol RepositoryActionType: AnyObject { + func toggleFavorite() + func load() +} + +final class RepositoryAction: RepositoryActionType { + private let favoriteModel: FavoriteModelType + private let _toggleFavorite = PassthroughSubject() + private let _load = PassthroughSubject() + private var cancellable = Set() + + init( + repository: Repository, + dispatcher: RepositoryDispatcher, + favoriteModel: FavoriteModelType + ) { + self.favoriteModel = favoriteModel + + _load + .map { favoriteModel.contains(repository) } + .switchToLatest() + .map { $0 ? "Remove" : "Add" } + .sink(receiveValue: dispatcher.favoriteButtonTitle.send) + .store(in: &cancellable) + + _toggleFavorite + .flatMap { + favoriteModel.contains(repository) + .prefix(1) + } + .sink { contains in + if contains { + favoriteModel.removeFavorite(repository) + } else { + favoriteModel.addFavorite(repository) + } + } + .store(in: &cancellable) + } + + func toggleFavorite() { + _toggleFavorite.send() + } + + func load() { + _load.send() + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryDispatcher.swift b/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryDispatcher.swift new file mode 100644 index 0000000..4894c9f --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryDispatcher.swift @@ -0,0 +1,12 @@ +// +// RepositoryDispatcher.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine + +final class RepositoryDispatcher { + let favoriteButtonTitle = PassthroughSubject() +} diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryStore.swift b/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryStore.swift new file mode 100644 index 0000000..3d2e4df --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Repository/Flux/RepositoryStore.swift @@ -0,0 +1,38 @@ +// +// RepositoryStore.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import Foundation +import GithubKit + +protocol RepositoryStoreType: AnyObject { + var repository: Repository { get } + var favoriteButtonTitlePublisher: Published.Publisher { get } +} + +final class RepositoryStore: RepositoryStoreType { + let repository: Repository + + @Published + private(set) var favoriteButtonTitle = "" + var favoriteButtonTitlePublisher: Published.Publisher { + $favoriteButtonTitle + } + + private var cancellables = Set() + + init( + repository: Repository, + dispatcher: RepositoryDispatcher + ) { + self.repository = repository + + dispatcher.favoriteButtonTitle + .assign(to: \.favoriteButtonTitle, on: self) + .store(in: &cancellables) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift index 038e54a..1d64e1d 100644 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift @@ -6,43 +6,49 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit -import SafariServices +import Combine import GithubKit +import SafariServices +import UIKit -protocol RepositoryView: class { - func updateFavoriteButtonTitle(_ title: String) -} +final class RepositoryViewController: SFSafariViewController { + private var cancellables = Set() + private let action: RepositoryActionType + private let store: RepositoryStoreType -final class RepositoryViewController: SFSafariViewController, RepositoryView { - private(set) lazy var favoriteButtonItem: UIBarButtonItem = { - return UIBarButtonItem(title: self.presenter.favoriteButtonTitle, - style: .plain, - target: self, - action: #selector(RepositoryViewController.favoriteButtonTap(_:))) - }() - private let presenter: RepositoryPresenter - - init(presenter: RepositoryPresenter) { - self.presenter = presenter - super.init(url: presenter.url, configuration: .init()) - hidesBottomBarWhenPushed = true + private let _favoriteButtonTap = PassthroughSubject() + init( + action: RepositoryActionType, + store: RepositoryStoreType + ) { + self.action = action + self.store = store + super.init(url: store.repository.url, configuration: .init()) + hidesBottomBarWhenPushed = true } - + override func viewDidLoad() { super.viewDidLoad() - + + let favoriteButtonItem = UIBarButtonItem( + title: nil, + style: .plain, + target: self, + action: #selector(self.favoriteButtonTap(_:)) + ) navigationItem.rightBarButtonItem = favoriteButtonItem - presenter.view = self - } - - @objc private func favoriteButtonTap(_ sender: UIBarButtonItem) { - presenter.favoriteButtonTap() + store.favoriteButtonTitlePublisher + .map(Optional.some) + .receive(on: DispatchQueue.main) + .assign(to: \.title, on: favoriteButtonItem) + .store(in: &cancellables) + + action.load() } - - func updateFavoriteButtonTitle(_ title: String) { - favoriteButtonItem.title = title + + @objc private func favoriteButtonTap(_: UIBarButtonItem) { + action.toggleFavorite() } } diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift deleted file mode 100644 index afbd3df..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewPresenter.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// RepositoryViewPresenter.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/11. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Foundation -import GithubKit - -protocol RepositoryPresenter: class { - var view: RepositoryView? { get set } - var url: URL { get } - var favoriteButtonTitle: String { get } - func favoriteButtonTap() -} - -final class RepositoryViewPresenter: RepositoryPresenter { - weak var view: RepositoryView? - private let favoriteModel: FavoriteModelType - private let repository: Repository - - var favoriteButtonTitle: String { - return favoriteModel.favorites.contains(repository) ? "Remove" : "Add" - } - - var url: URL { - return repository.url - } - - init( - repository: Repository, - favoriteModel: FavoriteModelType - ) { - self.repository = repository - self.favoriteModel = favoriteModel - } - - func favoriteButtonTap() { - if favoriteModel.favorites.contains(repository) { - favoriteModel.removeFavorite(repository) - view?.updateFavoriteButtonTitle(favoriteButtonTitle) - } else { - favoriteModel.addFavorite(repository) - view?.updateFavoriteButtonTitle(favoriteButtonTitle) - } - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchAction.swift b/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchAction.swift new file mode 100644 index 0000000..5999731 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchAction.swift @@ -0,0 +1,151 @@ +// +// SearchAction.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import Foundation +import GithubKit +import UIKit + +protocol SearchActionType: AnyObject { + func setlect( + from user: [User], + at indexPath: IndexPath + ) + func isViewAppearing(_ isViewAppearing: Bool) + func searchText(_ text: String?) + func isReachedBottom(_ isReachedBottom: Bool) + func headerFooterView(_ view: UIView) + func load() +} + +final class SearchAction: SearchActionType { + private let dispatcher: SearchDispatcher + + private let _isViewAppearing = PassthroughSubject() + private let _searchText = PassthroughSubject() + private let _isReachedBottom = PassthroughSubject() + private let _headerFooterView = PassthroughSubject() + private let _load = PassthroughSubject() + private var cancellables = Set() + + init( + notificationCenter: NotificationCenter, + dispatcher: SearchDispatcher, + searchModel: SearchModelType + ) { + self.dispatcher = dispatcher + + func handleKeyboard( + name: Notification.Name, + subject: PassthroughSubject + ) -> Void { + _isViewAppearing + .map { isViewAppearing -> AnyPublisher in + guard isViewAppearing else { + return Empty().eraseToAnyPublisher() + } + return notificationCenter.publisher(for: name) + .flatMap { notification -> AnyPublisher in + guard let info = UIKeyboardInfo(notification: notification) else { + return Empty().eraseToAnyPublisher() + } + return Just(info).eraseToAnyPublisher() + } + .eraseToAnyPublisher() + } + .switchToLatest() + .sink(receiveValue: subject.send) + .store(in: &cancellables) + } + + handleKeyboard( + name: UIResponder.keyboardWillShowNotification, + subject: dispatcher.keyboardWillShow + ) + + handleKeyboard( + name: UIResponder.keyboardWillHideNotification, + subject: dispatcher.keyboardWillHide + ) + + _searchText + .sink { + guard let text = $0 else { + return + } + searchModel.fetchUsers(withQuery: text) + } + .store(in: &cancellables) + + searchModel.errorMessage + .sink(receiveValue: dispatcher.accessTokenAlert.send) + .store(in: &cancellables) + + _load + .map { searchModel.isFetchingUsersPublisher } + .switchToLatest() + .sink(receiveValue: dispatcher.isFetchingUsers.send) + .store(in: &cancellables) + + _load + .map { searchModel.usersPublisher } + .switchToLatest() + .sink(receiveValue: dispatcher.users.send) + .store(in: &cancellables) + + _load + .map { + searchModel.totalCountPublisher + .combineLatest(searchModel.usersPublisher) + } + .switchToLatest() + .map { "\($1.count) / \($0)" } + .sink(receiveValue: dispatcher.countString.send) + .store(in: &cancellables) + + _isReachedBottom + .removeDuplicates() + .filter { $0 } + .sink { _ in + searchModel.fetchUsers() + } + .store(in: &cancellables) + + _headerFooterView + .combineLatest(searchModel.isFetchingUsersPublisher) + .sink(receiveValue: dispatcher.updateLoadingView.send) + .store(in: &cancellables) + } + + func setlect( + from user: [User], + at indexPath: IndexPath + ) { + let user = user[indexPath.row] + dispatcher.selectedUser.send(user) + } + + func isViewAppearing(_ isViewAppearing: Bool) { + _isViewAppearing.send(isViewAppearing) + } + + func searchText(_ text: String?) { + _searchText.send(text) + } + + func isReachedBottom(_ isReachedBottom: Bool) { + _isReachedBottom.send(isReachedBottom) + } + + func headerFooterView(_ view: UIView) { + _headerFooterView.send(view) + } + + func load() { + _load.send() + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchDispatcher.swift b/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchDispatcher.swift new file mode 100644 index 0000000..2c3be84 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchDispatcher.swift @@ -0,0 +1,22 @@ +// +// SearchDispatcher.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import Foundation +import GithubKit +import UIKit + +final class SearchDispatcher { + let selectedUser = PassthroughSubject() + let updateLoadingView = PassthroughSubject<(UIView, Bool), Never>() + let countString = PassthroughSubject() + let users = PassthroughSubject<[User], Never>() + let isFetchingUsers = PassthroughSubject() + let keyboardWillShow = PassthroughSubject() + let keyboardWillHide = PassthroughSubject() + let accessTokenAlert = PassthroughSubject() +} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchStore.swift b/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchStore.swift new file mode 100644 index 0000000..374ab50 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/Flux/SearchStore.swift @@ -0,0 +1,80 @@ +// +// SearchStore.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import Foundation +import GithubKit +import UIKit + +protocol SearchStoreType: AnyObject { + var users: [User] { get } + var isFetchingUsers: Bool { get } + var countStringPublisher: Published.Publisher { get } + var selectedUser: AnyPublisher { get } + var reloadData: AnyPublisher { get } + var updateLoadingView: AnyPublisher<(UIView, Bool), Never> { get } + var keyboardWillShow: AnyPublisher { get } + var keyboardWillHide: AnyPublisher { get } + var accessTokenAlert: AnyPublisher { get } +} + +final class SearchStore: SearchStoreType { + @Published + private(set) var users: [User] = [] + @Published + private(set) var isFetchingUsers = false + @Published + private(set) var countString: String = "" + + var countStringPublisher: Published.Publisher { + $countString + } + + let selectedUser: AnyPublisher + let reloadData: AnyPublisher + let updateLoadingView: AnyPublisher<(UIView, Bool), Never> + let keyboardWillShow: AnyPublisher + let keyboardWillHide: AnyPublisher + let accessTokenAlert: AnyPublisher + + private var cancellables = Set() + + init( + dispatcher: SearchDispatcher + ) { + self.selectedUser = dispatcher.selectedUser + .eraseToAnyPublisher() + self.updateLoadingView = dispatcher.updateLoadingView + .eraseToAnyPublisher() + self.keyboardWillHide = dispatcher.keyboardWillHide + .eraseToAnyPublisher() + self.keyboardWillShow = dispatcher.keyboardWillShow + .eraseToAnyPublisher() + self.accessTokenAlert = dispatcher.accessTokenAlert + .eraseToAnyPublisher() + let reloadData = PassthroughSubject() + self.reloadData = reloadData.eraseToAnyPublisher() + + dispatcher.countString + .assign(to: \.countString, on: self) + .store(in: &cancellables) + + dispatcher.isFetchingUsers + .assign(to: \.isFetchingUsers, on: self) + .store(in: &cancellables) + + dispatcher.users + .assign(to: \.users, on: self) + .store(in: &cancellables) + + $users + .map { _ in } + .merge(with: $isFetchingUsers.map { _ in }) + .sink(receiveValue: reloadData.send) + .store(in: &cancellables) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift index 3e93d8a..95fba10 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift @@ -10,17 +10,7 @@ import Combine import GithubKit import UIKit -protocol SearchView: class { - func reloadData() - func keyboardWillShow(with keyboardInfo: UIKeyboardInfo) - func keyboardWillHide(with keyboardInfo: UIKeyboardInfo) - func showUserRepository(with user: User) - func updateTotalCountLabel(_ countText: String) - func updateLoadingView(with view: UIView, isLoading: Bool) - func showEmptyTokenError(errorMessage: ErrorMessage) -} - -final class SearchViewController: UIViewController, SearchView { +final class SearchViewController: UIViewController { @IBOutlet private(set) weak var totalCountLabel: UILabel! @IBOutlet private(set) weak var tableView: UITableView! @@ -29,21 +19,33 @@ final class SearchViewController: UIViewController, SearchView { let searchBar = UISearchBar(frame: .zero) let loadingView = LoadingView() - let searchPresenter: SearchPresenter + let action: SearchActionType + let store: SearchStoreType let dataSource: SearchViewDataSource - private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter - private let makeUserRepositoryPresenter: (User) -> UserRepositoryPresenter + private let makeUserRepositoryAction: (User) -> UserRepositoryActionType + private let makeUserRepositoryStore: (User) -> UserRepositoryStoreType + + private let makeRepositoryAction: (Repository) -> RepositoryActionType + private let makeRepositoryStore: (Repository) -> RepositoryStoreType + + private var cancellables = Set() init( - searchPresenter: SearchPresenter, - makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter, - makeUserRepositoryPresenter: @escaping (User) -> UserRepositoryPresenter + action: SearchActionType, + store: SearchStoreType, + makeUserRepositoryAction: @escaping (User) -> UserRepositoryActionType, + makeUserRepositoryStore: @escaping (User) -> UserRepositoryStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType ) { - self.searchPresenter = searchPresenter - self.dataSource = SearchViewDataSource(presenter: searchPresenter) - self.makeRepositoryPresenter = makeRepositoryPresenter - self.makeUserRepositoryPresenter = makeUserRepositoryPresenter + self.action = action + self.store = store + self.makeUserRepositoryAction = makeUserRepositoryAction + self.makeUserRepositoryStore = makeUserRepositoryStore + self.makeRepositoryAction = makeRepositoryAction + self.makeRepositoryStore = makeRepositoryStore + self.dataSource = SearchViewDataSource(action: action, store: store) super.init(nibName: SearchViewController.className, bundle: nil) } @@ -55,95 +57,156 @@ final class SearchViewController: UIViewController, SearchView { super.viewDidLoad() navigationItem.titleView = searchBar - searchBar.delegate = self searchBar.placeholder = "Input user name" - + searchBar.delegate = self + dataSource.configure(with: tableView) - searchPresenter.view = self + // observe viewModel + store.accessTokenAlert + .receive(on: DispatchQueue.main) + .sink(receiveValue: showAccessTokenAlert) + .store(in: &cancellables) + + store.keyboardWillShow + .receive(on: DispatchQueue.main) + .sink(receiveValue: keyboardWillShow) + .store(in: &cancellables) + + store.keyboardWillHide + .receive(on: DispatchQueue.main) + .sink(receiveValue: keyboardWillHide) + .store(in: &cancellables) + + store.countStringPublisher + .map(Optional.some) + .receive(on: DispatchQueue.main) + .assign(to: \.text, on: totalCountLabel) + .store(in: &cancellables) + + store.reloadData + .receive(on: DispatchQueue.main) + .sink(receiveValue: reloadData) + .store(in: &cancellables) + + store.selectedUser + .receive(on: DispatchQueue.main) + .sink(receiveValue: showUserRepository) + .store(in: &cancellables) + + store.updateLoadingView + .receive(on: DispatchQueue.main) + .sink(receiveValue: updateLoadingView) + .store(in: &cancellables) + + action.load() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + action.isViewAppearing(true) } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - searchPresenter.viewWillAppear() + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + action.isViewAppearing(false) } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if searchBar.isFirstResponder { - searchBar.resignFirstResponder() + searchBar.resignFirstResponder() + } + + private var showAccessTokenAlert: (ErrorMessage) -> Void { + { [weak self] error in + guard let me = self else { + return + } + let alert = UIAlertController(title: error.title, message: error.message, preferredStyle: .alert) + me.present(alert, animated: false, completion: nil) + } + } + + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() + } + } + + private var keyboardWillShow: (UIKeyboardInfo) -> Void { + { [weak self] keyboardInfo in + guard let me = self else { + return + } + me.view.layoutIfNeeded() + let extra = me.tabBarController?.tabBar.bounds.height ?? 0 + me.tableViewBottomConstraint.constant = keyboardInfo.frame.size.height - extra + UIView.animate(withDuration: keyboardInfo.animationDuration, + delay: 0, + options: keyboardInfo.animationCurve, + animations: { me.view.layoutIfNeeded() }, + completion: nil) + } + } + + private var keyboardWillHide: (UIKeyboardInfo) -> Void { + { [weak self] keyboardInfo in + guard let me = self else { + return + } + me.view.layoutIfNeeded() + me.tableViewBottomConstraint.constant = 0 + UIView.animate(withDuration: keyboardInfo.animationDuration, + delay: 0, + options: keyboardInfo.animationCurve, + animations: { me.view.layoutIfNeeded() }, + completion: nil) + } + } + + private var showUserRepository: (User) -> Void { + { [weak self] user in + guard let me = self else { + return + } + let vc = UserRepositoryViewController( + action: me.makeUserRepositoryAction(user), + store: me.makeUserRepositoryStore(user), + makeRepositoryAction: me.makeRepositoryAction, + makeRepositoryStore: me.makeRepositoryStore + ) + me.navigationController?.pushViewController(vc, animated: true) + } + } + + private var updateLoadingView: (UIView, Bool) -> Void { + { [weak self] view, isLoading in + guard let me = self else { + return + } + me.loadingView.removeFromSuperview() + me.loadingView.isLoading = isLoading + me.loadingView.add(to: view) } - searchPresenter.viewWillDisappear() - } - - func reloadData() { - tableView.reloadData() - } - - func keyboardWillShow(with keyboardInfo: UIKeyboardInfo) { - view.layoutIfNeeded() - let extra = tabBarController?.tabBar.bounds.height ?? 0 - tableViewBottomConstraint.constant = keyboardInfo.frame.size.height - extra - UIView.animate(withDuration: keyboardInfo.animationDuration, - delay: 0, - options: keyboardInfo.animationCurve, - animations: { self.view.layoutIfNeeded() }, - completion: nil) - } - - func keyboardWillHide(with keyboardInfo: UIKeyboardInfo) { - view.layoutIfNeeded() - tableViewBottomConstraint.constant = 0 - UIView.animate(withDuration: keyboardInfo.animationDuration, - delay: 0, - options: keyboardInfo.animationCurve, - animations: { self.view.layoutIfNeeded() }, - completion: nil) - } - - func showUserRepository(with user: User) { - let presenter = makeUserRepositoryPresenter(user) - let vc = UserRepositoryViewController( - userRepositoryPresenter: presenter, - makeRepositoryPresenter: makeRepositoryPresenter - ) - navigationController?.pushViewController(vc, animated: true) - } - - func updateTotalCountLabel(_ countText: String) { - totalCountLabel.text = countText - } - - func updateLoadingView(with view: UIView, isLoading: Bool) { - loadingView.removeFromSuperview() - loadingView.isLoading = isLoading - loadingView.add(to: view) - } - - func showEmptyTokenError(errorMessage: ErrorMessage) { - let alert = UIAlertController(title: errorMessage.message, - message: errorMessage.message, - preferredStyle: .alert) - present(alert, animated: false, completion: nil) } } extension SearchViewController: UISearchBarDelegate { - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.resignFirstResponder() - searchBar.showsCancelButton = false + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + searchBar.showsCancelButton = true } - + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { searchBar.resignFirstResponder() searchBar.showsCancelButton = false } - - func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { - searchBar.showsCancelButton = true + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + searchBar.showsCancelButton = false } - + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - searchPresenter.search(queryIfNeeded: searchText) + action.searchText(searchBar.text) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift index b98fb72..a68e503 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift @@ -6,47 +6,54 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // +import Combine import Foundation -import UIKit import GithubKit +import UIKit final class SearchViewDataSource: NSObject { - fileprivate let presenter: SearchPresenter - - init(presenter: SearchPresenter) { - self.presenter = presenter + private let action: SearchActionType + private let store: SearchStoreType + + init( + action: SearchActionType, + store: SearchStoreType + ) { + self.action = action + self.store = store } - + func configure(with tableView: UITableView) { tableView.dataSource = self tableView.delegate = self - + tableView.register(UserViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) + tableView.register(UITableViewHeaderFooterView.self, + forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) } } extension SearchViewDataSource: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return presenter.numberOfUsers + return store.users.count } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(UserViewCell.self, for: indexPath) - let user = presenter.user(at: indexPath.row) + let user = store.users[indexPath.row] cell.configure(with: user) return cell } - + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { return nil } - + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { return nil } - presenter.showLoadingView(on: view) + action.headerFooterView(view) return view } } @@ -54,24 +61,24 @@ extension SearchViewDataSource: UITableViewDataSource { extension SearchViewDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - presenter.showUser(at: indexPath.row) + action.setlect(from: store.users, at: indexPath) } - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let user = presenter.user(at: indexPath.row) + let user = store.users[indexPath.row] return UserViewCell.calculateHeight(with: user, and: tableView) } - + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { return .leastNormalMagnitude } - + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return presenter.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude + return store.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) + action.isViewAppearing(maxScrollDistance <= scrollView.contentOffset.y) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift deleted file mode 100644 index 5407516..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewPresenter.swift +++ /dev/null @@ -1,138 +0,0 @@ -// -// SearchViewPresenter.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/10. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Combine -import Foundation -import GithubKit -import UIKit - -protocol SearchPresenter: class { - var view: SearchView? { get set } - var numberOfUsers: Int { get } - var isFetchingUsers: Bool { get } - func search(queryIfNeeded qeury: String) - func user(at index: Int) -> User - func showUser(at index: Int) - func setIsReachedBottom(_ isReachedBottom: Bool) - func viewWillAppear() - func viewWillDisappear() - func showLoadingView(on view: UIView) -} - -final class SearchViewPresenter: SearchPresenter { - weak var view: SearchView? - - var numberOfUsers: Int { - return model.users.count - } - - var isFetchingUsers: Bool { - return model.isFetchingUsers - } - - private let model: SearchModelType - private let mainAsync: (@escaping () -> Void) -> Void - private let notificationCenter: NotificationCenter - - private var isReachedBottom: Bool = false - private var cancellables = Set() - - init( - model: SearchModelType, - mainAsync: @escaping (@escaping () -> Void) -> Void, - notificationCenter: NotificationCenter - ) { - self.model = model - self.mainAsync = mainAsync - self.notificationCenter = notificationCenter - self.model.delegate = self - } - - private func fetchUsers() { - model.fetchUsers() - } - - func search(queryIfNeeded query: String) { - model.fetchUsers(withQuery: query) - } - - func user(at index: Int) -> User { - return model.users[index] - } - - func showUser(at index: Int) { - let user = model.users[index] - view?.showUserRepository(with: user) - } - - func setIsReachedBottom(_ isReachedBottom: Bool) { - let oldValue = self.isReachedBottom - self.isReachedBottom = isReachedBottom - if isReachedBottom && isReachedBottom != oldValue { - fetchUsers() - } - } - - func viewWillAppear() { - notificationCenter.publisher(for: UIResponder.keyboardWillShowNotification) - .sink { [weak self] notification in - guard let info = UIKeyboardInfo(notification: notification) else { - return - } - self?.view?.keyboardWillShow(with: info) - } - .store(in: &cancellables) - - notificationCenter.publisher(for: UIResponder.keyboardWillHideNotification) - .sink { [weak self] notification in - guard let info = UIKeyboardInfo(notification: notification) else { - return - } - self?.view?.keyboardWillHide(with: info) - } - .store(in: &cancellables) - } - - func viewWillDisappear() { - cancellables.removeAll() - } - - func showLoadingView(on view: UIView) { - self.view?.updateLoadingView(with: view, isLoading: isFetchingUsers) - } -} - -extension SearchViewPresenter: SearchModelDelegate { - func searchModel(_ searchModel: SearchModel, didRecieve errorMessage: ErrorMessage) { - mainAsync { - self.view?.showEmptyTokenError(errorMessage: errorMessage) - } - } - - func searchModel(_ searchModel: SearchModel, didChange isFetchingUsers: Bool) { - mainAsync { - self.view?.reloadData() - } - } - - func searchModel(_ searchModel: SearchModel, didChange users: [User]) { - let totalCount = searchModel.totalCount - mainAsync { - self.view?.updateTotalCountLabel("\(users.count) / \(totalCount)") - self.view?.reloadData() - } - } - - func searchModel(_ searchModel: SearchModel, didChange totalCount: Int) { - let users = searchModel.users - mainAsync { - self.view?.updateTotalCountLabel("\(users.count) / \(totalCount)") - self.view?.reloadData() - } - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryAction.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryAction.swift new file mode 100644 index 0000000..224ce25 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryAction.swift @@ -0,0 +1,99 @@ +// +// UserRepositoryAction.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import Foundation +import GithubKit +import UIKit + +protocol UserRepositoryActionType: AnyObject { + func select( + from repositories: [Repository], + at indexPath: IndexPath + ) + func fetchRepositories() + func isReachedBottom(_ isReachedBottom: Bool) + func headerFooterView(_ view: UIView) + func load() +} + +final class UserRepositoryAction: UserRepositoryActionType { + private let dispatcher: UserRepositoryDispatcher + private let repositoryModel: RepositoryModelType + + private let _isReachedBottom = PassthroughSubject() + private let _headerFooterView = PassthroughSubject() + private let _load = PassthroughSubject() + private var cancellables = Set() + + init( + dispatcher: UserRepositoryDispatcher, + repositoryModel: RepositoryModelType + ) { + self.dispatcher = dispatcher + self.repositoryModel = repositoryModel + + _isReachedBottom + .removeDuplicates() + .filter { $0 } + .sink { _ in + repositoryModel.fetchRepositories() + } + .store(in: &cancellables) + + _headerFooterView + .combineLatest(repositoryModel.isFetchingRepositoriesPublisher) + .sink(receiveValue: dispatcher.updateLoadingView.send) + .store(in: &cancellables) + + _load + .map { + repositoryModel.totalCountPublisher + .combineLatest(repositoryModel.repositoriesPublisher) + } + .switchToLatest() + .map { "\($1.count) / \($0)" } + .sink(receiveValue: dispatcher.countString.send) + .store(in: &cancellables) + + _load + .map { repositoryModel.isFetchingRepositoriesPublisher } + .switchToLatest() + .sink(receiveValue: dispatcher.isRepositoryFetching.send) + .store(in: &cancellables) + + _load + .map { repositoryModel.repositoriesPublisher } + .switchToLatest() + .sink(receiveValue: dispatcher.repositories.send) + .store(in: &cancellables) + } + + func select( + from repositories: [Repository], + at indexPath: IndexPath + ) { + let repository = repositories[indexPath.row] + dispatcher.selectedRepository.send(repository) + } + + func fetchRepositories() { + repositoryModel.fetchRepositories() + } + + func isReachedBottom(_ isReachedBottom: Bool) { + _isReachedBottom.send(isReachedBottom) + } + + func headerFooterView(_ view: UIView) { + _headerFooterView.send(view) + } + + func load() { + _load.send() + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryDispatcher.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryDispatcher.swift new file mode 100644 index 0000000..9ffe4ee --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryDispatcher.swift @@ -0,0 +1,18 @@ +// +// UserRepositoryDispatcher.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import GithubKit +import UIKit + +final class UserRepositoryDispatcher { + let selectedRepository = PassthroughSubject() + let updateLoadingView = PassthroughSubject<(UIView, Bool), Never>() + let countString = PassthroughSubject() + let repositories = PassthroughSubject<[Repository], Never>() + let isRepositoryFetching = PassthroughSubject() +} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryStore.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryStore.swift new file mode 100644 index 0000000..fe517ff --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/Flux/UserRepositoryStore.swift @@ -0,0 +1,73 @@ +// +// UserRepositoryStore.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2021/02/13. +// + +import Combine +import Foundation +import GithubKit +import UIKit + +protocol UserRepositoryStoreType: AnyObject { + var repositories: [Repository] { get } + var isRepositoryFetching: Bool { get } + var title: String { get } + var countStringPublisher: Published.Publisher { get } + var selectedRepository: AnyPublisher { get } + var reloadData: AnyPublisher { get } + var updateLoadingView: AnyPublisher<(UIView, Bool), Never> { get } +} + +final class UserRepositoryStore: UserRepositoryStoreType { + @Published + private(set) var repositories: [Repository] = [] + @Published + private(set) var isRepositoryFetching = false + @Published + private(set) var title: String + @Published + private(set) var countString: String = "" + + var countStringPublisher: Published.Publisher { + $countString + } + + let selectedRepository: AnyPublisher + let reloadData: AnyPublisher + let updateLoadingView: AnyPublisher<(UIView, Bool), Never> + + private var cancellables = Set() + + init( + user: User, + dispatcher: UserRepositoryDispatcher + ) { + self.title = "\(user.login)'s Repositories" + let reloadData = PassthroughSubject() + self.selectedRepository = dispatcher.selectedRepository + .eraseToAnyPublisher() + self.reloadData = reloadData.eraseToAnyPublisher() + self.updateLoadingView = dispatcher.updateLoadingView + .eraseToAnyPublisher() + + $repositories + .map { _ in } + .merge(with: $isRepositoryFetching.map { _ in }) + .sink(receiveValue: reloadData.send) + .store(in: &cancellables) + + dispatcher.countString + .assign(to: \.countString, on: self) + .store(in: &cancellables) + + dispatcher.repositories + .assign(to: \.repositories, on: self) + .store(in: &cancellables) + + dispatcher.isRepositoryFetching + .assign(to: \.isRepositoryFetching, on: self) + .store(in: &cancellables) + } +} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift index 91ae1fa..1e2262f 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift @@ -6,71 +6,104 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // +import Combine import GithubKit import UIKit -protocol UserRepositoryView: class { - func reloadData() - func showRepository(with repository: Repository) - func updateTotalCountLabel(_ countText: String) - func updateLoadingView(with view: UIView, isLoading: Bool) -} - -final class UserRepositoryViewController: UIViewController, UserRepositoryView { +final class UserRepositoryViewController: UIViewController { @IBOutlet private(set) weak var tableView: UITableView! @IBOutlet private(set) weak var totalCountLabel: UILabel! let loadingView = LoadingView() - let userRepositoryPresenter: UserRepositoryPresenter + let action: UserRepositoryActionType + let store: UserRepositoryStoreType let dataSource: UserRepositoryViewDataSource - private let makeRepositoryPresenter: (Repository) -> RepositoryPresenter - + private let makeRepositoryAction: (Repository) -> RepositoryActionType + private let makeRepositoryStore: (Repository) -> RepositoryStoreType + private var cacellables = Set() + init( - userRepositoryPresenter: UserRepositoryPresenter, - makeRepositoryPresenter: @escaping (Repository) -> RepositoryPresenter + action: UserRepositoryActionType, + store: UserRepositoryStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType + ) { - self.userRepositoryPresenter = userRepositoryPresenter - self.dataSource = UserRepositoryViewDataSource(presenter: userRepositoryPresenter) - self.makeRepositoryPresenter = makeRepositoryPresenter + self.action = action + self.store = store + self.makeRepositoryAction = makeRepositoryAction + self.makeRepositoryStore = makeRepositoryStore + self.dataSource = UserRepositoryViewDataSource( + action: action, + store: store + ) super.init(nibName: UserRepositoryViewController.className, bundle: nil) hidesBottomBarWhenPushed = true } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - - title = userRepositoryPresenter.title - + + title = store.title + dataSource.configure(with: tableView) - userRepositoryPresenter.view = self - userRepositoryPresenter.fetchRepositories() - } - - func showRepository(with repository: Repository) { - let repositoryPresenter = makeRepositoryPresenter(repository) - let vc = RepositoryViewController(presenter: repositoryPresenter) - navigationController?.pushViewController(vc, animated: true) + store.selectedRepository + .receive(on: DispatchQueue.main) + .sink(receiveValue: showRepository) + .store(in: &cacellables) + + store.reloadData + .receive(on: DispatchQueue.main) + .sink(receiveValue: reloadData) + .store(in: &cacellables) + + store.countStringPublisher + .map(Optional.some) + .receive(on: DispatchQueue.main) + .assign(to: \.text, on: totalCountLabel) + .store(in: &cacellables) + + store.updateLoadingView + .receive(on: DispatchQueue.main) + .sink(receiveValue: updateLoadingView) + .store(in: &cacellables) + + action.load() + action.fetchRepositories() } - - func reloadData() { - tableView.reloadData() + + private var showRepository: (Repository) -> Void { + { [weak self] repository in + guard let me = self else { + return + } + let vc = RepositoryViewController( + action: me.makeRepositoryAction(repository), + store: me.makeRepositoryStore(repository) + ) + me.navigationController?.pushViewController(vc, animated: true) + } } - - func updateTotalCountLabel(_ countText: String) { - totalCountLabel.text = countText + + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() + } } - - func updateLoadingView(with view: UIView, isLoading: Bool) { - loadingView.removeFromSuperview() - loadingView.isLoading = isLoading - loadingView.add(to: view) + + private var updateLoadingView: (UIView, Bool) -> Void { + { [weak self] view, isLoading in + self?.loadingView.removeFromSuperview() + self?.loadingView.isLoading = isLoading + self?.loadingView.add(to: view) + } } } diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift index 21c0044..9bccaa2 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift @@ -6,47 +6,54 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // +import Combine import Foundation -import UIKit import GithubKit +import UIKit final class UserRepositoryViewDataSource: NSObject { - fileprivate let presenter: UserRepositoryPresenter - - init(presenter: UserRepositoryPresenter) { - self.presenter = presenter + private let action: UserRepositoryActionType + private let store: UserRepositoryStoreType + + init( + action: UserRepositoryActionType, + store: UserRepositoryStoreType + ) { + self.action = action + self.store = store } - + func configure(with tableView: UITableView) { tableView.dataSource = self tableView.delegate = self - + tableView.register(RepositoryViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) + tableView.register(UITableViewHeaderFooterView.self, + forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) } } extension UserRepositoryViewDataSource: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return presenter.numberOfRepositories + return store.repositories.count } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - let repository = presenter.repository(at: indexPath.row) + let repository = store.repositories[indexPath.row] cell.configure(with: repository) return cell } - + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { return nil } - + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { return nil } - presenter.showLoadingView(on: view) + action.headerFooterView(view) return view } } @@ -54,24 +61,24 @@ extension UserRepositoryViewDataSource: UITableViewDataSource { extension UserRepositoryViewDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - presenter.showRepository(at: indexPath.row) + action.select(from: store.repositories, at: indexPath) } - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let repository = presenter.repository(at: indexPath.row) + let repository = store.repositories[indexPath.row] return RepositoryViewCell.calculateHeight(with: repository, and: tableView) } - + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { return .leastNormalMagnitude } - + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return presenter.isFetchingRepositories ? LoadingView.defaultHeight : .leastNormalMagnitude + return store.isRepositoryFetching ? LoadingView.defaultHeight : .leastNormalMagnitude } - + func scrollViewDidScroll(_ scrollView: UIScrollView) { let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) - presenter.setIsReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) + action.isReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) } } diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift deleted file mode 100644 index 06593ea..0000000 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewPresenter.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// UserRepositoryViewPresenter.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/10. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Foundation -import GithubKit -import UIKit - -protocol UserRepositoryPresenter: class { - var view: UserRepositoryView? { get set } - var title: String { get } - var isFetchingRepositories: Bool { get } - var numberOfRepositories: Int { get } - func repository(at index: Int) -> Repository - func showRepository(at index: Int) - func showLoadingView(on view: UIView) - func setIsReachedBottom(_ isReachedBottom: Bool) - func fetchRepositories() -} - -final class UserRepositoryViewPresenter: UserRepositoryPresenter { - weak var view: UserRepositoryView? - - var numberOfRepositories: Int { - return model.repositories.count - } - - var isFetchingRepositories: Bool { - return model.isFetchingRepositories - } - - var title: String { - return "\(model.user.login)'s Repositories" - } - - private let model: RepositoryModelType - private let mainAsync: (@escaping () -> Void) -> Void - - private var isReachedBottom: Bool = false - - init( - model: RepositoryModelType, - mainAsync: @escaping (@escaping () -> Void) -> Void - ) { - self.model = model - self.mainAsync = mainAsync - self.model.delegate = self - } - - func fetchRepositories() { - model.fetchRepositories() - } - - func repository(at index: Int) -> Repository { - return model.repositories[index] - } - - func showRepository(at index: Int) { - let repository = model.repositories[index] - view?.showRepository(with: repository) - } - - func showLoadingView(on view: UIView) { - self.view?.updateLoadingView(with: view, isLoading: isFetchingRepositories) - } - - func setIsReachedBottom(_ isReachedBottom: Bool) { - let oldValue = self.isReachedBottom - self.isReachedBottom = isReachedBottom - if isReachedBottom && isReachedBottom != oldValue { - fetchRepositories() - } - } -} - -extension UserRepositoryViewPresenter: RepositoryModelDelegate { - func repositoryModel(_ repositoryModel: RepositoryModel, didChange isFetchingRepositories: Bool) { - mainAsync { - self.view?.reloadData() - } - } - - func repositoryModel(_ repositoryModel: RepositoryModel, didChange repositories: [Repository]) { - let totalCount = repositoryModel.totalCount - mainAsync { - self.view?.updateTotalCountLabel("\(repositories.count) / \(totalCount)") - self.view?.reloadData() - } - } - - func repositoryModel(_ repositoryModel: RepositoryModel, didChange totalCount: Int) { - let repositories = repositoryModel.repositories - mainAsync { - self.view?.updateTotalCountLabel("\(repositories.count) / \(totalCount)") - self.view?.reloadData() - } - } -}