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/MVC/Views.key b/Images/MVC/Views.key index 14c28a5..ccca2ac 100644 Binary files a/Images/MVC/Views.key and b/Images/MVC/Views.key differ diff --git a/Images/MVP/Views.key b/Images/MVP/Views.key new file mode 100644 index 0000000..2597c39 Binary files /dev/null and b/Images/MVP/Views.key differ diff --git a/Images/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 new file mode 100644 index 0000000..798ad4b Binary files /dev/null and b/Images/favorite.png differ diff --git a/Images/repository.png b/Images/repository.png new file mode 100644 index 0000000..ce3fa2f Binary files /dev/null and b/Images/repository.png differ diff --git a/Images/search.png b/Images/search.png new file mode 100644 index 0000000..8a8aaac Binary files /dev/null and b/Images/search.png differ diff --git a/Images/structure.png b/Images/structure.png index 85e7cb2..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 new file mode 100644 index 0000000..9290970 Binary files /dev/null and b/Images/user_repository.png differ diff --git a/README.md b/README.md index 5254b4b..4200b61 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# iOSDesignPatternSamples (MVC) +# iOSDesignPatternSamples (Flux) -This is Github user search demo app that made with MVC design pattern. +This is Github user search demo app that made with Flux design pattern. ## Application Structure @@ -8,14 +8,45 @@ This is Github user search demo app that made with MVC design pattern. ## ViewControllers -- [SearchViewController](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift) -> Search Github user and show user result list -- [FavoriteViewController](./iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift) -> Show local on memory favorite repositories -- [UserRepositoryViewController](./iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift) -> Show Github user's repositories -- [RepositoryViewController](./iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift) -> Show a repository and add / remove local on memory favorites +### [SearchViewController](./iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift) +Search Github user and show user result list + +![](./Images/search.png) + +- [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) + +- [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) + +- [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) + +- [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 fc27d65..fd957b4 100644 --- a/iOSDesignPatternSamples.xcodeproj/project.pbxproj +++ b/iOSDesignPatternSamples.xcodeproj/project.pbxproj @@ -7,26 +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 */; }; + 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 */; }; + 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 */; }; + 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 */ @@ -34,22 +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 = ""; }; + 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 = ""; }; 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 = ""; }; 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 */ @@ -79,19 +109,53 @@ children = ( 6AE9204A886B2448F36B9293 /* UserRepositoryViewController.swift */, 1F905F35ABF1B1579FEE2B5A /* UserRepositoryViewController.xib */, + 969B79174D1421B0FCFE7154 /* UserRepositoryViewDataSource.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 */, + 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 = ( @@ -112,6 +176,7 @@ isa = PBXGroup; children = ( 3D51BC0E506CEA4C9ACAA8DB /* RepositoryViewController.swift */, + 0FF0ADCFE3848C8129BCA358 /* Flux */, ); path = Repository; sourceTree = ""; @@ -138,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 = ( @@ -152,6 +227,8 @@ children = ( 6AFD90EB20BB491847814C5E /* FavoriteViewController.swift */, E0D61178C6EC742BD2F981F1 /* FavoriteViewController.xib */, + 5DE1FF29F81CED271FD929C2 /* FavoriteViewDataSource.swift */, + C874BA3CE4CCE615BEA46578 /* Flux */, ); path = Favorite; sourceTree = ""; @@ -281,16 +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 */, 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 */, + 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 */, 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 */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift b/iOSDesignPatternSamples/Sources/Common/AppDelegate.swift index 7fc18a7..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,24 +23,76 @@ 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( - searchModel: SearchModel( - sendRequest: ApiSession.shared.send, - asyncAfter: { DispatchQueue.global().asyncAfter(deadline: $0, execute: $1) }, - mainAsync: { work in DispatchQueue.main.async { work() } } - ), - makeFavoriteModel: { [favoriteModel] in favoriteModel }, - makeRepositoryModel: { - RepositoryModel( - user: $0, + action: SearchAction( + notificationCenter: .default, + dispatcher: searchDispatcher, + searchModel: SearchModel( sendRequest: ApiSession.shared.send ) + ), + store: SearchStore( + dispatcher: searchDispatcher + ), + 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 + ) + }, + makeRepositoryStore: { repository in + RepositoryStore( + repository: repository, + dispatcher: repositoryDispatcher + ) } ) nc.setViewControllers([searchVC], animated: false) case let (1, nc as UINavigationController): - let favoriteVC = FavoriteViewController(favoriteModel: favoriteModel) + let favoriteDispatcher = FavoriteDispatcher() + let repositoryDispatcher = RepositoryDispatcher() + let favoriteVC = FavoriteViewController( + 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) default: @@ -51,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 4f172d3..4930e5d 100644 --- a/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewController.swift @@ -6,72 +6,77 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit +import Combine import GithubKit +import UIKit final class FavoriteViewController: UIViewController { @IBOutlet private(set) weak var tableView: UITableView! - - let favoriteModel: FavoriteModelType - init(favoriteModel: FavoriteModelType) { - self.favoriteModel = favoriteModel + let action: FavoriteActionType + let store: FavoriteStoreType + let dataSource: FavoriteViewDataSource + private let makeRepositoryAction: (Repository) -> RepositoryActionType + private let makeRepositoryStore: (Repository) -> RepositoryStoreType + private var cancellables = Set() + + init( + action: FavoriteActionType, + store: FavoriteStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType + ) { + 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) } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() title = "On Memory Favorite" - - favoriteModel.delegate = self - configure(with: tableView) - } - - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(RepositoryViewCell.self) - } - - private func showRepository(with repository: Repository) { - let vc = RepositoryViewController(repository: repository, favoriteModel: favoriteModel) - navigationController?.pushViewController(vc, animated: true) - } -} + dataSource.configure(with: tableView) -extension FavoriteViewController: FavoriteModelDelegate { - func favoriteDidChange() { - tableView.reloadData() - } -} + store.selectedRepository + .receive(on: DispatchQueue.main) + .sink(receiveValue: showRepository) + .store(in: &cancellables) -extension FavoriteViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return favoriteModel.favorites.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - cell.configure(with: favoriteModel.favorites[indexPath.row]) - return cell + store.reloadData + .receive(on: DispatchQueue.main) + .sink(receiveValue: reloadData) + .store(in: &cancellables) + + action.load() } -} -extension FavoriteViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: false) - - let repository = favoriteModel.favorites[indexPath.row] - showRepository(with: repository) + 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 tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return RepositoryViewCell.calculateHeight(with: favoriteModel.favorites[indexPath.row], and: tableView) + + 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 new file mode 100644 index 0000000..0b5a26d --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Favorite/FavoriteViewDataSource.swift @@ -0,0 +1,56 @@ +// +// FavoriteViewDataSource.swift +// iOSDesignPatternSamples +// +// Created by marty-suzuki on 2017/09/10. +// Copyright © 2017年 marty-suzuki. All rights reserved. +// + +import Foundation +import GithubKit +import UIKit + +final class FavoriteViewDataSource: NSObject { + 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 store.favorites.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) + let repository = store.favorites[indexPath.row] + cell.configure(with: repository) + return cell + } +} + +extension FavoriteViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + action.select(from: store.favorites, for: indexPath) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + let repository = store.favorites[indexPath.row] + return RepositoryViewCell.calculateHeight(with: repository, and: tableView) + } +} 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 5764c9c..1d64e1d 100644 --- a/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Repository/RepositoryViewController.swift @@ -6,44 +6,49 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit -import SafariServices +import Combine import GithubKit +import SafariServices +import UIKit final class RepositoryViewController: SFSafariViewController { - private(set) lazy var favoriteButtonItem: UIBarButtonItem = { - let favorites = self.favoriteModel.favorites - let title = favorites.contains(where: { $0.url == self.repository.url }) ? "Remove" : "Add" - return UIBarButtonItem(title: title, - style: .plain, - target: self, - action: #selector(RepositoryViewController.favoriteButtonTap(_:))) - }() - - let repository: Repository - let favoriteModel: FavoriteModelType - - init(repository: Repository, favoriteModel: FavoriteModelType) { - self.repository = repository - self.favoriteModel = favoriteModel - - super.init(url: repository.url, configuration: .init()) + private var cancellables = Set() + private let action: RepositoryActionType + private let store: RepositoryStoreType + + 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 + + store.favoriteButtonTitlePublisher + .map(Optional.some) + .receive(on: DispatchQueue.main) + .assign(to: \.title, on: favoriteButtonItem) + .store(in: &cancellables) + + action.load() } - - @objc private func favoriteButtonTap(_ sender: UIBarButtonItem) { - if favoriteModel.favorites.first(where: { $0.url == repository.url }) == nil { - favoriteModel.addFavorite(repository) - favoriteButtonItem.title = "Remove" - } else { - favoriteModel.removeFavorite(repository) - favoriteButtonItem.title = "Add" - } + + @objc private func favoriteButtonTap(_: UIBarButtonItem) { + action.toggleFavorite() } } 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 cc8ccdb..95fba10 100644 --- a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewController.swift @@ -11,7 +11,7 @@ import GithubKit import UIKit final class SearchViewController: UIViewController { - + @IBOutlet private(set) weak var totalCountLabel: UILabel! @IBOutlet private(set) weak var tableView: UITableView! @IBOutlet private(set) weak var tableViewBottomConstraint: NSLayoutConstraint! @@ -19,27 +19,33 @@ final class SearchViewController: UIViewController { let searchBar = UISearchBar(frame: .zero) let loadingView = LoadingView() - private var cancelllables = Set() - private var isReachedBottom: Bool = false { - didSet { - if isReachedBottom && isReachedBottom != oldValue { - searchModel.fetchUsers() - } - } - } + let action: SearchActionType + let store: SearchStoreType + let dataSource: SearchViewDataSource - let searchModel: SearchModelType - private let makeFavoriteModel: () -> FavoriteModelType - private let makeRepositoryModel: (User) -> RepositoryModelType + 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( - searchModel: SearchModelType, - makeFavoriteModel: @escaping () -> FavoriteModelType, - makeRepositoryModel: @escaping (User) -> RepositoryModelType + action: SearchActionType, + store: SearchStoreType, + makeUserRepositoryAction: @escaping (User) -> UserRepositoryActionType, + makeUserRepositoryStore: @escaping (User) -> UserRepositoryStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType ) { - self.searchModel = searchModel - self.makeFavoriteModel = makeFavoriteModel - self.makeRepositoryModel = makeRepositoryModel + 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) } @@ -51,174 +57,156 @@ final class SearchViewController: UIViewController { super.viewDidLoad() navigationItem.titleView = searchBar - searchBar.delegate = self searchBar.placeholder = "Input user name" - - configure(with: tableView) + searchBar.delegate = self + + dataSource.configure(with: tableView) + + // 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) - searchModel.delegate = self + 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 viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - observeKeyboard() + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + action.isViewAppearing(true) } - + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + action.isViewAppearing(false) + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - if searchBar.isFirstResponder { - searchBar.resignFirstResponder() - } - cancelllables.removeAll() - } - - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(UserViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) - } - - private func observeKeyboard() { - NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) - .sink { [weak self] notification in - guard let info = UIKeyboardInfo(notification: notification) else { - return - } - self?.view.layoutIfNeeded() - let extra = self?.tabBarController?.tabBar.bounds.height ?? 0 - self?.tableViewBottomConstraint.constant = info.frame.size.height - extra - UIView.animate(withDuration: info.animationDuration, delay: 0, options: info.animationCurve, animations: { - self?.view.layoutIfNeeded() - }, completion: nil) - } - .store(in: &cancelllables) - - NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) - .sink { [weak self] notification in - guard let info = UIKeyboardInfo(notification: notification) else { - return - } - self?.view.layoutIfNeeded() - self?.tableViewBottomConstraint.constant = 0 - UIView.animate(withDuration: info.animationDuration, delay: 0, options: info.animationCurve, animations: { - self?.view.layoutIfNeeded() - }, completion: nil) + searchBar.resignFirstResponder() + } + + private var showAccessTokenAlert: (ErrorMessage) -> Void { + { [weak self] error in + guard let me = self else { + return } - .store(in: &cancelllables) + let alert = UIAlertController(title: error.title, message: error.message, preferredStyle: .alert) + me.present(alert, animated: false, completion: nil) + } } - - private func showUserRepository(with user: User) { - let repositoryModel = makeRepositoryModel(user) - let vc = UserRepositoryViewController( - repositoryModel: repositoryModel, - makeFavoriteModel: makeFavoriteModel - ) - navigationController?.pushViewController(vc, animated: true) + + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() + } } -} -extension SearchViewController: SearchModelDelegate { - func searchModel(_ searchModel: SearchModel, didRecieve errorMessage: ErrorMessage) { - DispatchQueue.main.async { - let alert = UIAlertController(title: errorMessage.title, - message: errorMessage.message, - preferredStyle: .alert) - self.present(alert, animated: false, completion: nil) + 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) } } - func searchModel(_ searchModel: SearchModel, didChange isFetchingUsers: Bool) { - DispatchQueue.main.async { - self.tableView.reloadData() + 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) } } - func searchModel(_ searchModel: SearchModel, didChange users: [User]) { - let totalCount = searchModel.totalCount - DispatchQueue.main.async { - self.totalCountLabel.text = "\(users.count) / \(totalCount)" - self.tableView.reloadData() + 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) } } - func searchModel(_ searchModel: SearchModel, didChange totalCount: Int) { - let users = searchModel.users - DispatchQueue.main.async { - self.totalCountLabel.text = "\(users.count) / \(totalCount)" + 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) } } } 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 searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - searchModel.fetchUsers(withQuery: searchText) - } -} -extension SearchViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return searchModel.users.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeue(UserViewCell.self, for: indexPath) - cell.configure(with: searchModel.users[indexPath.row]) - return cell - } - - func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - return nil - } - - func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { - guard let view = tableView.dequeueReusableHeaderFooterView(withIdentifier: UITableViewHeaderFooterView.className) else { - return nil - } - loadingView.removeFromSuperview() - loadingView.isLoading = searchModel.isFetchingUsers - loadingView.add(to: view) - return view + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + searchBar.showsCancelButton = false } -} -extension SearchViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: false) - - let user = searchModel.users[indexPath.row] - showUserRepository(with: user) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return UserViewCell.calculateHeight(with: searchModel.users[indexPath.row], and: tableView) - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return .leastNormalMagnitude - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return searchModel.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) - isReachedBottom = maxScrollDistance <= scrollView.contentOffset.y + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + action.searchText(searchBar.text) } } diff --git a/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift new file mode 100644 index 0000000..a68e503 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/Search/SearchViewDataSource.swift @@ -0,0 +1,84 @@ +// +// SearchViewDataSource.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 + +final class SearchViewDataSource: NSObject { + 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) + } +} + +extension SearchViewDataSource: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return store.users.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(UserViewCell.self, for: indexPath) + 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 + } + action.headerFooterView(view) + return view + } +} + +extension SearchViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + action.setlect(from: store.users, at: indexPath) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + 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 store.isFetchingUsers ? LoadingView.defaultHeight : .leastNormalMagnitude + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) + action.isViewAppearing(maxScrollDistance <= scrollView.contentOffset.y) + } +} 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 fa5880a..1e2262f 100644 --- a/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewController.swift @@ -6,139 +6,104 @@ // Copyright © 2017年 marty-suzuki. All rights reserved. // -import UIKit +import Combine import GithubKit +import UIKit final class UserRepositoryViewController: UIViewController { - + @IBOutlet private(set) weak var tableView: UITableView! @IBOutlet private(set) weak var totalCountLabel: UILabel! let loadingView = LoadingView() - - private var isReachedBottom: Bool = false { - didSet { - if isReachedBottom && isReachedBottom != oldValue { - repositoryModel.fetchRepositories() - } - } - } - let repositoryModel: RepositoryModelType - private let makeFavoriteModel: () -> FavoriteModelType - + let action: UserRepositoryActionType + let store: UserRepositoryStoreType + let dataSource: UserRepositoryViewDataSource + + private let makeRepositoryAction: (Repository) -> RepositoryActionType + private let makeRepositoryStore: (Repository) -> RepositoryStoreType + private var cacellables = Set() + init( - repositoryModel: RepositoryModelType, - makeFavoriteModel: @escaping () -> FavoriteModelType + action: UserRepositoryActionType, + store: UserRepositoryStoreType, + makeRepositoryAction: @escaping (Repository) -> RepositoryActionType, + makeRepositoryStore: @escaping (Repository) -> RepositoryStoreType + ) { - self.repositoryModel = repositoryModel - self.makeFavoriteModel = makeFavoriteModel - + 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 = "\(repositoryModel.user.login)'s Repositories" - - configure(with: tableView) - repositoryModel.delegate = self - repositoryModel.fetchRepositories() - } - - private func configure(with tableView: UITableView) { - tableView.dataSource = self - tableView.delegate = self - - tableView.register(RepositoryViewCell.self) - tableView.register(UITableViewHeaderFooterView.self, forHeaderFooterViewReuseIdentifier: UITableViewHeaderFooterView.className) - } - - private func showRepository(with repository: Repository) { - let favoriteModel = makeFavoriteModel() - let vc = RepositoryViewController(repository: repository, favoriteModel: favoriteModel) - navigationController?.pushViewController(vc, animated: true) - } -} + title = store.title -extension UserRepositoryViewController: RepositoryModelDelegate { - func repositoryModel(_ repositoryModel: RepositoryModel, didChange isFetchingRepositories: Bool) { - DispatchQueue.main.async { - self.tableView.reloadData() - } - } + dataSource.configure(with: tableView) - func repositoryModel(_ repositoryModel: RepositoryModel, didChange repositories: [Repository]) { - let totalCount = repositoryModel.totalCount - DispatchQueue.main.async { - self.totalCountLabel.text = "\(repositories.count) / \(totalCount)" - self.tableView.reloadData() - } + 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 repositoryModel(_ repositoryModel: RepositoryModel, didChange totalCount: Int) { - let repositories = repositoryModel.repositories - DispatchQueue.main.async { - self.totalCountLabel.text = "\(repositories.count) / \(totalCount)" + 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) } } -} -extension UserRepositoryViewController: UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return repositoryModel.repositories.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) - cell.configure(with: repositoryModel.repositories[indexPath.row]) - return cell - } - - func 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 + private var reloadData: () -> Void { + { [weak self] in + self?.tableView.reloadData() } - loadingView.removeFromSuperview() - loadingView.isLoading = repositoryModel.isFetchingRepositories - loadingView.add(to: view) - return view } -} -extension UserRepositoryViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - tableView.deselectRow(at: indexPath, animated: false) - - let repository = repositoryModel.repositories[indexPath.row] - showRepository(with: repository) - } - - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return RepositoryViewCell.calculateHeight(with: repositoryModel.repositories[indexPath.row], and: tableView) - } - - func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return .leastNormalMagnitude - } - - func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { - return repositoryModel.isFetchingRepositories ? LoadingView.defaultHeight : .leastNormalMagnitude - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) - isReachedBottom = maxScrollDistance <= scrollView.contentOffset.y + 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 new file mode 100644 index 0000000..9bccaa2 --- /dev/null +++ b/iOSDesignPatternSamples/Sources/UI/UserRepository/UserRepositoryViewDataSource.swift @@ -0,0 +1,84 @@ +// +// UserRepositoryViewDataSource.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 + +final class UserRepositoryViewDataSource: NSObject { + 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) + } +} + +extension UserRepositoryViewDataSource: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return store.repositories.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeue(RepositoryViewCell.self, for: indexPath) + 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 + } + action.headerFooterView(view) + return view + } +} + +extension UserRepositoryViewDataSource: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + action.select(from: store.repositories, at: indexPath) + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + 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 store.isRepositoryFetching ? LoadingView.defaultHeight : .leastNormalMagnitude + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let maxScrollDistance = max(0, scrollView.contentSize.height - scrollView.bounds.size.height) + action.isReachedBottom(maxScrollDistance <= scrollView.contentOffset.y) + } +}