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/favorite.png b/Images/favorite.png index f248450..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 498a4fc..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 c8c6f37..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 c609fb6..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 06caad4..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 e92a566..4200b61 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# iOSDesignPatternSamples (MVVM) +# iOSDesignPatternSamples (Flux) -This is Github user search demo app that made with MVVM design pattern. +This is Github user search demo app that made with Flux design pattern. ## Application Structure @@ -13,35 +13,40 @@ Search Github user and show user result list ![](./Images/search.png) -- [SearchViewModel](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift) - [SearchViewDataSource](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift) <- Adapt UITableViewDataSource and UITableViewDelegate +- [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) -- [FavoriteViewModel](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewModel.swift) - [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) -- [UserRepositoryViewModel](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewModel.swift) - [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) -- [RepositoryViewModel](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewModel.swift) +- [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 d953d3b..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 */; }; - 1B6B63FE22A5A86F6028991E /* UserRepositoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BA8AC18BFBE203EBECBDF0 /* UserRepositoryViewModel.swift */; }; - 450252B4BA07F8D498E608EF /* RepositoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42AAD61E2A4041414E741BC4 /* RepositoryViewModel.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 */; }; 5A6EC25DC03393E8F8E064FF /* ApiSession.extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34175ABC0E7BB9870D592CEF /* ApiSession.extension.swift */; }; - 651202151F5333A9ABE09B22 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8FB5FFB17FCEE46B7E72E4 /* SearchViewModel.swift */; }; + 645A35DA317A4471244893CE /* FavoriteStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF357511873A96AF5614198 /* FavoriteStore.swift */; }; 68266EFC53379F0728F6B00B /* FavoriteViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */; }; 6D22F97989A935ECA42DB3CA /* SearchViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 5EDBADE530FE153A9651F109 /* SearchViewController.xib */; }; 6EC13FBC2AE1E8DBEBE88804 /* FavoriteViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */; }; + 71B682A7678AE7B950DCD0F3 /* FavoriteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92A03189FF8521FF3E71A7D /* FavoriteAction.swift */; }; 71E4B210FC5945A53739149D /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E2B00ED49EA7B8CD6C7C4F5 /* LoadingView.swift */; }; - 72E107687058F53E4B2EF247 /* FavoriteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A35DE51E635D96C6FB469DF /* FavoriteViewModel.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 */; }; + 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,12 +49,14 @@ 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 = ""; }; - 3A35DE51E635D96C6FB469DF /* FavoriteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewModel.swift; sourceTree = ""; }; - 3B8FB5FFB17FCEE46B7E72E4 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryViewController.swift; sourceTree = ""; }; - 42AAD61E2A4041414E741BC4 /* RepositoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryViewModel.swift; sourceTree = ""; }; 482D2D42402C917E5C0069BC /* FavoriteModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteModel.swift; sourceTree = ""; }; + 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 = ""; }; @@ -54,16 +64,22 @@ 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteViewController.swift; sourceTree = ""; }; 711633F4B85B3F76D0C88E41 /* UIKeyboardInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKeyboardInfo.swift; sourceTree = ""; }; 7E02ECB42B97D30045AF6AB2 /* SearchViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewDataSource.swift; sourceTree = ""; }; - 85BA8AC18BFBE203EBECBDF0 /* UserRepositoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryViewModel.swift; sourceTree = ""; }; + 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 = ""; }; 9E2B00ED49EA7B8CD6C7C4F5 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 9ED48619F4A0CDE4B2D46702 /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.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 */, - 85BA8AC18BFBE203EBECBDF0 /* UserRepositoryViewModel.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 */, - 3B8FB5FFB17FCEE46B7E72E4 /* SearchViewModel.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 */, - 42AAD61E2A4041414E741BC4 /* RepositoryViewModel.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 */, - 3A35DE51E635D96C6FB469DF /* FavoriteViewModel.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 */, - 72E107687058F53E4B2EF247 /* FavoriteViewModel.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 */, - 450252B4BA07F8D498E608EF /* RepositoryViewModel.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 */, - 651202151F5333A9ABE09B22 /* SearchViewModel.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 */, - 1B6B63FE22A5A86F6028991E /* UserRepositoryViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift index 9c981c0..2b362a0 100644 --- a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift +++ b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift @@ -15,7 +15,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - private let favoriteModel = FavoriteModel() + let favoriteModel = FavoriteModel() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -23,37 +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( - viewModel: SearchViewModel( + action: SearchAction( + notificationCenter: .default, + dispatcher: searchDispatcher, searchModel: SearchModel( sendRequest: ApiSession.shared.send - ), - notificationCenter: .default + ) + ), + store: SearchStore( + dispatcher: searchDispatcher ), - makeUserRepositoryViewModel: { [favoriteModel] in - UserRepositoryViewModel( - user: $0, - favoriteModel: favoriteModel, + makeUserRepositoryAction: { user in + UserRepositoryAction( + dispatcher: userRepositoryDispatcher, repositoryModel: RepositoryModel( - user: $0, + user: user, sendRequest: ApiSession.shared.send ) ) }, - makeRepositoryViewModel: { [favoriteModel] in - RepositoryViewModel( - repository: $0, - favoritesModel: favoriteModel + makeUserRepositoryStore: { user in + UserRepositoryStore( + user: user, + dispatcher: userRepositoryDispatcher + ) + }, + makeRepositoryAction: { [favoriteModel] repository in + RepositoryAction( + repository: repository, + dispatcher: repositoryDispatcher, + favoriteModel: favoriteModel + ) + }, + 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( - viewModel: FavoriteViewModel(favoriteModel: favoriteModel), - makeRepositoryViewModel: { [favoriteModel] in - RepositoryViewModel(repository: $0, favoritesModel: favoriteModel) + 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) diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift index 3b2ada3..4930e5d 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift @@ -13,19 +13,27 @@ import UIKit final class FavoriteViewController: UIViewController { @IBOutlet private(set) weak var tableView: UITableView! - let viewModel: FavoriteViewModelType + let action: FavoriteActionType + let store: FavoriteStoreType let dataSource: FavoriteViewDataSource - - private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType + private let makeRepositoryAction: (Repository) -> RepositoryActionType + private let makeRepositoryStore: (Repository) -> RepositoryStoreType private var cancellables = Set() init( - viewModel: FavoriteViewModelType, - makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType + action: FavoriteActionType, + store: FavoriteStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType ) { - self.makeRepositoryViewModel = makeRepositoryViewModel - self.viewModel = viewModel - self.dataSource = FavoriteViewDataSource(viewModel: viewModel) + 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) } @@ -37,18 +45,19 @@ final class FavoriteViewController: UIViewController { super.viewDidLoad() title = "On Memory Favorite" - dataSource.configure(with: tableView) - viewModel.output.selectedRepository + store.selectedRepository .receive(on: DispatchQueue.main) .sink(receiveValue: showRepository) .store(in: &cancellables) - viewModel.output.relaodData + store.reloadData .receive(on: DispatchQueue.main) .sink(receiveValue: reloadData) .store(in: &cancellables) + + action.load() } private var showRepository: (Repository) -> Void { @@ -56,8 +65,11 @@ final class FavoriteViewController: UIViewController { guard let me = self else { return } - let vm = me.makeRepositoryViewModel(repository) - let vc = RepositoryViewController(viewModel: vm) + + let vc = RepositoryViewController( + action: me.makeRepositoryAction(repository), + store: me.makeRepositoryStore(repository) + ) me.navigationController?.pushViewController(vc, animated: true) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift index cf6b099..0b5a26d 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift @@ -11,28 +11,33 @@ import GithubKit import UIKit final class FavoriteViewDataSource: NSObject { - private let viewModel: FavoriteViewModelType - - init(viewModel: FavoriteViewModelType) { - self.viewModel = viewModel + 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 viewModel.output.favorites.count + return store.favorites.count } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - let repository = viewModel.output.favorites[indexPath.row] + 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) - viewModel.input.selectedIndexPath(indexPath) + action.select(from: store.favorites, for: indexPath) } - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let repository = viewModel.output.favorites[indexPath.row] + let repository = store.favorites[indexPath.row] return RepositoryViewCell.calculateHeight(with: repository, and: tableView) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewModel.swift b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewModel.swift deleted file mode 100644 index 8d74356..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewModel.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// FavoriteViewModel.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/10. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Combine -import Foundation -import GithubKit - -protocol FavoriteViewModelType: AnyObject { - var output: FavoriteViewModel.Output { get } - var input: FavoriteViewModel.Input { get } -} - -final class FavoriteViewModel: FavoriteViewModelType { - let output: Output - let input: Input - - var favorites: [Repository] { - favoriteModel.favorites - } - - private let favoriteModel: FavoriteModelType - private var cancellable = Set() - - init( - favoriteModel: FavoriteModelType - ) { - self.favoriteModel = favoriteModel - let _selectedIndexPath = PassthroughSubject() - let _selectedRepository = PassthroughSubject() - - self.output = Output( - favorites: favoriteModel.favorites, - relaodData: favoriteModel.favoritePublisher.map { _ in }.eraseToAnyPublisher(), - selectedRepository: _selectedRepository.eraseToAnyPublisher() - ) - - self.input = Input(selectedIndexPath: _selectedIndexPath.send) - - _selectedIndexPath - .map { favoriteModel.favorites[$0.row] } - .sink { - _selectedRepository.send($0) - } - .store(in: &cancellable) - - favoriteModel.favoritePublisher - .assign(to: \.favorites, on: output) - .store(in: &cancellable) - } -} - -extension FavoriteViewModel { - struct Input { - let selectedIndexPath: (IndexPath) -> Void - } - - final class Output { - @Published - fileprivate(set) var favorites: [Repository] - let relaodData: AnyPublisher - let selectedRepository: AnyPublisher - init( - favorites: [Repository], - relaodData: AnyPublisher, - selectedRepository: AnyPublisher - ) { - self.favorites = favorites - self.relaodData = relaodData - self.selectedRepository = selectedRepository - } - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/Favorite/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 097650e..1d64e1d 100644 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift @@ -13,11 +13,18 @@ import UIKit final class RepositoryViewController: SFSafariViewController { private var cancellables = Set() - private let viewModel: RepositoryViewModelType + private let action: RepositoryActionType + private let store: RepositoryStoreType - init(viewModel: RepositoryViewModelType) { - self.viewModel = viewModel - super.init(url: viewModel.output.url, configuration: .init()) + 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 } @@ -32,14 +39,16 @@ final class RepositoryViewController: SFSafariViewController { ) navigationItem.rightBarButtonItem = favoriteButtonItem - viewModel.output.favoriteButtonTitle + store.favoriteButtonTitlePublisher .map(Optional.some) .receive(on: DispatchQueue.main) .assign(to: \.title, on: favoriteButtonItem) .store(in: &cancellables) + + action.load() } @objc private func favoriteButtonTap(_: UIBarButtonItem) { - viewModel.input.favoriteButtonTap() + action.toggleFavorite() } } diff --git a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewModel.swift b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewModel.swift deleted file mode 100644 index 150f6e0..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewModel.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// RepositoryViewModel.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/11. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Combine -import Foundation -import GithubKit - -protocol RepositoryViewModelType: AnyObject { - var input: RepositoryViewModel.Input { get } - var output: RepositoryViewModel.Output { get } -} - -final class RepositoryViewModel: RepositoryViewModelType { - let input: Input - let output: Output - - private var cancellables = Set() - - init( - repository: Repository, - favoritesModel: FavoriteModelType - ) { - let favoriteButtonTitle = favoritesModel.contains(repository) - .map { $0 ? "Remove" : "Add" } - .eraseToAnyPublisher() - - self.output = Output( - url: repository.url, - favoriteButtonTitle: favoriteButtonTitle - ) - - let favoriteButtonTap = PassthroughSubject() - self.input = Input(favoriteButtonTap: favoriteButtonTap.send) - - favoriteButtonTap - .map { _ in - favoritesModel.contains(repository).prefix(1) - } - .switchToLatest() - .sink { contains in - if contains { - favoritesModel.removeFavorite(repository) - } else { - favoritesModel.addFavorite(repository) - } - } - .store(in: &cancellables) - } -} - -extension RepositoryViewModel { - struct Input { - let favoriteButtonTap: () -> Void - } - - final class Output { - @Published - fileprivate(set) var url: URL - let favoriteButtonTitle: AnyPublisher - init( - url: URL, - favoriteButtonTitle: AnyPublisher - ) { - self.url = url - self.favoriteButtonTitle = favoriteButtonTitle - } - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/Search/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 7a76076..95fba10 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift @@ -19,22 +19,33 @@ final class SearchViewController: UIViewController { let searchBar = UISearchBar(frame: .zero) let loadingView = LoadingView() - let viewModel: SearchViewModelType + let action: SearchActionType + let store: SearchStoreType let dataSource: SearchViewDataSource - private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType - private let makeUserRepositoryViewModel: (User) -> UserRepositoryViewModelType + 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( - viewModel: SearchViewModelType, - makeUserRepositoryViewModel: @escaping (User) -> UserRepositoryViewModelType, - makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType + action: SearchActionType, + store: SearchStoreType, + makeUserRepositoryAction: @escaping (User) -> UserRepositoryActionType, + makeUserRepositoryStore: @escaping (User) -> UserRepositoryStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType ) { - self.makeRepositoryViewModel = makeRepositoryViewModel - self.makeUserRepositoryViewModel = makeUserRepositoryViewModel - self.viewModel = viewModel - self.dataSource = SearchViewDataSource(viewModel: viewModel) + 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) } @@ -52,51 +63,53 @@ final class SearchViewController: UIViewController { dataSource.configure(with: tableView) // observe viewModel - viewModel.output.accessTokenAlert + store.accessTokenAlert .receive(on: DispatchQueue.main) .sink(receiveValue: showAccessTokenAlert) .store(in: &cancellables) - viewModel.output.keyboardWillShow + store.keyboardWillShow .receive(on: DispatchQueue.main) .sink(receiveValue: keyboardWillShow) .store(in: &cancellables) - viewModel.output.keyboardWillHide + store.keyboardWillHide .receive(on: DispatchQueue.main) .sink(receiveValue: keyboardWillHide) .store(in: &cancellables) - viewModel.output.countString + store.countStringPublisher .map(Optional.some) .receive(on: DispatchQueue.main) .assign(to: \.text, on: totalCountLabel) .store(in: &cancellables) - viewModel.output.reloadData + store.reloadData .receive(on: DispatchQueue.main) .sink(receiveValue: reloadData) .store(in: &cancellables) - viewModel.output.selectedUser + store.selectedUser .receive(on: DispatchQueue.main) .sink(receiveValue: showUserRepository) .store(in: &cancellables) - viewModel.output.updateLoadingView + store.updateLoadingView .receive(on: DispatchQueue.main) .sink(receiveValue: updateLoadingView) .store(in: &cancellables) + + action.load() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - viewModel.input.viewDidAppear() + action.isViewAppearing(true) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - viewModel.input.viewDidDisappear() + action.isViewAppearing(false) } override func viewWillDisappear(_ animated: Bool) { @@ -156,10 +169,11 @@ final class SearchViewController: UIViewController { guard let me = self else { return } - let vm = me.makeUserRepositoryViewModel(user) let vc = UserRepositoryViewController( - viewModel: vm, - makeRepositoryViewModel: me.makeRepositoryViewModel + action: me.makeUserRepositoryAction(user), + store: me.makeUserRepositoryStore(user), + makeRepositoryAction: me.makeRepositoryAction, + makeRepositoryStore: me.makeRepositoryStore ) me.navigationController?.pushViewController(vc, animated: true) } @@ -193,6 +207,6 @@ extension SearchViewController: UISearchBarDelegate { } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - viewModel.input.searchText(searchBar.text) + action.searchText(searchBar.text) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift index 654e2b2..a68e503 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift @@ -6,22 +6,27 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // +import Combine import Foundation import GithubKit import UIKit final class SearchViewDataSource: NSObject { + private let action: SearchActionType + private let store: SearchStoreType - private let viewModel: SearchViewModelType - - init(viewModel: SearchViewModelType) { - self.viewModel = viewModel + 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) @@ -30,25 +35,25 @@ final class SearchViewDataSource: NSObject { extension SearchViewDataSource: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.output.users.count + return store.users.count } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(UserViewCell.self, for: indexPath) - let user = viewModel.output.users[indexPath.row] + 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 } - viewModel.input.headerFooterView(view) + action.headerFooterView(view) return view } } @@ -56,24 +61,24 @@ extension SearchViewDataSource: UITableViewDataSource { extension SearchViewDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - viewModel.input.selectedIndexPath(indexPath) + action.setlect(from: store.users, at: indexPath) } - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let user = viewModel.output.users[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 viewModel.output.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) - viewModel.input.isReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) + action.isViewAppearing(maxScrollDistance <= scrollView.contentOffset.y) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift deleted file mode 100644 index 2d251d5..0000000 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewModel.swift +++ /dev/null @@ -1,175 +0,0 @@ -// -// SearchViewModel.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/10. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Combine -import Foundation -import GithubKit -import UIKit - -protocol SearchViewModelType: AnyObject { - var input: SearchViewModel.Input { get } - var output: SearchViewModel.Output { get } -} - -final class SearchViewModel: SearchViewModelType{ - let output: Output - let input: Input - - private var cancellables = Set() - - init( - searchModel: SearchModelType, - notificationCenter: NotificationCenter - ) { - let viewDidAppear = PassthroughSubject() - let viewDidDisappear = PassthroughSubject() - let searchText = PassthroughSubject() - let isReachedBottom = PassthroughSubject() - let selectedIndexPath = PassthroughSubject() - let headerFooterView = PassthroughSubject() - - self.input = Input( - viewDidAppear: viewDidAppear.send, - viewDidDisappear: viewDidDisappear.send, - searchText: searchText.send, - isReachedBottom: isReachedBottom.send, - selectedIndexPath: selectedIndexPath.send, - headerFooterView: headerFooterView.send - ) - - do { - let selectedUser = selectedIndexPath - .map { searchModel.users[$0.row] } - .eraseToAnyPublisher() - - let updateLoadingView = headerFooterView - .combineLatest(searchModel.isFetchingUsersPublisher) - .eraseToAnyPublisher() - - let countString = searchModel.totalCountPublisher - .combineLatest(searchModel.usersPublisher) - .map { "\($1.count) / \($0)" } - .eraseToAnyPublisher() - - let reloadData = searchModel.usersPublisher.map { _ in } - .merge(with: searchModel.totalCountPublisher.map { _ in }, - searchModel.isFetchingUsersPublisher.map { _ in }) - .eraseToAnyPublisher() - - // keyboard notification - let isViewAppearing = viewDidAppear.map { true } - .merge(with: viewDidDisappear.map { false }) - .eraseToAnyPublisher() - - let makeKeyboardObservable: (Notification.Name, Bool) -> AnyPublisher = { name, isViewAppearing in - guard isViewAppearing else { - return Empty().eraseToAnyPublisher() - } - return notificationCenter.publisher(for: name) - .flatMap { notification -> AnyPublisher in - guard let info = UIKeyboardInfo(notification: notification) else { - return Empty().eraseToAnyPublisher() - } - return Just(info).eraseToAnyPublisher() - } - .eraseToAnyPublisher() - } - - let keyboardWillShow = isViewAppearing - .map { makeKeyboardObservable(UIResponder.keyboardWillShowNotification, $0) } - .switchToLatest() - .eraseToAnyPublisher() - - let keyboardWillHide = isViewAppearing - .map { makeKeyboardObservable(UIResponder.keyboardWillHideNotification, $0) } - .switchToLatest() - .eraseToAnyPublisher() - - self.output = Output( - users: searchModel.users, - isFetchingUsers: searchModel.isFetchingUsers, - accessTokenAlert: searchModel.errorMessage, - updateLoadingView: updateLoadingView, - selectedUser: selectedUser, - keyboardWillShow: keyboardWillShow, - keyboardWillHide: keyboardWillHide, - countString: countString, - reloadData: reloadData - ) - } - - searchText - .map { $0 ?? "" } - .sink { - searchModel.fetchUsers(withQuery: $0) - } - .store(in: &cancellables) - - isReachedBottom - .removeDuplicates() - .filter { $0 } - .sink { _ in - searchModel.fetchUsers() - } - .store(in: &cancellables) - - searchModel.usersPublisher - .assign(to: \.users, on: output) - .store(in: &cancellables) - - searchModel.isFetchingUsersPublisher - .assign(to: \.isFetchingUsers, on: output) - .store(in: &cancellables) - } -} - -extension SearchViewModel { - struct Input { - let viewDidAppear: () -> Void - let viewDidDisappear: () -> Void - let searchText: (String?) -> Void - let isReachedBottom: (Bool) -> Void - let selectedIndexPath: (IndexPath) -> Void - let headerFooterView: (UIView) -> Void - } - - final class Output { - @Published - fileprivate(set) var users: [User] - @Published - fileprivate(set) var isFetchingUsers: Bool - let accessTokenAlert: AnyPublisher - let updateLoadingView: AnyPublisher<(UIView, Bool), Never> - let selectedUser: AnyPublisher - let keyboardWillShow: AnyPublisher - let keyboardWillHide: AnyPublisher - let countString: AnyPublisher - let reloadData: AnyPublisher - init( - users: [User], - isFetchingUsers: Bool, - accessTokenAlert: AnyPublisher, - updateLoadingView: AnyPublisher<(UIView, Bool), Never>, - selectedUser: AnyPublisher, - keyboardWillShow: AnyPublisher, - keyboardWillHide: AnyPublisher, - countString: AnyPublisher, - reloadData: AnyPublisher - ) { - self.users = users - self.isFetchingUsers = isFetchingUsers - self.accessTokenAlert = accessTokenAlert - self.updateLoadingView = updateLoadingView - self.selectedUser = selectedUser - self.keyboardWillShow = keyboardWillShow - self.keyboardWillHide = keyboardWillHide - self.countString = countString - self.reloadData = reloadData - } - } -} diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/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 1bb7b2d..1e2262f 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift @@ -17,19 +17,29 @@ final class UserRepositoryViewController: UIViewController { let loadingView = LoadingView() - let viewModel: UserRepositoryViewModelType + let action: UserRepositoryActionType + let store: UserRepositoryStoreType let dataSource: UserRepositoryViewDataSource - private let makeRepositoryViewModel: (Repository) -> RepositoryViewModelType + private let makeRepositoryAction: (Repository) -> RepositoryActionType + private let makeRepositoryStore: (Repository) -> RepositoryStoreType private var cacellables = Set() init( - viewModel: UserRepositoryViewModelType, - makeRepositoryViewModel: @escaping (Repository) -> RepositoryViewModelType + action: UserRepositoryActionType, + store: UserRepositoryStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType + ) { - self.makeRepositoryViewModel = makeRepositoryViewModel - self.viewModel = viewModel - self.dataSource = UserRepositoryViewDataSource(viewModel: viewModel) + 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 } @@ -41,32 +51,33 @@ final class UserRepositoryViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - title = viewModel.output.title + title = store.title dataSource.configure(with: tableView) - viewModel.output.showRepository + store.selectedRepository .receive(on: DispatchQueue.main) .sink(receiveValue: showRepository) .store(in: &cacellables) - viewModel.output.reloadData + store.reloadData .receive(on: DispatchQueue.main) .sink(receiveValue: reloadData) .store(in: &cacellables) - viewModel.output.countString + store.countStringPublisher .map(Optional.some) .receive(on: DispatchQueue.main) .assign(to: \.text, on: totalCountLabel) .store(in: &cacellables) - viewModel.output.updateLoadingView + store.updateLoadingView .receive(on: DispatchQueue.main) .sink(receiveValue: updateLoadingView) .store(in: &cacellables) - viewModel.input.fetchRepositories() + action.load() + action.fetchRepositories() } private var showRepository: (Repository) -> Void { @@ -74,8 +85,10 @@ final class UserRepositoryViewController: UIViewController { guard let me = self else { return } - let vm = me.makeRepositoryViewModel(repository) - let vc = RepositoryViewController(viewModel: vm) + let vc = RepositoryViewController( + action: me.makeRepositoryAction(repository), + store: me.makeRepositoryStore(repository) + ) me.navigationController?.pushViewController(vc, animated: true) } } diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift index 34760a6..9bccaa2 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift @@ -6,22 +6,27 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // +import Combine import Foundation import GithubKit import UIKit final class UserRepositoryViewDataSource: NSObject { + private let action: UserRepositoryActionType + private let store: UserRepositoryStoreType - private let viewModel: UserRepositoryViewModelType - - init(viewModel: UserRepositoryViewModelType) { - self.viewModel = viewModel + 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) @@ -30,25 +35,25 @@ final class UserRepositoryViewDataSource: NSObject { extension UserRepositoryViewDataSource: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return viewModel.output.repositories.count + return store.repositories.count } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - let repository = viewModel.output.repositories[indexPath.row] + 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 } - viewModel.input.headerFooterView(view) + action.headerFooterView(view) return view } } @@ -56,24 +61,24 @@ extension UserRepositoryViewDataSource: UITableViewDataSource { extension UserRepositoryViewDataSource: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: false) - viewModel.input.selectedIndexPath(indexPath) + action.select(from: store.repositories, at: indexPath) } - + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - let repository = viewModel.output.repositories[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 viewModel.output.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) - viewModel.input.isReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) + action.isReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) } } diff --git a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewModel.swift b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewModel.swift deleted file mode 100644 index b440743..0000000 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewModel.swift +++ /dev/null @@ -1,129 +0,0 @@ -// -// UserRepositoryViewModel.swift -// iOSDesignPatternSamples -// -// Created by marty-suzuki on 2017/09/10. -// Copyright © 2017年 marty-suzuki. All rights reserved. -// - -import Combine -import Foundation -import GithubKit -import UIKit - -protocol UserRepositoryViewModelType: AnyObject { - var input: UserRepositoryViewModel.Input { get } - var output: UserRepositoryViewModel.Output { get } -} - -final class UserRepositoryViewModel: UserRepositoryViewModelType { - let input: Input - let output: Output - - private var cancellables = Set() - - init( - user: User, - favoriteModel: FavoriteModelType, - repositoryModel: RepositoryModelType - ) { - let _fetchRepositories = PassthroughSubject() - let _selectedIndexPath = PassthroughSubject() - let _isReachedBottom = PassthroughSubject() - let _headerFooterView = PassthroughSubject() - - self.input = Input( - fetchRepositories: _fetchRepositories.send, - selectedIndexPath: _selectedIndexPath.send, - isReachedBottom: _isReachedBottom.send, - headerFooterView: _headerFooterView.send - ) - - do { - let updateLoadingView = _headerFooterView - .combineLatest(repositoryModel.isFetchingRepositoriesPublisher) - .eraseToAnyPublisher() - - let showRepository = _selectedIndexPath - .map { repositoryModel.repositories[$0.row] } - .eraseToAnyPublisher() - - let countString = repositoryModel.totalCountPublisher - .combineLatest(repositoryModel.repositoriesPublisher) - .map { "\($1.count) / \($0)" } - .eraseToAnyPublisher() - - let reloadData = repositoryModel.repositoriesPublisher.map { _ in } - .merge(with: repositoryModel.totalCountPublisher.map { _ in }, - repositoryModel.isFetchingRepositoriesPublisher.map { _ in }) - .eraseToAnyPublisher() - - self.output = Output( - title: "\(user.login)'s Repositories", - repositories: repositoryModel.repositories, - isFetchingRepositories: repositoryModel.isFetchingRepositories, - updateLoadingView: updateLoadingView, - showRepository: showRepository, - countString: countString, - reloadData: reloadData - ) - } - - _isReachedBottom - .removeDuplicates() - .filter { $0 } - .sink { _ in - repositoryModel.fetchRepositories() - } - .store(in: &cancellables) - - repositoryModel.repositoriesPublisher - .assign(to: \.repositories, on: output) - .store(in: &cancellables) - - repositoryModel.isFetchingRepositoriesPublisher - .assign(to: \.isFetchingRepositories, on: output) - .store(in: &cancellables) - - repositoryModel.fetchRepositories() - } -} - -extension UserRepositoryViewModel { - struct Input { - let fetchRepositories: () -> Void - let selectedIndexPath: (IndexPath) -> Void - let isReachedBottom: (Bool) -> Void - let headerFooterView: (UIView) -> Void - } - - final class Output { - @Published - fileprivate(set) var title: String - @Published - fileprivate(set) var repositories: [Repository] - @Published - fileprivate(set) var isFetchingRepositories: Bool - let updateLoadingView: AnyPublisher<(UIView, Bool), Never> - let showRepository: AnyPublisher - let countString: AnyPublisher - let reloadData: AnyPublisher - init( - title: String, - repositories: [Repository], - isFetchingRepositories: Bool, - updateLoadingView: AnyPublisher<(UIView, Bool), Never>, - showRepository: AnyPublisher, - countString: AnyPublisher, - reloadData: AnyPublisher - ) { - self.title = title - self.repositories = repositories - self.isFetchingRepositories = isFetchingRepositories - self.updateLoadingView = updateLoadingView - self.showRepository = showRepository - self.countString = countString - self.reloadData = reloadData - } - } -}