├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── Assets ├── BatchesDataSource.monopic ├── BatchesDataSource.png ├── combine-data-sources.png ├── plain-collection.gif ├── plain-list.gif ├── sections-list.gif └── slack.png ├── CombineDataSources.podspec ├── Example ├── Example.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── Example.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── Example │ ├── AppDelegate.swift │ ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json │ ├── Base.lproj │ ├── LaunchScreen.storyboard │ └── Main.storyboard │ ├── BatchesViewController.swift │ ├── CollectionViewController.swift │ ├── CustomBatchesViewController.swift │ ├── GitHubSearchViewController.swift │ ├── Info.plist │ ├── MenuTableViewController.swift │ ├── SceneDelegate.swift │ ├── ViewController.swift │ └── etc │ └── SampleData.swift ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── CombineDataSources │ ├── BatchesDataSource │ └── BatchesDataSource.swift │ ├── CollectionView │ ├── CollectionViewItemsController.swift │ └── UICollectionView+Subscribers.swift │ ├── TableView │ ├── TableViewBatchesController.swift │ ├── TableViewItemsController.swift │ └── UITableView+Subscribers.swift │ └── etc │ ├── Publisher+Bind.swift │ └── Section.swift └── Tests └── CombineDataSourcesTests ├── BatchesDataSource └── BatchesDataSourceTests.swift ├── CollectionView ├── CollectionViewItemsControllerTests.swift └── UICollectionView+SubscribersTests.swift ├── MemoryManagementTests.swift ├── TableView ├── TableViewBatchesControllerTests.swift ├── TableViewItemsControllerTests.swift └── UITableView+SubscribersTests.swift └── data └── TestFixtures.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## Build generated 6 | build/ 7 | DerivedData/ 8 | 9 | ## Various settings 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | xcuserdata/ 19 | 20 | ## Other 21 | *.moved-aside 22 | *.xccheckout 23 | *.xcscmblueprint 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | *.ipa 28 | *.dSYM.zip 29 | *.dSYM 30 | 31 | ## Playgrounds 32 | timeline.xctimeline 33 | playground.xcworkspace 34 | 35 | # Swift Package Manager 36 | # 37 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 38 | # Packages/ 39 | # Package.pins 40 | # Package.resolved 41 | .build/ 42 | 43 | # CocoaPods 44 | # 45 | # We recommend against adding the Pods directory to your .gitignore. However 46 | # you should judge for yourself, the pros and cons are mentioned at: 47 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 48 | # 49 | # Pods/ 50 | 51 | # Carthage 52 | # 53 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 54 | # Carthage/Checkouts 55 | 56 | Carthage/Build 57 | 58 | # fastlane 59 | # 60 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 61 | # screenshots whenever they are needed. 62 | # For more information about the recommended setup visit: 63 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 64 | 65 | fastlane/report.xml 66 | fastlane/Preview.html 67 | fastlane/screenshots/**/*.png 68 | fastlane/test_output 69 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Assets/BatchesDataSource.monopic: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CombineCommunity/CombineDataSources/362795e0336f2c1637036f692a3b5d05806302b4/Assets/BatchesDataSource.monopic -------------------------------------------------------------------------------- /Assets/BatchesDataSource.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CombineCommunity/CombineDataSources/362795e0336f2c1637036f692a3b5d05806302b4/Assets/BatchesDataSource.png -------------------------------------------------------------------------------- /Assets/combine-data-sources.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CombineCommunity/CombineDataSources/362795e0336f2c1637036f692a3b5d05806302b4/Assets/combine-data-sources.png -------------------------------------------------------------------------------- /Assets/plain-collection.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CombineCommunity/CombineDataSources/362795e0336f2c1637036f692a3b5d05806302b4/Assets/plain-collection.gif -------------------------------------------------------------------------------- /Assets/plain-list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CombineCommunity/CombineDataSources/362795e0336f2c1637036f692a3b5d05806302b4/Assets/plain-list.gif -------------------------------------------------------------------------------- /Assets/sections-list.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CombineCommunity/CombineDataSources/362795e0336f2c1637036f692a3b5d05806302b4/Assets/sections-list.gif -------------------------------------------------------------------------------- /Assets/slack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CombineCommunity/CombineDataSources/362795e0336f2c1637036f692a3b5d05806302b4/Assets/slack.png -------------------------------------------------------------------------------- /CombineDataSources.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'CombineDataSources' 3 | s.version = '0.2.5' 4 | s.summary = 'CombineDataSources provides custom Combine subscribers for collection/table view' 5 | s.description = <<-DESC 6 | CombineDataSources provides custom Combine subscribers that act as table and collection view controllers and bind a stream of element collections to table or collection sections with cells. 7 | DESC 8 | s.homepage = 'https://github.com/CombineCommunity/CombineDataSources' 9 | s.license = 'MIT' 10 | s.author = { 'Marin Todorov' => 'touch-code-magazine@gmail.com' } 11 | s.ios.deployment_target = '13.0' 12 | s.source = { :git => 'https://github.com/CombineCommunity/CombineDataSources.git', :tag => s.version.to_s } 13 | s.source_files = 'Sources/**/*.swift' 14 | s.framework = ['Combine'] 15 | s.swift_version = '5.0' 16 | end 17 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 9C326989230B2DDE00E93F9C /* CollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C326988230B2DDE00E93F9C /* CollectionViewController.swift */; }; 11 | 9C371EE7230356CE00617B57 /* MenuTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C371EE6230356CE00617B57 /* MenuTableViewController.swift */; }; 12 | 9C7E190B2313E02D00518E33 /* CustomBatchesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C7E190A2313E02D00518E33 /* CustomBatchesViewController.swift */; }; 13 | 9CA01D192312EF7C00666EDE /* BatchesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA01D182312EF7C00666EDE /* BatchesViewController.swift */; }; 14 | 9CA01D1C2312F4CF00666EDE /* SampleData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA01D1B2312F4CF00666EDE /* SampleData.swift */; }; 15 | 9CA4B70D23048B470041CBA4 /* GitHubSearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */; }; 16 | 9CB630CA22FD510000368A0D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB630C922FD510000368A0D /* AppDelegate.swift */; }; 17 | 9CB630CC22FD510000368A0D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB630CB22FD510000368A0D /* SceneDelegate.swift */; }; 18 | 9CB630CE22FD510000368A0D /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CB630CD22FD510000368A0D /* ViewController.swift */; }; 19 | 9CB630D122FD510000368A0D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9CB630CF22FD510000368A0D /* Main.storyboard */; }; 20 | 9CB630D322FD510100368A0D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9CB630D222FD510100368A0D /* Assets.xcassets */; }; 21 | 9CB630D622FD510100368A0D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9CB630D422FD510100368A0D /* LaunchScreen.storyboard */; }; 22 | 9CDF130622FD593000397C16 /* CombineDataSources in Frameworks */ = {isa = PBXBuildFile; productRef = 9CDF130522FD593000397C16 /* CombineDataSources */; }; 23 | /* End PBXBuildFile section */ 24 | 25 | /* Begin PBXFileReference section */ 26 | 9C326988230B2DDE00E93F9C /* CollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewController.swift; sourceTree = ""; }; 27 | 9C371EE6230356CE00617B57 /* MenuTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuTableViewController.swift; sourceTree = ""; }; 28 | 9C7E190A2313E02D00518E33 /* CustomBatchesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomBatchesViewController.swift; sourceTree = ""; }; 29 | 9CA01D182312EF7C00666EDE /* BatchesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchesViewController.swift; sourceTree = ""; }; 30 | 9CA01D1B2312F4CF00666EDE /* SampleData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleData.swift; sourceTree = ""; }; 31 | 9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubSearchViewController.swift; sourceTree = ""; }; 32 | 9CB630C622FD510000368A0D /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33 | 9CB630C922FD510000368A0D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 34 | 9CB630CB22FD510000368A0D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 35 | 9CB630CD22FD510000368A0D /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 36 | 9CB630D022FD510000368A0D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 37 | 9CB630D222FD510100368A0D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 38 | 9CB630D522FD510100368A0D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 39 | 9CB630D722FD510100368A0D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 40 | 9CB630DF22FD523B00368A0D /* CombineDataSources */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CombineDataSources; path = ..; sourceTree = ""; }; 41 | /* End PBXFileReference section */ 42 | 43 | /* Begin PBXFrameworksBuildPhase section */ 44 | 9CB630C322FD510000368A0D /* Frameworks */ = { 45 | isa = PBXFrameworksBuildPhase; 46 | buildActionMask = 2147483647; 47 | files = ( 48 | 9CDF130622FD593000397C16 /* CombineDataSources in Frameworks */, 49 | ); 50 | runOnlyForDeploymentPostprocessing = 0; 51 | }; 52 | /* End PBXFrameworksBuildPhase section */ 53 | 54 | /* Begin PBXGroup section */ 55 | 9CA01D1A2312F4C700666EDE /* etc */ = { 56 | isa = PBXGroup; 57 | children = ( 58 | 9CA01D1B2312F4CF00666EDE /* SampleData.swift */, 59 | ); 60 | path = etc; 61 | sourceTree = ""; 62 | }; 63 | 9CB630BD22FD510000368A0D = { 64 | isa = PBXGroup; 65 | children = ( 66 | 9CB630C822FD510000368A0D /* Example */, 67 | 9CB630C722FD510000368A0D /* Products */, 68 | 9CB630DF22FD523B00368A0D /* CombineDataSources */, 69 | 9CDF130422FD593000397C16 /* Frameworks */, 70 | ); 71 | sourceTree = ""; 72 | }; 73 | 9CB630C722FD510000368A0D /* Products */ = { 74 | isa = PBXGroup; 75 | children = ( 76 | 9CB630C622FD510000368A0D /* Example.app */, 77 | ); 78 | name = Products; 79 | sourceTree = ""; 80 | }; 81 | 9CB630C822FD510000368A0D /* Example */ = { 82 | isa = PBXGroup; 83 | children = ( 84 | 9CA01D1A2312F4C700666EDE /* etc */, 85 | 9CB630C922FD510000368A0D /* AppDelegate.swift */, 86 | 9CB630CB22FD510000368A0D /* SceneDelegate.swift */, 87 | 9C371EE6230356CE00617B57 /* MenuTableViewController.swift */, 88 | 9CB630CD22FD510000368A0D /* ViewController.swift */, 89 | 9C326988230B2DDE00E93F9C /* CollectionViewController.swift */, 90 | 9CA4B70C23048B470041CBA4 /* GitHubSearchViewController.swift */, 91 | 9CA01D182312EF7C00666EDE /* BatchesViewController.swift */, 92 | 9C7E190A2313E02D00518E33 /* CustomBatchesViewController.swift */, 93 | 9CB630CF22FD510000368A0D /* Main.storyboard */, 94 | 9CB630D222FD510100368A0D /* Assets.xcassets */, 95 | 9CB630D422FD510100368A0D /* LaunchScreen.storyboard */, 96 | 9CB630D722FD510100368A0D /* Info.plist */, 97 | ); 98 | path = Example; 99 | sourceTree = ""; 100 | }; 101 | 9CDF130422FD593000397C16 /* Frameworks */ = { 102 | isa = PBXGroup; 103 | children = ( 104 | ); 105 | name = Frameworks; 106 | sourceTree = ""; 107 | }; 108 | /* End PBXGroup section */ 109 | 110 | /* Begin PBXNativeTarget section */ 111 | 9CB630C522FD510000368A0D /* Example */ = { 112 | isa = PBXNativeTarget; 113 | buildConfigurationList = 9CB630DA22FD510100368A0D /* Build configuration list for PBXNativeTarget "Example" */; 114 | buildPhases = ( 115 | 9CB630C222FD510000368A0D /* Sources */, 116 | 9CB630C322FD510000368A0D /* Frameworks */, 117 | 9CB630C422FD510000368A0D /* Resources */, 118 | ); 119 | buildRules = ( 120 | ); 121 | dependencies = ( 122 | 9CDF130322FD592B00397C16 /* PBXTargetDependency */, 123 | ); 124 | name = Example; 125 | packageProductDependencies = ( 126 | 9CDF130522FD593000397C16 /* CombineDataSources */, 127 | ); 128 | productName = Example; 129 | productReference = 9CB630C622FD510000368A0D /* Example.app */; 130 | productType = "com.apple.product-type.application"; 131 | }; 132 | /* End PBXNativeTarget section */ 133 | 134 | /* Begin PBXProject section */ 135 | 9CB630BE22FD510000368A0D /* Project object */ = { 136 | isa = PBXProject; 137 | attributes = { 138 | LastSwiftUpdateCheck = 1100; 139 | LastUpgradeCheck = 1100; 140 | ORGANIZATIONNAME = "Underplot ltd"; 141 | TargetAttributes = { 142 | 9CB630C522FD510000368A0D = { 143 | CreatedOnToolsVersion = 11.0; 144 | }; 145 | }; 146 | }; 147 | buildConfigurationList = 9CB630C122FD510000368A0D /* Build configuration list for PBXProject "Example" */; 148 | compatibilityVersion = "Xcode 9.3"; 149 | developmentRegion = en; 150 | hasScannedForEncodings = 0; 151 | knownRegions = ( 152 | en, 153 | Base, 154 | ); 155 | mainGroup = 9CB630BD22FD510000368A0D; 156 | productRefGroup = 9CB630C722FD510000368A0D /* Products */; 157 | projectDirPath = ""; 158 | projectRoot = ""; 159 | targets = ( 160 | 9CB630C522FD510000368A0D /* Example */, 161 | ); 162 | }; 163 | /* End PBXProject section */ 164 | 165 | /* Begin PBXResourcesBuildPhase section */ 166 | 9CB630C422FD510000368A0D /* Resources */ = { 167 | isa = PBXResourcesBuildPhase; 168 | buildActionMask = 2147483647; 169 | files = ( 170 | 9CB630D622FD510100368A0D /* LaunchScreen.storyboard in Resources */, 171 | 9CB630D322FD510100368A0D /* Assets.xcassets in Resources */, 172 | 9CB630D122FD510000368A0D /* Main.storyboard in Resources */, 173 | ); 174 | runOnlyForDeploymentPostprocessing = 0; 175 | }; 176 | /* End PBXResourcesBuildPhase section */ 177 | 178 | /* Begin PBXSourcesBuildPhase section */ 179 | 9CB630C222FD510000368A0D /* Sources */ = { 180 | isa = PBXSourcesBuildPhase; 181 | buildActionMask = 2147483647; 182 | files = ( 183 | 9C326989230B2DDE00E93F9C /* CollectionViewController.swift in Sources */, 184 | 9C371EE7230356CE00617B57 /* MenuTableViewController.swift in Sources */, 185 | 9CA01D192312EF7C00666EDE /* BatchesViewController.swift in Sources */, 186 | 9CB630CE22FD510000368A0D /* ViewController.swift in Sources */, 187 | 9CA4B70D23048B470041CBA4 /* GitHubSearchViewController.swift in Sources */, 188 | 9CB630CA22FD510000368A0D /* AppDelegate.swift in Sources */, 189 | 9CB630CC22FD510000368A0D /* SceneDelegate.swift in Sources */, 190 | 9C7E190B2313E02D00518E33 /* CustomBatchesViewController.swift in Sources */, 191 | 9CA01D1C2312F4CF00666EDE /* SampleData.swift in Sources */, 192 | ); 193 | runOnlyForDeploymentPostprocessing = 0; 194 | }; 195 | /* End PBXSourcesBuildPhase section */ 196 | 197 | /* Begin PBXTargetDependency section */ 198 | 9CDF130322FD592B00397C16 /* PBXTargetDependency */ = { 199 | isa = PBXTargetDependency; 200 | productRef = 9CDF130222FD592B00397C16 /* CombineDataSources */; 201 | }; 202 | /* End PBXTargetDependency section */ 203 | 204 | /* Begin PBXVariantGroup section */ 205 | 9CB630CF22FD510000368A0D /* Main.storyboard */ = { 206 | isa = PBXVariantGroup; 207 | children = ( 208 | 9CB630D022FD510000368A0D /* Base */, 209 | ); 210 | name = Main.storyboard; 211 | sourceTree = ""; 212 | }; 213 | 9CB630D422FD510100368A0D /* LaunchScreen.storyboard */ = { 214 | isa = PBXVariantGroup; 215 | children = ( 216 | 9CB630D522FD510100368A0D /* Base */, 217 | ); 218 | name = LaunchScreen.storyboard; 219 | sourceTree = ""; 220 | }; 221 | /* End PBXVariantGroup section */ 222 | 223 | /* Begin XCBuildConfiguration section */ 224 | 9CB630D822FD510100368A0D /* Debug */ = { 225 | isa = XCBuildConfiguration; 226 | buildSettings = { 227 | ALWAYS_SEARCH_USER_PATHS = NO; 228 | CLANG_ANALYZER_NONNULL = YES; 229 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 230 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 231 | CLANG_CXX_LIBRARY = "libc++"; 232 | CLANG_ENABLE_MODULES = YES; 233 | CLANG_ENABLE_OBJC_ARC = YES; 234 | CLANG_ENABLE_OBJC_WEAK = YES; 235 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 236 | CLANG_WARN_BOOL_CONVERSION = YES; 237 | CLANG_WARN_COMMA = YES; 238 | CLANG_WARN_CONSTANT_CONVERSION = YES; 239 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 240 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 241 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 242 | CLANG_WARN_EMPTY_BODY = YES; 243 | CLANG_WARN_ENUM_CONVERSION = YES; 244 | CLANG_WARN_INFINITE_RECURSION = YES; 245 | CLANG_WARN_INT_CONVERSION = YES; 246 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 247 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 248 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 249 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 250 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 251 | CLANG_WARN_STRICT_PROTOTYPES = YES; 252 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 253 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 254 | CLANG_WARN_UNREACHABLE_CODE = YES; 255 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 256 | COPY_PHASE_STRIP = NO; 257 | DEBUG_INFORMATION_FORMAT = dwarf; 258 | ENABLE_STRICT_OBJC_MSGSEND = YES; 259 | ENABLE_TESTABILITY = YES; 260 | GCC_C_LANGUAGE_STANDARD = gnu11; 261 | GCC_DYNAMIC_NO_PIC = NO; 262 | GCC_NO_COMMON_BLOCKS = YES; 263 | GCC_OPTIMIZATION_LEVEL = 0; 264 | GCC_PREPROCESSOR_DEFINITIONS = ( 265 | "DEBUG=1", 266 | "$(inherited)", 267 | ); 268 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 269 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 270 | GCC_WARN_UNDECLARED_SELECTOR = YES; 271 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 272 | GCC_WARN_UNUSED_FUNCTION = YES; 273 | GCC_WARN_UNUSED_VARIABLE = YES; 274 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 275 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 276 | MTL_FAST_MATH = YES; 277 | ONLY_ACTIVE_ARCH = YES; 278 | SDKROOT = iphoneos; 279 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 280 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 281 | }; 282 | name = Debug; 283 | }; 284 | 9CB630D922FD510100368A0D /* Release */ = { 285 | isa = XCBuildConfiguration; 286 | buildSettings = { 287 | ALWAYS_SEARCH_USER_PATHS = NO; 288 | CLANG_ANALYZER_NONNULL = YES; 289 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 290 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 291 | CLANG_CXX_LIBRARY = "libc++"; 292 | CLANG_ENABLE_MODULES = YES; 293 | CLANG_ENABLE_OBJC_ARC = YES; 294 | CLANG_ENABLE_OBJC_WEAK = YES; 295 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 296 | CLANG_WARN_BOOL_CONVERSION = YES; 297 | CLANG_WARN_COMMA = YES; 298 | CLANG_WARN_CONSTANT_CONVERSION = YES; 299 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 300 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 301 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 302 | CLANG_WARN_EMPTY_BODY = YES; 303 | CLANG_WARN_ENUM_CONVERSION = YES; 304 | CLANG_WARN_INFINITE_RECURSION = YES; 305 | CLANG_WARN_INT_CONVERSION = YES; 306 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 307 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 308 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 309 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 310 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 311 | CLANG_WARN_STRICT_PROTOTYPES = YES; 312 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 313 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 314 | CLANG_WARN_UNREACHABLE_CODE = YES; 315 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 316 | COPY_PHASE_STRIP = NO; 317 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 318 | ENABLE_NS_ASSERTIONS = NO; 319 | ENABLE_STRICT_OBJC_MSGSEND = YES; 320 | GCC_C_LANGUAGE_STANDARD = gnu11; 321 | GCC_NO_COMMON_BLOCKS = YES; 322 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 323 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 324 | GCC_WARN_UNDECLARED_SELECTOR = YES; 325 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 326 | GCC_WARN_UNUSED_FUNCTION = YES; 327 | GCC_WARN_UNUSED_VARIABLE = YES; 328 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 329 | MTL_ENABLE_DEBUG_INFO = NO; 330 | MTL_FAST_MATH = YES; 331 | SDKROOT = iphoneos; 332 | SWIFT_COMPILATION_MODE = wholemodule; 333 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 334 | VALIDATE_PRODUCT = YES; 335 | }; 336 | name = Release; 337 | }; 338 | 9CB630DB22FD510100368A0D /* Debug */ = { 339 | isa = XCBuildConfiguration; 340 | buildSettings = { 341 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 342 | CODE_SIGN_STYLE = Automatic; 343 | INFOPLIST_FILE = Example/Info.plist; 344 | LD_RUNPATH_SEARCH_PATHS = ( 345 | "$(inherited)", 346 | "@executable_path/Frameworks", 347 | ); 348 | PRODUCT_BUNDLE_IDENTIFIER = com.underplot.Example; 349 | PRODUCT_NAME = "$(TARGET_NAME)"; 350 | SWIFT_VERSION = 5.0; 351 | TARGETED_DEVICE_FAMILY = 1; 352 | }; 353 | name = Debug; 354 | }; 355 | 9CB630DC22FD510100368A0D /* Release */ = { 356 | isa = XCBuildConfiguration; 357 | buildSettings = { 358 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 359 | CODE_SIGN_STYLE = Automatic; 360 | INFOPLIST_FILE = Example/Info.plist; 361 | LD_RUNPATH_SEARCH_PATHS = ( 362 | "$(inherited)", 363 | "@executable_path/Frameworks", 364 | ); 365 | PRODUCT_BUNDLE_IDENTIFIER = com.underplot.Example; 366 | PRODUCT_NAME = "$(TARGET_NAME)"; 367 | SWIFT_VERSION = 5.0; 368 | TARGETED_DEVICE_FAMILY = 1; 369 | }; 370 | name = Release; 371 | }; 372 | /* End XCBuildConfiguration section */ 373 | 374 | /* Begin XCConfigurationList section */ 375 | 9CB630C122FD510000368A0D /* Build configuration list for PBXProject "Example" */ = { 376 | isa = XCConfigurationList; 377 | buildConfigurations = ( 378 | 9CB630D822FD510100368A0D /* Debug */, 379 | 9CB630D922FD510100368A0D /* Release */, 380 | ); 381 | defaultConfigurationIsVisible = 0; 382 | defaultConfigurationName = Release; 383 | }; 384 | 9CB630DA22FD510100368A0D /* Build configuration list for PBXNativeTarget "Example" */ = { 385 | isa = XCConfigurationList; 386 | buildConfigurations = ( 387 | 9CB630DB22FD510100368A0D /* Debug */, 388 | 9CB630DC22FD510100368A0D /* Release */, 389 | ); 390 | defaultConfigurationIsVisible = 0; 391 | defaultConfigurationName = Release; 392 | }; 393 | /* End XCConfigurationList section */ 394 | 395 | /* Begin XCSwiftPackageProductDependency section */ 396 | 9CDF130222FD592B00397C16 /* CombineDataSources */ = { 397 | isa = XCSwiftPackageProductDependency; 398 | productName = CombineDataSources; 399 | }; 400 | 9CDF130522FD593000397C16 /* CombineDataSources */ = { 401 | isa = XCSwiftPackageProductDependency; 402 | productName = CombineDataSources; 403 | }; 404 | /* End XCSwiftPackageProductDependency section */ 405 | }; 406 | rootObject = 9CB630BE22FD510000368A0D /* Project object */; 407 | } 408 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | 8 | @UIApplicationMain 9 | class AppDelegate: UIResponder, UIApplicationDelegate { 10 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 11 | return true 12 | } 13 | 14 | // MARK: UISceneSession Lifecycle 15 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 16 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Example/Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Example/Example/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Example/Example/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 284 | 290 | 311 | 328 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | 456 | 457 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 | 473 | 474 | 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 494 | 495 | 496 | 497 | 498 | 499 | 500 | 501 | 502 | 503 | 504 | 505 | 506 | 507 | 508 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 516 | 517 | 518 | 519 | 520 | 521 | 522 | 523 | 524 | 525 | 532 | 539 | 540 | 541 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 550 | 551 | 552 | 553 | 554 | 555 | 556 | 557 | 558 | 559 | 560 | 561 | 562 | 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | 572 | 573 | 574 | 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 584 | 585 | 586 | 587 | 588 | 589 | 590 | 591 | 592 | 593 | 594 | 595 | 596 | 597 | 598 | 599 | 600 | 601 | 602 | 603 | 610 | 611 | 612 | 613 | 614 | 615 | 616 | 617 | 618 | 619 | 620 | 621 | 622 | 623 | 624 | 625 | 626 | 627 | 628 | 629 | 630 | 631 | 632 | 633 | 634 | 635 | 636 | 637 | 638 | 639 | 640 | 641 | -------------------------------------------------------------------------------- /Example/Example/BatchesViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | import CombineDataSources 9 | 10 | struct MockAPI { 11 | static func requestPage(pageNumber: Int) -> AnyPublisher.LoadResult, Error> { 12 | // Do your network request or otherwise fetch items here. 13 | return sampleData(.pages) 14 | } 15 | 16 | static func requestBatch(token: Data?) -> AnyPublisher.LoadResult, Error> { 17 | // Do your network request or otherwise fetch items here. 18 | return sampleData(.batches) 19 | } 20 | } 21 | 22 | class BatchesViewController: UIViewController { 23 | @IBOutlet var tableView: UITableView! 24 | 25 | enum Demo: Int, RawRepresentable { 26 | case pages, batchesWithToken 27 | } 28 | 29 | var demo: Demo! 30 | var controller: TableViewBatchesController! 31 | 32 | override func viewDidLoad() { 33 | super.viewDidLoad() 34 | 35 | // Create a plain table data source. 36 | let itemsController = TableViewItemsController<[[String]]>(cellIdentifier: "Cell", cellType: UITableViewCell.self, cellConfig: { cell, indexPath, text in 37 | cell.textLabel!.text = "\(indexPath.row+1). \(text)" 38 | }) 39 | 40 | switch demo { 41 | case .batchesWithToken: 42 | 43 | // Bind a batched data source to table view. 44 | controller = TableViewBatchesController( 45 | tableView: tableView, 46 | itemsController: itemsController, 47 | initialToken: nil, 48 | loadItemsWithToken: { nextToken in 49 | MockAPI.requestBatch(token: nextToken) 50 | } 51 | ) 52 | 53 | case .pages: 54 | 55 | // Bind a paged data source to table view. 56 | controller = TableViewBatchesController( 57 | tableView: tableView, 58 | itemsController: itemsController, 59 | loadPage: { nextPage in 60 | return MockAPI.requestPage(pageNumber: nextPage) 61 | } 62 | ) 63 | 64 | default: break 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Example/Example/CollectionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | import CombineDataSources 9 | 10 | class PersonCollectionCell: UICollectionViewCell { 11 | @IBOutlet var nameLabel: UILabel! 12 | @IBOutlet var image: UIImageView! 13 | private var subscriptions = [AnyCancellable]() 14 | 15 | var imageURL: URL! { 16 | didSet { 17 | URLSession.shared.dataTaskPublisher(for: imageURL) 18 | .compactMap { UIImage(data: $0.data) } 19 | .replaceError(with: UIImage()) 20 | .receive(on: DispatchQueue.main) 21 | .assign(to: \.image, on: image) 22 | .store(in: &subscriptions) 23 | } 24 | } 25 | } 26 | 27 | class CollectionViewController: UIViewController { 28 | enum Demo: Int, RawRepresentable { 29 | case plain, multiple, sections, noAnimations 30 | } 31 | 32 | @IBOutlet var collectionView: UICollectionView! 33 | 34 | // The kind of demo to show 35 | var demo: Demo = .plain 36 | 37 | // Test data set to use 38 | let first = [ 39 | [Person(name: "Julia"), Person(name: "Vicki"), Person(name: "Pete")], 40 | [Person(name: "Jim"), Person(name: "Jane")], 41 | ] 42 | let second = [ 43 | [Person(name: "Pete"), Person(name: "Vicki")], 44 | [Person(name: "Jim")], 45 | ] 46 | 47 | // Publisher to emit data to the table 48 | var data = PassthroughSubject<[[Person]], Never>() 49 | 50 | private var flag = false 51 | 52 | // Emits values out of `data` 53 | func reload() { 54 | data.send(flag ? first : second) 55 | flag.toggle() 56 | 57 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in 58 | self?.reload() 59 | } 60 | } 61 | 62 | override func viewDidLoad() { 63 | super.viewDidLoad() 64 | 65 | switch demo { 66 | case .plain: 67 | // A plain list with a single section -> Publisher<[Person], Never> 68 | data 69 | .map { $0[0] } 70 | .subscribe(collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in 71 | cell.nameLabel.text = model.name 72 | cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")! 73 | })) 74 | 75 | case .multiple: 76 | // Table with sections -> Publisher<[[Person]], Never> 77 | data 78 | .subscribe(collectionView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in 79 | cell.nameLabel.text = model.name 80 | cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")! 81 | })) 82 | 83 | case .sections: 84 | // Table with section driven by `Section` models -> Publisher<[Section], Never> 85 | data 86 | .map { sections in 87 | return sections.map { persons -> Section in 88 | return Section(items: persons) 89 | } 90 | } 91 | .subscribe(collectionView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in 92 | cell.nameLabel.text = model.name 93 | cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")! 94 | })) 95 | 96 | case .noAnimations: 97 | // Use custom controller to disable animations 98 | let controller = CollectionViewItemsController<[[Person]]>(cellIdentifier: "Cell", cellType: PersonCollectionCell.self) { cell, indexPath, person in 99 | cell.nameLabel.text = person.name 100 | cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(person.name)")! 101 | } 102 | controller.animated = false 103 | 104 | data 105 | .subscribe(collectionView.sectionsSubscriber(controller)) 106 | } 107 | 108 | reload() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Example/Example/CustomBatchesViewController.swift: -------------------------------------------------------------------------------- 1 | 2 | import UIKit 3 | import Combine 4 | import CombineDataSources 5 | 6 | // An example custom token type. 7 | struct ServerToken: Codable { 8 | let id: UUID 9 | let count: Int 10 | } 11 | 12 | enum APIError: LocalizedError { 13 | case test 14 | var errorDescription: String? { 15 | return "Request failed, try again." 16 | } 17 | } 18 | 19 | var requestsCounter = 0 20 | 21 | extension MockAPI { 22 | // An example of some custom token logic - for this demo we use a JSON struct that holds 23 | // a custom UUID and the count of elements to fetch in the current batch. 24 | static func requestBatchCustomToken(_ token: Data?) -> AnyPublisher.LoadResult, Error> { 25 | let serverToken: ServerToken? = token.map { try! JSONDecoder().decode(ServerToken.self, from: $0) } 26 | // Do network request, database lookup, etc. here 27 | return Future.LoadResult, Error> { promise in 28 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 29 | let currentBatchCount = serverToken?.count ?? 2 30 | let nextToken = ServerToken(id: UUID(), count: currentBatchCount * 2) 31 | let items = (0.. 0 else { 35 | // Return a test error 36 | promise(.failure(APIError.test)) 37 | return 38 | } 39 | 40 | guard currentBatchCount < 50 else { 41 | // No more items to fetch 42 | promise(.success(.completed)) 43 | return 44 | } 45 | 46 | // Return the current batch items + the token to fetch the next batch. 47 | promise(.success(.itemsToken(items, nextToken: try! JSONEncoder().encode(nextToken)))) 48 | } 49 | }.eraseToAnyPublisher() 50 | } 51 | } 52 | 53 | class CustomBatchesViewController: UIViewController { 54 | @IBOutlet var itemsLabel: UILabel! 55 | @IBOutlet var statusLabel: UILabel! 56 | @IBOutlet var loadNextButton: UIButton! 57 | @IBOutlet var resetButton: UIButton! 58 | 59 | var batcher: BatchesDataSource! 60 | var subscriptions = [AnyCancellable]() 61 | 62 | let loadNextSubject = PassthroughSubject() 63 | let resetSubject = PassthroughSubject() 64 | 65 | override func viewWillAppear(_ animated: Bool) { 66 | super.viewWillAppear(animated) 67 | 68 | let input = BatchesInput( 69 | reload: resetSubject.eraseToAnyPublisher(), 70 | loadNext: loadNextSubject.eraseToAnyPublisher() 71 | ) 72 | 73 | batcher = BatchesDataSource( 74 | items: ["Initial Element"], 75 | input: input, 76 | initialToken: nil, 77 | loadItemsWithToken: { token in 78 | return MockAPI.requestBatchCustomToken(token) 79 | }) 80 | 81 | // Bind Items label 82 | batcher.output.$items 83 | .map { "\($0.count) items fetched" } 84 | .assign(to: \.text, on: itemsLabel) 85 | .store(in: &subscriptions) 86 | 87 | // Bind Status label 88 | Publishers.MergeMany([ 89 | // Status: is loading 90 | batcher.output.$isLoading.filter { $0 } 91 | .map { _ in "Loading batch..." }.eraseToAnyPublisher(), 92 | 93 | // Status: is completed 94 | batcher.output.$isCompleted.filter { $0 } 95 | .map { _ in "Fetched all items available" }.eraseToAnyPublisher(), 96 | 97 | // Status: successfull fetch 98 | Publishers.CombineLatest3(batcher.output.$isLoading, batcher.output.$isCompleted, batcher.output.$error) 99 | .filter { !$0 && !$1 && $2 == nil} 100 | .map { _ in "Fetched succcessfully" } 101 | .eraseToAnyPublisher(), 102 | 103 | // Status: error 104 | batcher.output.$error 105 | .filter { $0 != nil } 106 | .map { $0?.localizedDescription } 107 | .eraseToAnyPublisher() 108 | ]) 109 | .assign(to: \.text, on: statusLabel) 110 | .store(in: &subscriptions) 111 | 112 | // Bind Load next button alpha 113 | Publishers.CombineLatest(batcher.output.$isLoading, batcher.output.$isCompleted) 114 | .map { $0 || $1 ? 0.5 : 1.0 } 115 | .assign(to: \.alpha, on: loadNextButton) 116 | .store(in: &subscriptions) 117 | 118 | // Bind Load next is enabled 119 | Publishers.CombineLatest(batcher.output.$isLoading, batcher.output.$isCompleted) 120 | .map { !($0 || $1) } 121 | .assign(to: \.isEnabled, on: loadNextButton) 122 | .store(in: &subscriptions) 123 | 124 | // Bind Reset button 125 | batcher.output.$isLoading 126 | .map { !$0 } 127 | .assign(to: \.isEnabled, on: resetButton) 128 | .store(in: &subscriptions) 129 | } 130 | 131 | @IBAction func loadNext() { 132 | loadNextSubject.send() 133 | } 134 | 135 | @IBAction func reset() { 136 | resetSubject.send() 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Example/Example/GitHubSearchViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | import CombineDataSources 9 | 10 | struct Repo: Codable, Hashable { 11 | let name: String 12 | let description: String? 13 | } 14 | 15 | struct SearchResults: Codable { 16 | let items: [Repo] 17 | } 18 | 19 | class GitHubSearchViewController: UIViewController, UISearchBarDelegate { 20 | @IBOutlet var tableView: UITableView! 21 | private var subscriptions = [AnyCancellable]() 22 | 23 | func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { 24 | guard let searchText = searchBar.text, !searchText.isEmpty else { return } 25 | 26 | URLSession.shared.dataTaskPublisher(for: 27 | URL(string: "https://api.github.com/search/repositories?q=\(searchText)")!) 28 | .map { $0.0 } 29 | .decode(type: SearchResults.self, decoder: JSONDecoder()) 30 | .map { $0.items } 31 | .replaceError(with: []) 32 | .receive(on: DispatchQueue.main) 33 | .bind(subscriber: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: UITableViewCell.self, cellConfig: { (cell, ip, repo) in 34 | cell.textLabel!.text = repo.name 35 | cell.detailTextLabel!.text = repo.description 36 | })) 37 | .store(in: &subscriptions) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Example/Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UISceneConfigurationName 33 | Default Configuration 34 | UISceneDelegateClassName 35 | $(PRODUCT_MODULE_NAME).SceneDelegate 36 | UISceneStoryboardFile 37 | Main 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIMainStoryboardFile 45 | Main 46 | UIRequiredDeviceCapabilities 47 | 48 | armv7 49 | 50 | UISupportedInterfaceOrientations 51 | 52 | UIInterfaceOrientationPortrait 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /Example/Example/MenuTableViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | 8 | class MenuTableViewController: UITableViewController { 9 | override func prepare(for segue: UIStoryboardSegue, sender: Any?) { 10 | let rowIndex = (sender as! UITableViewCell).tag 11 | 12 | (segue.destination as? ViewController)?.demo = ViewController.Demo(rawValue: rowIndex)! 13 | (segue.destination as? CollectionViewController)?.demo = CollectionViewController.Demo(rawValue: rowIndex)! 14 | (segue.destination as? BatchesViewController)?.demo = BatchesViewController.Demo(rawValue: rowIndex)! 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Example/Example/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | 8 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 9 | var window: UIWindow? 10 | } 11 | -------------------------------------------------------------------------------- /Example/Example/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | import CombineDataSources 9 | 10 | struct Person: Hashable { 11 | let name: String 12 | } 13 | 14 | class PersonCell: UITableViewCell { 15 | @IBOutlet var nameLabel: UILabel! 16 | } 17 | 18 | class ViewController: UIViewController { 19 | enum Demo: Int, RawRepresentable { 20 | case plain, multiple, sections, noAnimations 21 | } 22 | 23 | @IBOutlet var tableView: UITableView! 24 | 25 | // The kind of demo to show 26 | var demo: Demo = .plain 27 | 28 | // Test data set to use 29 | let first = [ 30 | [Person(name: "Julia"), Person(name: "Vicki"), Person(name: "Pete")], 31 | [Person(name: "Jane"), Person(name: "Jim")], 32 | ] 33 | let second = [ 34 | [Person(name: "Pete"), Person(name: "Vicki")], 35 | [Person(name: "Jim")], 36 | ] 37 | 38 | // Publisher to emit data to the table 39 | var data = PassthroughSubject<[[Person]], Never>() 40 | var subscriptions = [AnyCancellable]() 41 | 42 | private var flag = false 43 | 44 | // Emits values out of `data` 45 | func reload() { 46 | data.send(flag ? first : second) 47 | flag.toggle() 48 | 49 | DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in 50 | self?.reload() 51 | } 52 | } 53 | 54 | override func viewDidLoad() { 55 | super.viewDidLoad() 56 | 57 | switch demo { 58 | case .plain: 59 | // A plain list with a single section -> Publisher<[Person], Never> 60 | first.publisher 61 | .bind(subscriber: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in 62 | cell.nameLabel.text = "\(indexPath.section+1).\(indexPath.row+1) \(model.name)" 63 | })) 64 | .store(in: &subscriptions) 65 | 66 | case .multiple: 67 | // Table with sections -> Publisher<[[Person]], Never> 68 | data 69 | .bind(subscriber: tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in 70 | cell.nameLabel.text = "\(indexPath.section+1).\(indexPath.row+1) \(model.name)" 71 | })) 72 | .store(in: &subscriptions) 73 | 74 | case .sections: 75 | // Table with section driven by `Section` models -> Publisher<[Section], Never> 76 | data 77 | .map { sections in 78 | return sections.map { persons -> Section in 79 | return Section(header: "Header", items: persons, footer: "Footer") 80 | } 81 | } 82 | .bind(subscriber: tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in 83 | cell.nameLabel.text = "\(indexPath.section+1).\(indexPath.row+1) \(model.name)" 84 | })) 85 | .store(in: &subscriptions) 86 | 87 | case .noAnimations: 88 | // Use custom controller to disable animations 89 | let controller = TableViewItemsController<[[Person]]>(cellIdentifier: "Cell", cellType: PersonCell.self) { cell, indexPath, person in 90 | cell.nameLabel.text = "\(indexPath.section+1).\(indexPath.row+1) \(person.name)" 91 | } 92 | controller.animated = false 93 | 94 | data 95 | .bind(subscriber: tableView.sectionsSubscriber(controller)) 96 | .store(in: &subscriptions) 97 | } 98 | 99 | reload() 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Example/Example/etc/SampleData.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import Combine 4 | import CombineDataSources 5 | 6 | enum SampleDataType { 7 | case pages, batches 8 | } 9 | 10 | func sampleData(_ type: SampleDataType, count: Int = 20) -> AnyPublisher.LoadResult, Error> { 11 | return Future.LoadResult, Error> { promise in 12 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 13 | switch type { 14 | case .pages: 15 | promise(.success(BatchesDataSource.LoadResult.items((0..() 41 | 42 | data 43 | .bind(subscriber: tableView.rowsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in 44 | cell.nameLabel.text = model.name 45 | })) 46 | .store(in: &subscriptions) 47 | ``` 48 | 49 | ![Plain list updates with CombineDataSources](https://github.com/combineopensource/CombineDataSources/raw/master/Assets/plain-list.gif) 50 | 51 | Respectively for a collection view: 52 | 53 | ```swift 54 | data 55 | .bind(subscriber: collectionView.itemsSubscriber(cellIdentifier: "Cell", cellType: PersonCollectionCell.self, cellConfig: { cell, indexPath, model in 56 | cell.nameLabel.text = model.name 57 | cell.imageURL = URL(string: "https://api.adorable.io/avatars/100/\(model.name)")! 58 | })) 59 | .store(in: &subscriptions) 60 | ``` 61 | 62 | ![Plain list updates for a collection view](https://github.com/combineopensource/CombineDataSources/raw/master/Assets/plain-collection.gif) 63 | 64 | #### Bind a list of Section models 65 | 66 | ```swift 67 | var data = PassthroughSubject<[Section], Never>() 68 | 69 | data 70 | .bind(subscriber: tableView.sectionsSubscriber(cellIdentifier: "Cell", cellType: PersonCell.self, cellConfig: { cell, indexPath, model in 71 | cell.nameLabel.text = model.name 72 | })) 73 | .store(in: &subscriptions) 74 | ``` 75 | 76 | ![Sectioned list updates with CombineDataSources](https://github.com/combineopensource/CombineDataSources/raw/master/Assets/sections-list.gif) 77 | 78 | #### Customize the table controller 79 | 80 | ```swift 81 | var data = PassthroughSubject<[[Person]], Never>() 82 | 83 | let controller = TableViewItemsController<[[Person]]>(cellIdentifier: "Cell", cellType: PersonCell.self) { cell, indexPath, person in 84 | cell.nameLabel.text = person.name 85 | } 86 | controller.animated = false 87 | 88 | // More custom controller configuration ... 89 | 90 | data 91 | .bind(subscriber: tableView.sectionsSubscriber(controller)) 92 | .store(in: &subscriptions) 93 | ``` 94 | 95 | #### List loaded in batches 96 | 97 | A common pattern for list views is to load a very long list of elements in "batches" or "pages". (The distinction being that pages imply ordered, equal-length batches.) 98 | 99 | **CombineDataSources** includes a data source allowing you to easily implement the batched list pattern called `BatchesDataSource` and a table view controller `TableViewBatchesController` which wraps loading items in batches via the said data source and managing your UI. 100 | 101 | In case you want to implement your own custom logic, you can use directly the data source type: 102 | 103 | ```swift 104 | let input = BatchesInput( 105 | reload: resetSubject.eraseToAnyPublisher(), 106 | loadNext: loadNextSubject.eraseToAnyPublisher() 107 | ) 108 | 109 | let dataSource = BatchesDataSource( 110 | items: ["Initial Element"], 111 | input: input, 112 | initialToken: nil, 113 | loadItemsWithToken: { token in 114 | return MockAPI.requestBatchCustomToken(token) 115 | }) 116 | ``` 117 | 118 | `dataSource` is controlled via the two inputs: 119 | 120 | - `input.reload` (to reload the very first batch) and 121 | 122 | - `loadNext` (to load each next batch) 123 | 124 | The data source has four outputs: 125 | 126 | - `output.$items` is the current list of elements, 127 | 128 | - `output.$isLoading` whether it's currently fetching a batch of elements, 129 | 130 | - `output.$isCompleted` whether the data source fetched all available elements, and 131 | 132 | - `output.$error` which is a stream of `Error?` elements where errors by the loading closure will bubble up. 133 | 134 | In case you'd like to use the provided controller the code is fairly simple as well. You use the standard table view items controller and `TableViewBatchesController` like so: 135 | 136 | ```swift 137 | let itemsController = TableViewItemsController<[[String]]>(cellIdentifier: "Cell", cellType: UITableViewCell.self, cellConfig: { cell, indexPath, text in 138 | cell.textLabel!.text = "\(indexPath.row+1). \(text)" 139 | }) 140 | 141 | let tableController = TableViewBatchesController( 142 | tableView: tableView, 143 | itemsController: itemsController, 144 | initialToken: nil, 145 | loadItemsWithToken: { nextToken in 146 | MockAPI.requestBatch(token: nextToken) 147 | } 148 | ) 149 | ``` 150 | 151 | `tableController` will set the table view data source, fetch items, and display cells with the proper animations. 152 | 153 | ## Todo 154 | 155 | - [ ] much better README, pls 156 | - [ ] use a @Published for the time being instead of withLatestFrom 157 | - [ ] make the batches data source prepend or append the new batch (e.g. new items come from the top or at the bottom) 158 | - [ ] cover every API with tests 159 | - [ ] make the default batches view controller neater 160 | - [ ] add AppKit version of the data sources 161 | - [x] support Cocoapods 162 | 163 | ## Installation 164 | 165 | ### Swift Package Manager 166 | 167 | Add the following dependency to your **Package.swift** file: 168 | 169 | ```swift 170 | .package(url: "https://github.com/combineopensource/CombineDataSources, from: "0.2") 171 | ``` 172 | 173 | ### Cocoapods 174 | Add the following dependency to your **Podfile**: 175 | 176 | ```swift 177 | pod 'CombineDataSources' 178 | ``` 179 | 180 | ## License 181 | 182 | CombineOpenSource is available under the MIT license. See the LICENSE file for more info. 183 | 184 | ## Combine Open Source 185 | 186 | ![Combine Slack channel](Assets/slack.png) 187 | 188 | CombineOpenSource Slack channel: [https://combineopensource.slack.com](https://combineopensource.slack.com). 189 | 190 | [Sign up here](https://join.slack.com/t/combineopensource/shared_invite/enQtNzQ1MzYyMTMxOTkxLWJkZmNkZDU4MTE4NmU2MjBhYzM5NzI1NTRlNWNhODFiMDEyMjVjOWZmZWI2NmViMzU3ZjZhYjc0YTExOGZmMDM) 191 | 192 | ## Credits 193 | 194 | Created by Marin Todorov for [CombineOpenSource](https://github.com/combineopensource). 195 | 196 | 📚 You can support me by checking out our Combine book: [combinebook.com](http://combinebook.com). 197 | 198 | Inspired by [RxDataSources](https://github.com/RxSwiftCommunity/RxDataSources) and [RxRealmDataSources](https://github.com/RxSwiftCommunity/RxRealmDataSources). 199 | -------------------------------------------------------------------------------- /Sources/CombineDataSources/BatchesDataSource/BatchesDataSource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | /* 7 | Data flow in BatchesDataSource: 8 | Dashed boxes represent the inputs provided to `BatchesDataSource.init(...)`. 9 | Single line boxes are the intermediate publishers. 10 | Double line boxes are the published outputs. 11 | 12 | ┌──────────────────────┐ ╔════════════════════╗ 13 | ┌──────────────────────▶│ itemsSubject │──────────────────▶║ Output.$items ║◀───┐ 14 | │ └──────────────────────┘ ╚════════════════════╝ │ 15 | │ ╔════════════════════╗ │ 16 | │ ┌──────────────────────┬──────────────────▶║ Output.$isLoading ║ │ 17 | │ │ │ ╚════════════════════╝ │ 18 | │ │ │ │ 19 | │ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ 20 | ┌──────────────┐ │ │ │ │ │ │ │ │ 21 | ┌─┬──▶│ reload │──┬──▶│ batchRequest │─▶│ batchResponse │─▶│ successResponse │─▶│ result │ 22 | │ │ └──────────────┘ │ │ │ │ │ │ │ │ │ 23 | │ │ │ └───────────────────┘ └───────────────────┘ └───────────────────┘ └───────────────────┘ 24 | │ │ ┌──────────────┐ ▲ │ │ │ 25 | │ │ │ loadNext │ └───────┐ │ │ │ 26 | │ │ └──────────────┘ │ │ ┌─────┘ │ 27 | │ │ ▲ │ │ │ │ 28 | │ │ │ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ │ ╔════════════════════╗ │ 29 | │ │ ┌ ─ ─ ─ ─ ─ ─ ─ │ loadNextBatch() │ │ └─▶║Output.$isCompleted ║ │ 30 | │ └── initialToken │ │ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ ╚════════════════════╝ │ 31 | │ └ ─ ─ ─ ─ ─ ─ ─ │ │ ╔════════════════════╗ │ 32 | │ │ └──────────────────▶║ Output.$error ║ │ 33 | │ ┌ ─ ─ ─ ─ ─ ─ ─ │ ╚════════════════════╝ │ 34 | └── items │ │ ┌──────────────────┐ │ 35 | └ ─ ─ ─ ─ ─ ─ ─ └─────────────────────────────│ token │◀────────────────────────────────┘ 36 | └──────────────────┘ 37 | */ 38 | 39 | import Foundation 40 | import Combine 41 | 42 | /// Batches source input. Provides two publishers to control requesting the next batch 43 | /// of items and resetting the items collection. 44 | public struct BatchesInput { 45 | public init(reload: AnyPublisher? = nil, loadNext: AnyPublisher) { 46 | self.reload = reload ?? Empty().eraseToAnyPublisher() 47 | self.loadNext = loadNext 48 | } 49 | 50 | /// Resets the list and loads the initial list of items. 51 | public let reload: AnyPublisher 52 | 53 | /// Loads the next batch of items. 54 | public let loadNext: AnyPublisher 55 | } 56 | 57 | /// Manages a list of items in batches or pages. 58 | public struct BatchesDataSource { 59 | internal let input: BatchesInput 60 | 61 | public class Output { 62 | /// Is the data source currently fetching a batch of items. 63 | @Published public var isLoading = false 64 | 65 | /// Is the data source loaded all available items. 66 | @Published public var isCompleted = false 67 | 68 | /// The list of items fetched so far. 69 | @Published public var items = [Element]() 70 | 71 | /// The last error while fetching a batch of items. 72 | @Published public var error: Error? = nil 73 | } 74 | 75 | /// The current output of the data source. 76 | public let output = Output() 77 | 78 | private var subscriptions = [AnyCancellable]() 79 | 80 | /// The result of loading of a batch of items. 81 | public enum LoadResult { 82 | /// A batch of `Element` items to use with pages. 83 | case items([Element]) 84 | 85 | /// A batch of `Element` items and a token to provide 86 | /// to the loader in order to fetch the next batch. 87 | case itemsToken([Element], nextToken: Data?) 88 | 89 | /// No more items available to fetch. 90 | case completed 91 | } 92 | 93 | enum ResponseResult { 94 | case result((token: Token, result: BatchesDataSource.LoadResult)) 95 | case error(Error) 96 | } 97 | 98 | enum Token { 99 | case int(Int) 100 | case data(Data?) 101 | } 102 | 103 | private init(items: [Element] = [], input: BatchesInput, initial: Token, loadNextCallback: @escaping (Token) -> AnyPublisher) { 104 | let itemsSubject = CurrentValueSubject<[Element], Never>(items) 105 | let token = CurrentValueSubject(initial) 106 | 107 | self.input = input 108 | let output = self.output 109 | 110 | input.reload 111 | .map { _ in items } 112 | .append(Empty(completeImmediately: false)) 113 | .subscribe(itemsSubject) 114 | .store(in: &subscriptions) 115 | 116 | let loadNext = input.loadNext 117 | .map { token.value } 118 | 119 | let batchRequest = loadNext 120 | .merge(with: input.reload.prepend(()).map { initial }) 121 | .eraseToAnyPublisher() 122 | 123 | // TODO: avoid having extra subject when `shareReplay()` is introduced. 124 | let batchResponse = PassthroughSubject() 125 | 126 | batchResponse 127 | .map { result -> Error? in 128 | switch result { 129 | case .error(let error): return error 130 | default: return nil 131 | } 132 | } 133 | .assign(to: \Output.error, on: output) 134 | .store(in: &subscriptions) 135 | 136 | // Bind `Output.isLoading` 137 | Publishers.Merge(batchRequest.map { _ in true }, batchResponse.map { _ in false }) 138 | .assign(to: \Output.isLoading, on: output) 139 | .store(in: &subscriptions) 140 | 141 | let successResponse = batchResponse 142 | .compactMap { result -> (token: Token, result: BatchesDataSource.LoadResult)? in 143 | switch result { 144 | case .result(let result): return result 145 | default: return nil 146 | } 147 | } 148 | .share() 149 | 150 | // Bind `Output.isCompleted` 151 | successResponse 152 | .map { tuple -> Bool in 153 | switch tuple.result { 154 | case .completed: return true 155 | default: return false 156 | } 157 | } 158 | .assign(to: \Output.isCompleted, on: output) 159 | .store(in: &subscriptions) 160 | 161 | let result = successResponse 162 | .compactMap { tuple -> (token: Token, items: [Element], nextToken: Token)? in 163 | switch tuple.result { 164 | case .completed: 165 | return nil 166 | case .items(let elements): 167 | // Fix incremeneting page number 168 | guard case Token.int(let currentPage) = tuple.token else { fatalError() } 169 | return (token: tuple.token, items: elements, nextToken: .int(currentPage+1)) 170 | case .itemsToken(let elements, let nextToken): 171 | return (token: tuple.token, items: elements, nextToken: .data(nextToken)) 172 | } 173 | } 174 | .share() 175 | 176 | // Bind `token` 177 | result 178 | .map { $0.nextToken } 179 | .subscribe(token) 180 | .store(in: &subscriptions) 181 | 182 | // Bind `items` 183 | result 184 | .map { 185 | // TODO: Solve for `withLatestFrom(_)` 186 | let currentItems = itemsSubject.value 187 | return currentItems + $0.items 188 | } 189 | .subscribe(itemsSubject) 190 | .store(in: &subscriptions) 191 | 192 | // Bind `Output.items` 193 | itemsSubject 194 | .assign(to: \Output.items, on: output) 195 | .store(in: &subscriptions) 196 | 197 | batchRequest 198 | .flatMap { token in 199 | return loadNextCallback(token) 200 | .map { result -> ResponseResult in 201 | return .result((token: token, result: result)) 202 | } 203 | .catch { error in 204 | Just(ResponseResult.error(error)) 205 | } 206 | .append(Empty(completeImmediately: true)) 207 | } 208 | .sink(receiveValue: batchResponse.send) 209 | .store(in: &subscriptions) 210 | 211 | } 212 | 213 | /// Initializes a list data source using a token to fetch batches of items. 214 | /// - Parameter items: initial list of items. 215 | /// - Parameter input: the input to control the data source. 216 | /// - Parameter initialToken: the token to use to fetch the first batch. 217 | /// - Parameter loadItemsWithToken: a `(Data?) -> (Publisher)` closure that fetches a batch of items and returns the items fetched 218 | /// plus a token to use for the next batch. The token can be an alphanumerical id, a URL, or another type of token. 219 | /// - Todo: if `withLatestFrom` is introduced, use it instead of grabbing the latest value unsafely. 220 | public init(items: [Element] = [], input: BatchesInput, initialToken: Data?, loadItemsWithToken: @escaping (Data?) -> AnyPublisher) { 221 | self.init(items: items, input: input, initial: Token.data(initialToken), loadNextCallback: { token -> AnyPublisher in 222 | switch token { 223 | case .data(let data): 224 | return loadItemsWithToken(data) 225 | default: fatalError() 226 | } 227 | }) 228 | } 229 | 230 | /// Initialiazes a list data source of items batched in numbered pages. 231 | /// - Parameter items: initial list of items. 232 | /// - Parameter input: the input to control the data source. 233 | /// - Parameter initialPage: the page number to use for the first load of items. 234 | /// - Parameter loadPage: a `(Int) -> (Publisher)` closure that fetches a batch of items. 235 | /// - Todo: if `withLatestFrom` is introduced, use it instead of grabbing the latest value unsafely. 236 | public init(items: [Element] = [], input: BatchesInput, initialPage: Int = 0, loadPage: @escaping (Int) -> AnyPublisher) { 237 | self.init(items: items, input: input, initial: Token.int(initialPage), loadNextCallback: { page -> AnyPublisher in 238 | switch page { 239 | case .int(let page): 240 | return loadPage(page) 241 | default: fatalError() 242 | } 243 | }) 244 | } 245 | } 246 | 247 | fileprivate var uuids = [String: Int]() 248 | 249 | extension Publisher { 250 | public func assertMaxSubscriptions(_ max: Int, file: StaticString = #file, line: UInt = #line) -> AnyPublisher { 251 | let uuid = "\(file):\(line)" 252 | 253 | return handleEvents(receiveSubscription: { _ in 254 | let count = uuids[uuid] ?? 0 255 | guard count < max else { 256 | assert(false, "Publisher subscribed more than \(max) times.") 257 | return 258 | } 259 | uuids[uuid] = count + 1 260 | }).eraseToAnyPublisher() 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /Sources/CombineDataSources/CollectionView/CollectionViewItemsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | 9 | /// A collection view controller acting as data source. 10 | /// `CollectionType` needs to be a collection of collections to represent sections containing rows. 11 | public class CollectionViewItemsController: NSObject, UICollectionViewDataSource 12 | where CollectionType: RandomAccessCollection, 13 | CollectionType.Index == Int, 14 | CollectionType.Element: Hashable, 15 | CollectionType.Element: RandomAccessCollection, 16 | CollectionType.Element.Index == Int, 17 | CollectionType.Element.Element: Hashable { 18 | 19 | public typealias Element = CollectionType.Element.Element 20 | public typealias CellFactory = (CollectionViewItemsController, UICollectionView, IndexPath, Element) -> UICollectionViewCell 21 | public typealias SupplementaryViewFactory = (CollectionViewItemsController, UICollectionView, String, IndexPath, CollectionType.Element) -> UICollectionReusableView 22 | public typealias CellConfig = (Cell, IndexPath, Element) -> Void 23 | 24 | private let cellFactory: CellFactory 25 | private var collection: CollectionType! 26 | 27 | /// Should the table updates be animated or static. 28 | public var animated = true 29 | 30 | /// The collection view for the data source 31 | var collectionView: UICollectionView! 32 | 33 | /// A fallback data source to implement custom logic like indexes, dragging, etc. 34 | public var dataSource: UICollectionViewDataSource? 35 | 36 | public var configureSupplementaryView: SupplementaryViewFactory? 37 | 38 | // MARK: - Init 39 | 40 | /// An initializer that takes a cell type and identifier and configures the controller to dequeue cells 41 | /// with that data and configures each cell by calling the developer provided `cellConfig()`. 42 | /// - Parameter cellIdentifier: A cell identifier to use to dequeue cells from the source collection view 43 | /// - Parameter cellType: A type to cast dequeued cells as 44 | /// - Parameter cellConfig: A closure to call before displaying each cell 45 | public init(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping CellConfig) where CellType: UICollectionViewCell { 46 | cellFactory = { dataSource, collectionView, indexPath, value in 47 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellIdentifier, for: indexPath) as! CellType 48 | cellConfig(cell, indexPath, value) 49 | return cell 50 | } 51 | } 52 | 53 | /// An initializer that takes a closure expected to return a dequeued cell ready to be displayed in the collection view. 54 | /// - Parameter cellFactory: A `(CollectionViewItemsController, UICollectionView, IndexPath, Element) -> UICollectionViewCell` closure. Use the table input parameter to dequeue a cell and configure it with the `Element`'s data 55 | public init(cellFactory: @escaping CellFactory) { 56 | self.cellFactory = cellFactory 57 | } 58 | 59 | deinit { 60 | debugPrint("Controller is released") 61 | } 62 | 63 | // MARK: - Update collection 64 | private let fromRow = {(section: Int) in return {(row: Int) in return IndexPath(row: row, section: section)}} 65 | 66 | func updateCollection(_ items: CollectionType) { 67 | // If the changes are not animatable, reload the table 68 | guard animated, collection != nil, items.count == collection.count else { 69 | collection = items 70 | collectionView.reloadData() 71 | return 72 | } 73 | 74 | // Commit the changes to the collection view sections 75 | collectionView.performBatchUpdates({[unowned self] in 76 | for sectionIndex in 0.. Int { 92 | guard collection != nil else { return 0 } 93 | return collection.count 94 | } 95 | 96 | public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 97 | return collection[section].count 98 | } 99 | 100 | public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { 101 | cellFactory(self, collectionView, indexPath, collection[indexPath.section][indexPath.row]) 102 | } 103 | 104 | public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { 105 | guard let configureSupplementaryView = configureSupplementaryView else { 106 | fatalError("Property `configureSupplementaryView` must not be nil when using supplementary views") 107 | } 108 | return configureSupplementaryView(self, collectionView, kind, indexPath, collection[indexPath.section]) 109 | } 110 | 111 | // MARK: - Fallback data source object 112 | override public func forwardingTarget(for aSelector: Selector!) -> Any? { 113 | return dataSource 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/CombineDataSources/CollectionView/UICollectionView+Subscribers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | 9 | extension UICollectionView { 10 | 11 | /// A collection view specific `Subscriber` that receives `[[Element]]` input and updates a sectioned collection view. 12 | /// - Parameter cellIdentifier: The Cell ID to use for dequeueing table cells. 13 | /// - Parameter cellType: The required cell type for table rows. 14 | /// - Parameter cellConfig: A closure that receives an initialized cell and a collection element 15 | /// and configures the cell for displaying in its containing table view. 16 | public func sectionsSubscriber(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping CollectionViewItemsController.CellConfig) 17 | -> AnySubscriber where CellType: UICollectionViewCell, 18 | Items: RandomAccessCollection, 19 | Items.Element: RandomAccessCollection, 20 | Items.Element: Equatable { 21 | 22 | return sectionsSubscriber(.init(cellIdentifier: cellIdentifier, cellType: cellType, cellConfig: cellConfig)) 23 | } 24 | 25 | /// A table view specific `Subscriber` that receives `[[Element]]` input and updates a sectioned table view. 26 | /// - Parameter source: A configured `CollectionViewItemsController` instance. 27 | public func sectionsSubscriber(_ source: CollectionViewItemsController) 28 | -> AnySubscriber where 29 | Items: RandomAccessCollection, 30 | Items.Element: RandomAccessCollection, 31 | Items.Element: Equatable { 32 | 33 | source.collectionView = self 34 | dataSource = source 35 | 36 | return AnySubscriber(receiveSubscription: { subscription in 37 | subscription.request(.unlimited) 38 | }, receiveValue: { [weak self] items -> Subscribers.Demand in 39 | guard let self = self else { return .none } 40 | 41 | if self.dataSource == nil { 42 | self.dataSource = source 43 | } 44 | 45 | source.updateCollection(items) 46 | return .unlimited 47 | }) { _ in } 48 | } 49 | 50 | /// A table view specific `Subscriber` that receives `[Element]` input and updates a single section table view. 51 | /// - Parameter cellIdentifier: The Cell ID to use for dequeueing table cells. 52 | /// - Parameter cellType: The required cell type for table rows. 53 | /// - Parameter cellConfig: A closure that receives an initialized cell and a collection element 54 | /// and configures the cell for displaying in its containing table view. 55 | public func itemsSubscriber(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping CollectionViewItemsController<[Items]>.CellConfig) 56 | -> AnySubscriber where CellType: UICollectionViewCell, 57 | Items: RandomAccessCollection, 58 | Items: Equatable { 59 | 60 | return itemsSubscriber(.init(cellIdentifier: cellIdentifier, cellType: cellType, cellConfig: cellConfig)) 61 | } 62 | 63 | /// A table view specific `Subscriber` that receives `[Element]` input and updates a single section table view. 64 | /// - Parameter source: A configured `CollectionViewItemsController` instance. 65 | public func itemsSubscriber(_ source: CollectionViewItemsController<[Items]>) 66 | -> AnySubscriber where 67 | Items: RandomAccessCollection, 68 | Items: Equatable { 69 | 70 | source.collectionView = self 71 | dataSource = source 72 | 73 | return AnySubscriber(receiveSubscription: { subscription in 74 | subscription.request(.unlimited) 75 | }, receiveValue: { [weak self] items -> Subscribers.Demand in 76 | guard let self = self else { return .none } 77 | 78 | if self.dataSource == nil { 79 | self.dataSource = source 80 | } 81 | 82 | source.updateCollection([items]) 83 | return .unlimited 84 | }) { _ in } 85 | } 86 | } 87 | 88 | -------------------------------------------------------------------------------- /Sources/CombineDataSources/TableView/TableViewBatchesController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | 9 | public class TableViewBatchesController { 10 | // Input 11 | public let reload = PassthroughSubject() 12 | public let loadNext = PassthroughSubject() 13 | 14 | // Output 15 | public let loadError = CurrentValueSubject(nil) 16 | 17 | // Private user interface 18 | private let tableView: UITableView 19 | private var batchesDataSource: BatchesDataSource! 20 | private var spin: UIActivityIndicatorView = { 21 | let spin = UIActivityIndicatorView(style: .large) 22 | spin.tintColor = .systemGray 23 | spin.startAnimating() 24 | spin.alpha = 0 25 | return spin 26 | }() 27 | 28 | private var itemsController: TableViewItemsController<[[Element]]>! 29 | private var subscriptions = [AnyCancellable]() 30 | 31 | public convenience init(tableView: UITableView, itemsController: TableViewItemsController<[[Element]]>, initialToken: Data?, loadItemsWithToken: @escaping (Data?) -> AnyPublisher.LoadResult, Error>) { 32 | self.init(tableView: tableView) 33 | 34 | // Create a token-based batched data source. 35 | batchesDataSource = BatchesDataSource( 36 | input: BatchesInput(reload: reload.eraseToAnyPublisher(), loadNext: loadNext.eraseToAnyPublisher()), 37 | initialToken: initialToken, 38 | loadItemsWithToken: loadItemsWithToken 39 | ) 40 | 41 | self.itemsController = itemsController 42 | 43 | bind() 44 | } 45 | 46 | public convenience init(tableView: UITableView, itemsController: TableViewItemsController<[[Element]]>, loadPage: @escaping (Int) -> AnyPublisher.LoadResult, Error>) { 47 | self.init(tableView: tableView) 48 | 49 | // Create a paged data source. 50 | self.batchesDataSource = BatchesDataSource( 51 | input: BatchesInput(reload: reload.eraseToAnyPublisher(), loadNext: loadNext.eraseToAnyPublisher()), 52 | loadPage: loadPage 53 | ) 54 | 55 | self.itemsController = itemsController 56 | 57 | bind() 58 | } 59 | 60 | private init(tableView: UITableView) { 61 | self.tableView = tableView 62 | 63 | // Add bottom offset. 64 | var newInsets = tableView.contentInset 65 | newInsets.bottom += 60 66 | tableView.contentInset = newInsets 67 | 68 | // Add spinner. 69 | tableView.addSubview(spin) 70 | } 71 | 72 | private func bind() { 73 | // Display items in table view. 74 | batchesDataSource.output.$items 75 | .receive(on: DispatchQueue.main) 76 | .bind(subscriber: tableView.rowsSubscriber(itemsController)) 77 | .store(in: &subscriptions) 78 | 79 | // Show/hide spinner. 80 | batchesDataSource.output.$isLoading 81 | .receive(on: DispatchQueue.main) 82 | .sink { [weak self] isLoading in 83 | guard let self = self else { return } 84 | if isLoading { 85 | self.spin.center = CGPoint(x: self.tableView.frame.width/2, y: self.tableView.contentSize.height + 30) 86 | self.spin.alpha = 1 87 | self.tableView.scrollRectToVisible(CGRect(x: 0, y: self.tableView.contentOffset.y + self.tableView.frame.height, width: 10, height: 10), animated: true) 88 | } else { 89 | self.spin.alpha = 0 90 | } 91 | } 92 | .store(in: &subscriptions) 93 | 94 | // Bind errors. 95 | batchesDataSource.output.$error 96 | .subscribe(loadError) 97 | .store(in: &subscriptions) 98 | 99 | // Observe for table dragging. 100 | let didDrag = Publishers.CombineLatest(Just(tableView), tableView.publisher(for: \.contentOffset)) 101 | .map { $0.0.isDragging } 102 | .scan((from: false, to: false)) { result, value -> (from: Bool, to: Bool) in 103 | return (from: result.to, to: value) 104 | } 105 | .filter { tuple -> Bool in 106 | tuple == (from: true, to: false) 107 | } 108 | 109 | // Observe table offset and trigger loading next page at bottom 110 | Publishers.CombineLatest(Just(tableView), didDrag) 111 | .map { $0.0 } 112 | .filter { table -> Bool in 113 | return isAtBottom(of: table) 114 | } 115 | .sink { [weak self] _ in 116 | self?.loadNext.send() 117 | } 118 | .store(in: &subscriptions) 119 | } 120 | } 121 | 122 | fileprivate func isAtBottom(of tableView: UITableView) -> Bool { 123 | let height = tableView.frame.size.height 124 | let contentYoffset = tableView.contentOffset.y 125 | let distanceFromBottom = tableView.contentSize.height - contentYoffset 126 | return distanceFromBottom <= height 127 | } 128 | -------------------------------------------------------------------------------- /Sources/CombineDataSources/TableView/TableViewItemsController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | 9 | /// A table view controller acting as data source. 10 | /// `CollectionType` needs to be a collection of collections to represent sections containing rows. 11 | public class TableViewItemsController: NSObject, UITableViewDataSource 12 | where CollectionType: RandomAccessCollection, 13 | CollectionType.Index == Int, 14 | CollectionType.Element: Hashable, 15 | CollectionType.Element: RandomAccessCollection, 16 | CollectionType.Element.Index == Int, 17 | CollectionType.Element.Element: Hashable { 18 | 19 | public typealias Element = CollectionType.Element.Element 20 | public typealias CellFactory = (TableViewItemsController, UITableView, IndexPath, Element) -> UITableViewCell 21 | public typealias CellConfig = (Cell, IndexPath, Element) -> Void 22 | 23 | private let cellFactory: CellFactory 24 | private var collection: CollectionType! 25 | 26 | /// Should the table updates be animated or static. 27 | public var animated = true 28 | 29 | /// What transitions to use for inserting, updating, and deleting table rows. 30 | public var rowAnimations = ( 31 | insert: UITableView.RowAnimation.automatic, 32 | update: UITableView.RowAnimation.automatic, 33 | delete: UITableView.RowAnimation.automatic 34 | ) 35 | 36 | /// The table view for the data source 37 | var tableView: UITableView! 38 | 39 | /// A fallback data source to implement custom logic like indexes, dragging, etc. 40 | public var dataSource: UITableViewDataSource? 41 | 42 | // MARK: - Init 43 | 44 | /// An initializer that takes a cell type and identifier and configures the controller to dequeue cells 45 | /// with that data and configures each cell by calling the developer provided `cellConfig()`. 46 | /// - Parameter cellIdentifier: A cell identifier to use to dequeue cells from the source table view 47 | /// - Parameter cellType: A type to cast dequeued cells as 48 | /// - Parameter cellConfig: A closure to call before displaying each cell 49 | public init(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping CellConfig) where CellType: UITableViewCell { 50 | cellFactory = { dataSource, tableView, indexPath, value in 51 | let cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier, for: indexPath) as! CellType 52 | cellConfig(cell, indexPath, value) 53 | return cell 54 | } 55 | } 56 | 57 | 58 | /// An initializer that takes a closure expected to return a dequeued cell ready to be displayed in the table view. 59 | /// - Parameter cellFactory: A `(TableViewItemsController, UITableView, IndexPath, Element) -> UITableViewCell` closure. Use the table input parameter to dequeue a cell and configure it with the `Element`'s data 60 | public init(cellFactory: @escaping CellFactory) { 61 | self.cellFactory = cellFactory 62 | } 63 | 64 | deinit { 65 | debugPrint("Controller is released") 66 | } 67 | 68 | // MARK: - Update collection 69 | private let fromRow = {(section: Int) in return {(row: Int) in return IndexPath(row: row, section: section)}} 70 | 71 | func updateCollection(_ items: CollectionType) { 72 | // Initial collection 73 | if collection == nil, animated { 74 | guard tableView.numberOfSections == 0 else { 75 | // collection is out of sync with the actual table view contents. 76 | collection = items 77 | tableView.reloadData() 78 | return 79 | } 80 | 81 | tableView.beginUpdates() 82 | tableView.insertSections(IndexSet(integersIn: 0.. Int { 115 | guard collection != nil else { return 0 } 116 | return collection.count 117 | } 118 | 119 | public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 120 | return collection[section].count 121 | } 122 | 123 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 124 | cellFactory(self, tableView, indexPath, collection[indexPath.section][indexPath.row]) 125 | } 126 | 127 | public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 128 | guard let sectionModel = collection[section] as? Section else { 129 | return dataSource?.tableView?(tableView, titleForHeaderInSection: section) 130 | } 131 | return sectionModel.header 132 | } 133 | 134 | public func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { 135 | guard let sectionModel = collection[section] as? Section else { 136 | return dataSource?.tableView?(tableView, titleForFooterInSection: section) 137 | } 138 | return sectionModel.footer 139 | } 140 | 141 | // MARK: - Fallback data source object 142 | override public func forwardingTarget(for aSelector: Selector!) -> Any? { 143 | return dataSource 144 | } 145 | } 146 | 147 | internal func delta(newList: T, oldList: T) -> (insertions: [Int], removals: [Int], moves: [(Int, Int)]) 148 | where T: RandomAccessCollection, T.Element: Hashable { 149 | 150 | let changes = newList.difference(from: oldList).inferringMoves() 151 | 152 | var insertions = [Int]() 153 | var removals = [Int]() 154 | var moves = [(Int, Int)]() 155 | 156 | for change in changes { 157 | switch change { 158 | case .insert(offset: let index, element: _, associatedWith: let associatedIndex): 159 | if let fromIndex = associatedIndex { 160 | moves.append((fromIndex, index)) 161 | } else { 162 | insertions.append(index) 163 | } 164 | case .remove(offset: let index, element: _, associatedWith: let associatedIndex): 165 | if associatedIndex == nil { 166 | removals.append(index) 167 | } 168 | } 169 | } 170 | return (insertions: insertions, removals: removals, moves: moves) 171 | } 172 | -------------------------------------------------------------------------------- /Sources/CombineDataSources/TableView/UITableView+Subscribers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import UIKit 7 | import Combine 8 | 9 | extension UITableView { 10 | 11 | /// A table view specific `Subscriber` that receives `[[Element]]` input and updates a sectioned table view. 12 | /// - Parameter cellIdentifier: The Cell ID to use for dequeueing table cells. 13 | /// - Parameter cellType: The required cell type for table rows. 14 | /// - Parameter cellConfig: A closure that receives an initialized cell and a collection element 15 | /// and configures the cell for displaying in its containing table view. 16 | public func sectionsSubscriber(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping TableViewItemsController.CellConfig) 17 | -> AnySubscriber where CellType: UITableViewCell, 18 | Items: RandomAccessCollection, 19 | Items.Element: RandomAccessCollection, 20 | Items.Element: Equatable { 21 | 22 | return sectionsSubscriber(.init(cellIdentifier: cellIdentifier, cellType: cellType, cellConfig: cellConfig)) 23 | } 24 | 25 | /// A table view specific `Subscriber` that receives `[[Element]]` input and updates a sectioned table view. 26 | /// - Parameter source: A configured `TableViewItemsController` instance. 27 | public func sectionsSubscriber(_ source: TableViewItemsController) 28 | -> AnySubscriber where 29 | Items: RandomAccessCollection, 30 | Items.Element: RandomAccessCollection, 31 | Items.Element: Equatable { 32 | 33 | source.tableView = self 34 | dataSource = source 35 | 36 | return AnySubscriber(receiveSubscription: { subscription in 37 | subscription.request(.unlimited) 38 | }, receiveValue: { [weak self] items -> Subscribers.Demand in 39 | guard let self = self else { return .none } 40 | 41 | if self.dataSource == nil { 42 | self.dataSource = source 43 | } 44 | 45 | source.updateCollection(items) 46 | return .unlimited 47 | }) { _ in } 48 | } 49 | 50 | /// A table view specific `Subscriber` that receives `[Element]` input and updates a single section table view. 51 | /// - Parameter cellIdentifier: The Cell ID to use for dequeueing table cells. 52 | /// - Parameter cellType: The required cell type for table rows. 53 | /// - Parameter cellConfig: A closure that receives an initialized cell and a collection element 54 | /// and configures the cell for displaying in its containing table view. 55 | public func rowsSubscriber(cellIdentifier: String, cellType: CellType.Type, cellConfig: @escaping TableViewItemsController<[Items]>.CellConfig) 56 | -> AnySubscriber where CellType: UITableViewCell, 57 | Items: RandomAccessCollection, 58 | Items: Equatable { 59 | 60 | return rowsSubscriber(.init(cellIdentifier: cellIdentifier, cellType: cellType, cellConfig: cellConfig)) 61 | } 62 | 63 | /// A table view specific `Subscriber` that receives `[Element]` input and updates a single section table view. 64 | /// - Parameter source: A configured `TableViewItemsController` instance. 65 | public func rowsSubscriber(_ source: TableViewItemsController<[Items]>) 66 | -> AnySubscriber where 67 | Items: RandomAccessCollection, 68 | Items: Equatable { 69 | 70 | source.tableView = self 71 | dataSource = source 72 | 73 | return AnySubscriber(receiveSubscription: { subscription in 74 | subscription.request(.unlimited) 75 | }, receiveValue: { [weak self] items -> Subscribers.Demand in 76 | guard let self = self else { return .none } 77 | 78 | if self.dataSource == nil { 79 | self.dataSource = source 80 | } 81 | 82 | source.updateCollection([items]) 83 | return .unlimited 84 | }) { _ in } 85 | } 86 | } 87 | 88 | -------------------------------------------------------------------------------- /Sources/CombineDataSources/etc/Publisher+Bind.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import Foundation 7 | import Combine 8 | 9 | public typealias Binding = Subscriber 10 | 11 | public extension Publisher where Failure == Never { 12 | func bind(subscriber: B) -> AnyCancellable 13 | where B.Failure == Never, B.Input == Output { 14 | 15 | handleEvents(receiveSubscription: { subscription in 16 | subscriber.receive(subscription: subscription) 17 | }) 18 | .sink { value in 19 | _ = subscriber.receive(value) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/CombineDataSources/etc/Section.swift: -------------------------------------------------------------------------------- 1 | // 2 | // For credits and licence check the LICENSE file included in this package. 3 | // (c) CombineOpenSource, Created by Marin Todorov. 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol SectionProtocol { 9 | associatedtype Element 10 | 11 | var header: String? { get } 12 | var footer: String? { get } 13 | var items: [Element] { get } 14 | } 15 | 16 | public struct Section: SectionProtocol, Identifiable { 17 | public init(header: String? = nil, items: [Element], footer: String? = nil, id: String? = nil) { 18 | self.id = id ?? header ?? UUID().uuidString 19 | self.header = header 20 | self.items = items 21 | self.footer = footer 22 | } 23 | 24 | public let id: String 25 | 26 | public let header: String? 27 | public let footer: String? 28 | public let items: [Element] 29 | } 30 | 31 | extension Section: Equatable { 32 | public static func == (lhs: Section, rhs: Section) -> Bool { 33 | return lhs.id == rhs.id 34 | } 35 | } 36 | 37 | extension Section: Hashable { 38 | public func hash(into hasher: inout Hasher) { 39 | hasher.combine(id) 40 | } 41 | } 42 | 43 | extension Section: RandomAccessCollection { 44 | public var startIndex: Int { 45 | return items.startIndex 46 | } 47 | 48 | public var endIndex: Int { 49 | return items.endIndex 50 | } 51 | 52 | public func index(after i: Int) -> Int { 53 | return items.index(after: i) 54 | } 55 | 56 | public subscript(index: Int) -> Element { 57 | return items[index] 58 | } 59 | 60 | public var count: Int { 61 | return items.count 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/CombineDataSourcesTests/BatchesDataSource/BatchesDataSourceTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | import XCTest 4 | 5 | @testable import CombineDataSources 6 | 7 | final class BatchesDataSourceTests: XCTestCase { 8 | var input: BatchesInput { 9 | BatchesInput(loadNext: PassthroughSubject().eraseToAnyPublisher()) 10 | } 11 | 12 | var inputControls: (input: BatchesInput, reload: PassthroughSubject, loadNext: PassthroughSubject) { 13 | let reload = PassthroughSubject() 14 | let loadNext = PassthroughSubject() 15 | let input = BatchesInput(reload: reload.eraseToAnyPublisher(), loadNext: loadNext.eraseToAnyPublisher()) 16 | return (input: input, reload: reload, loadNext: loadNext) 17 | } 18 | 19 | func testInitialState() { 20 | let batcher = BatchesDataSource(input: input) { page in 21 | return Empty.LoadResult, Error>().eraseToAnyPublisher() 22 | } 23 | 24 | XCTAssertEqual(batcher.output.isLoading, true) 25 | XCTAssertEqual(batcher.output.isCompleted, false) 26 | XCTAssertTrue(batcher.output.items.isEmpty) 27 | XCTAssertNil(batcher.output.error) 28 | } 29 | 30 | func testInitialItems() { 31 | let testStrings = ["test1", "test2"] 32 | let batcher = BatchesDataSource(items: testStrings, input: input) { page in 33 | return Empty.LoadResult, Error>().eraseToAnyPublisher() 34 | } 35 | 36 | XCTAssertEqual(batcher.output.items, testStrings) 37 | } 38 | 39 | func testInitialLoadSynchronous() { 40 | let testStrings = ["test1", "test2"] 41 | var subscriptions = [AnyCancellable]() 42 | 43 | let batcher = BatchesDataSource(items: testStrings, input: input) { page in 44 | return Just.LoadResult>(.items(["test3"])) 45 | .setFailureType(to: Error.self) 46 | .eraseToAnyPublisher() 47 | } 48 | 49 | let controlEvent = expectation(description: "Wait for control event") 50 | 51 | batcher.output.$items 52 | .prefix(1) 53 | .collect() 54 | .sink(receiveCompletion: { _ in 55 | controlEvent.fulfill() 56 | }) { values in 57 | XCTAssertEqual([testStrings + ["test3"]], values) 58 | } 59 | .store(in: &subscriptions) 60 | 61 | wait(for: [controlEvent], timeout: 1) 62 | } 63 | 64 | func testInitialLoadAsynchronous() { 65 | let testStrings = ["test1", "test2"] 66 | var subscriptions = [AnyCancellable]() 67 | 68 | let batcher = BatchesDataSource(items: testStrings, input: input) { page in 69 | return Future.LoadResult, Error> { promise in 70 | DispatchQueue.main.async { 71 | promise(.success(.items(["test3"]))) 72 | } 73 | }.eraseToAnyPublisher() 74 | } 75 | 76 | let controlEvent = expectation(description: "Wait for control event") 77 | 78 | batcher.output.$items 79 | .prefix(2) 80 | .collect() 81 | .sink(receiveCompletion: { _ in 82 | controlEvent.fulfill() 83 | }) { values in 84 | XCTAssertEqual([testStrings, testStrings + ["test3"]], values) 85 | } 86 | .store(in: &subscriptions) 87 | 88 | wait(for: [controlEvent], timeout: 1) 89 | } 90 | 91 | func testLoadNext() { 92 | let testStrings = ["test1", "test2"] 93 | var subscriptions = [AnyCancellable]() 94 | 95 | let inputControls = self.inputControls 96 | 97 | let batcher = BatchesDataSource(items: testStrings, input: inputControls.input) { page in 98 | return Future.LoadResult, Error> { promise in 99 | DispatchQueue.main.async { 100 | promise(.success(.items(["test3"]))) 101 | } 102 | }.eraseToAnyPublisher() 103 | } 104 | 105 | let controlEvent = expectation(description: "Wait for control event") 106 | 107 | batcher.output.$items 108 | .dropFirst(2) 109 | .prefix(2) 110 | .collect() 111 | .sink(receiveCompletion: { _ in 112 | controlEvent.fulfill() 113 | }) { values in 114 | XCTAssertEqual([ 115 | testStrings + ["test3", "test3"], 116 | testStrings + ["test3", "test3", "test3"] 117 | ], values) 118 | } 119 | .store(in: &subscriptions) 120 | 121 | DispatchQueue.global().async { 122 | inputControls.loadNext.send() 123 | } 124 | DispatchQueue.global().async { 125 | inputControls.loadNext.send() 126 | } 127 | 128 | wait(for: [controlEvent], timeout: 1) 129 | } 130 | 131 | func testReload() { 132 | let testStrings = ["test1", "test2"] 133 | var subscriptions = [AnyCancellable]() 134 | 135 | let inputControls = self.inputControls 136 | 137 | let batcher = BatchesDataSource(items: testStrings, input: inputControls.input) { page in 138 | return Future.LoadResult, Error> { promise in 139 | DispatchQueue.main.async { 140 | promise(.success(.items(["test3"]))) 141 | } 142 | }.eraseToAnyPublisher() 143 | } 144 | 145 | let controlEvent = expectation(description: "Wait for control event") 146 | 147 | batcher.output.$items 148 | .dropFirst(2) 149 | .prefix(2) 150 | .collect() 151 | .sink(receiveCompletion: { _ in 152 | controlEvent.fulfill() 153 | }) { values in 154 | XCTAssertEqual([ 155 | testStrings + ["test3"], 156 | testStrings + ["test3", "test3"] 157 | ], values) 158 | } 159 | .store(in: &subscriptions) 160 | 161 | DispatchQueue.global().async { 162 | inputControls.reload.send() 163 | inputControls.loadNext.send() 164 | } 165 | 166 | wait(for: [controlEvent], timeout: 1) 167 | } 168 | 169 | func testIsCompleted() { 170 | var subscriptions = [AnyCancellable]() 171 | let inputControls = self.inputControls 172 | 173 | var shouldComplete = false 174 | 175 | let batcher = BatchesDataSource(input: inputControls.input) { page in 176 | return Future.LoadResult, Error> { promise in 177 | DispatchQueue.main.async { 178 | if shouldComplete { 179 | promise(.success(.items(["test3"]))) 180 | } else { 181 | promise(.success(.completed)) 182 | } 183 | shouldComplete.toggle() 184 | } 185 | }.eraseToAnyPublisher() 186 | } 187 | 188 | let controlEvent = expectation(description: "Wait for control event") 189 | 190 | batcher.output.$isCompleted 191 | .prefix(3) 192 | .collect() 193 | .sink(receiveCompletion: { _ in 194 | controlEvent.fulfill() 195 | }) { values in 196 | XCTAssertEqual([ 197 | false, true, false 198 | ], values) 199 | } 200 | .store(in: &subscriptions) 201 | 202 | DispatchQueue.global().async { 203 | inputControls.loadNext.send() 204 | inputControls.reload.send() 205 | } 206 | 207 | wait(for: [controlEvent], timeout: 1) 208 | } 209 | 210 | func testIsLoading() { 211 | var subscriptions = [AnyCancellable]() 212 | let inputControls = self.inputControls 213 | 214 | let batcher = BatchesDataSource(input: inputControls.input) { page in 215 | return Future.LoadResult, Error> { promise in 216 | DispatchQueue.main.async { 217 | promise(.success(.items(["test3"]))) 218 | } 219 | }.eraseToAnyPublisher() 220 | } 221 | 222 | let controlEvent = expectation(description: "Wait for control event") 223 | 224 | batcher.output.$isLoading 225 | .prefix(4) 226 | .collect() 227 | .sink(receiveCompletion: { _ in 228 | controlEvent.fulfill() 229 | }) { values in 230 | XCTAssertEqual([ 231 | true, false, true, false 232 | ], values) 233 | } 234 | .store(in: &subscriptions) 235 | 236 | DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) { 237 | inputControls.loadNext.send() 238 | } 239 | 240 | wait(for: [controlEvent], timeout: 1) 241 | } 242 | 243 | func testError() { 244 | var subscriptions = [AnyCancellable]() 245 | let inputControls = self.inputControls 246 | 247 | var shouldError = false 248 | 249 | let batcher = BatchesDataSource(input: inputControls.input) { page in 250 | return Future.LoadResult, Error> { promise in 251 | DispatchQueue.main.async { 252 | if shouldError { 253 | promise(.success(.items(["test3"]))) 254 | } else { 255 | promise(.failure(TestError.test)) 256 | } 257 | shouldError.toggle() 258 | } 259 | }.eraseToAnyPublisher() 260 | } 261 | 262 | let controlEvent = expectation(description: "Wait for control event") 263 | 264 | batcher.output.$error 265 | .prefix(4) 266 | .collect() 267 | .sink(receiveCompletion: { _ in 268 | controlEvent.fulfill() 269 | }) { values in 270 | XCTAssertNil(values[0]) 271 | XCTAssertNotNil(values[1] as? TestError) 272 | XCTAssertNil(values[2]) 273 | XCTAssertNotNil(values[3] as? TestError) 274 | } 275 | .store(in: &subscriptions) 276 | 277 | DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { 278 | inputControls.loadNext.send() 279 | } 280 | DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) { 281 | inputControls.loadNext.send() 282 | } 283 | 284 | wait(for: [controlEvent], timeout: 1) 285 | } 286 | } 287 | -------------------------------------------------------------------------------- /Tests/CombineDataSourcesTests/CollectionView/CollectionViewItemsControllerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import UIKit 3 | @testable import CombineDataSources 4 | 5 | final class CollectionViewItemsControllerTests: XCTestCase { 6 | 7 | func testDataSource() { 8 | // Make the controller 9 | var lastIndexPath: IndexPath? = nil 10 | 11 | let ctr = CollectionViewItemsController<[[Model]]>(cellIdentifier: "Cell", cellType: UICollectionViewCell.self) { (cell, indexPath, model) in 12 | lastIndexPath = indexPath 13 | } 14 | 15 | // Configure the controller 16 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) 17 | collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell") 18 | 19 | ctr.collectionView = collectionView 20 | ctr.updateCollection([dataSet1, dataSet1]) 21 | 22 | // Test data source methods 23 | XCTAssertEqual(2, ctr.numberOfSections(in: collectionView)) 24 | XCTAssertEqual(3, ctr.collectionView(collectionView, numberOfItemsInSection: 0)) 25 | 26 | XCTAssertNil(lastIndexPath) 27 | let cell = ctr.collectionView(collectionView, cellForItemAt: IndexPath(row: 1, section: 0)) 28 | XCTAssertNotNil(cell) 29 | XCTAssertEqual(1, lastIndexPath?.row) 30 | 31 | // Test an update 32 | ctr.updateCollection([dataSet1]) 33 | XCTAssertEqual(1, ctr.numberOfSections(in: collectionView)) 34 | } 35 | 36 | func testSections() { 37 | var lastModel: Model? 38 | let ctr = CollectionViewItemsController<[Section]>(cellIdentifier: "Cell", cellType: UICollectionViewCell.self) { (cell, indexPath, model) in 39 | lastModel = model 40 | } 41 | 42 | // Configure the controller 43 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) 44 | collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell") 45 | collectionView.register(UICollectionReusableView.self, forSupplementaryViewOfKind: "supplementary-kind", withReuseIdentifier: "supplementaryview") 46 | 47 | ctr.configureSupplementaryView = { _, collection, kind, index, section -> UICollectionReusableView in 48 | return collection.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "supplementaryview", for: index) 49 | } 50 | 51 | ctr.collectionView = collectionView 52 | ctr.updateCollection(dataSet2) 53 | 54 | XCTAssertNil(lastModel) 55 | let cell = ctr.collectionView(collectionView, cellForItemAt: IndexPath(row: 0, section: 0)) 56 | let supplementaryView = ctr.collectionView(collectionView, viewForSupplementaryElementOfKind: "supplementary-kind", at: IndexPath(row: 0, section: 0)) 57 | XCTAssertNotNil(cell) 58 | XCTAssertNotNil(supplementaryView) 59 | XCTAssertEqual("test model", lastModel?.text) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Tests/CombineDataSourcesTests/CollectionView/UICollectionView+SubscribersTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Marin Todorov on 8/13/19. 6 | // 7 | 8 | import XCTest 9 | import UIKit 10 | @testable import CombineDataSources 11 | 12 | final class UICollectionView_SubscribersTests: XCTestCase { 13 | func testCollectionController() { 14 | let ctr = CollectionViewItemsController<[[Model]]>(cellIdentifier: "Cell", cellType: UICollectionViewCell.self) { (cell, indexPath, model) in 15 | // 16 | } 17 | 18 | // Configure the controller 19 | let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) 20 | collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell") 21 | 22 | // 23 | // Test rows subscriber 24 | // 25 | do { 26 | let subscriber = collectionView.itemsSubscriber(ctr) 27 | _ = subscriber.receive(dataSet1) 28 | 29 | XCTAssertNotNil(collectionView.dataSource) 30 | XCTAssertEqual(1, collectionView.numberOfSections) 31 | XCTAssertEqual(3, collectionView.numberOfItems(inSection: 0)) 32 | 33 | _ = subscriber.receive(dataSet1 + dataSet1) 34 | XCTAssertEqual(6, collectionView.numberOfItems(inSection: 0)) 35 | } 36 | 37 | // 38 | // Test rows subscriber 39 | // 40 | do { 41 | let subscriber = collectionView.sectionsSubscriber(ctr) 42 | _ = subscriber.receive([dataSet1]) 43 | 44 | XCTAssertNotNil(collectionView.dataSource) 45 | XCTAssertEqual(1, collectionView.numberOfSections) 46 | XCTAssertEqual(3, collectionView.numberOfItems(inSection: 0)) 47 | 48 | _ = subscriber.receive([dataSet1, dataSet1]) 49 | XCTAssertEqual(2, collectionView.numberOfSections) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/CombineDataSourcesTests/MemoryManagementTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import UIKit 3 | import Combine 4 | @testable import CombineDataSources 5 | 6 | final class MemoryManagementTests: XCTestCase { 7 | func testControllerOwnsTableViewAndDataSource() { 8 | let ctr: TableViewItemsController<[[Model]]>? = TableViewItemsController<[[Model]]>(cellIdentifier: "Cell", cellType: UITableViewCell.self) { (cell, indexPath, model) in 9 | // 10 | } 11 | 12 | // Configure the controller 13 | var tableView: UITableView? = UITableView() 14 | tableView!.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") 15 | var dataSource: TestDataSource? = TestDataSource() 16 | 17 | ctr!.tableView = tableView 18 | ctr!.dataSource = dataSource 19 | ctr!.updateCollection([dataSet1, dataSet1]) 20 | 21 | tableView = nil 22 | XCTAssertNotNil(ctr!.tableView) 23 | 24 | dataSource = nil 25 | XCTAssertNotNil(ctr!.dataSource) 26 | } 27 | 28 | func testBind() { 29 | let expectation1 = expectation(description: "subscribed") 30 | let expectation2 = expectation(description: "value") 31 | 32 | var subscriptions = [AnyCancellable]() 33 | var sub: AnySubscriber? 34 | 35 | sub = AnySubscriber( 36 | receiveSubscription: { sub in 37 | expectation1.fulfill() 38 | }, 39 | receiveValue: { value -> Subscribers.Demand in 40 | expectation2.fulfill() 41 | return .unlimited 42 | }) 43 | { (completion) in 44 | XCTFail("Binding sent completion event") 45 | } 46 | 47 | DispatchQueue.main.async { 48 | let data = PassthroughSubject() 49 | data 50 | .bind(subscriber: sub!) 51 | .store(in: &subscriptions) 52 | 53 | data.send("asdasd") // will be passed on 54 | data.send(completion: .finished) // will be filtered 55 | } 56 | wait(for: [expectation1, expectation2], timeout: 1) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Tests/CombineDataSourcesTests/TableView/TableViewBatchesControllerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Marin Todorov on 8/30/19. 6 | // 7 | 8 | import UIKit 9 | import Combine 10 | import XCTest 11 | 12 | final class TableViewBatchesControllerTests: XCTestCase { 13 | // TODO: add 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Tests/CombineDataSourcesTests/TableView/TableViewItemsControllerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import UIKit 3 | @testable import CombineDataSources 4 | 5 | final class TableViewItemsControllerTests: XCTestCase { 6 | 7 | func testDataSource() { 8 | // Make the controller 9 | var lastIndexPath: IndexPath? = nil 10 | 11 | let ctr = TableViewItemsController<[[Model]]>(cellIdentifier: "Cell", cellType: UITableViewCell.self) { (cell, indexPath, model) in 12 | lastIndexPath = indexPath 13 | } 14 | 15 | // Configure the controller 16 | let tableView = UITableView() 17 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") 18 | 19 | ctr.tableView = tableView 20 | ctr.updateCollection([dataSet1, dataSet1]) 21 | 22 | // Test data source methods 23 | XCTAssertEqual(2, ctr.numberOfSections(in: tableView)) 24 | XCTAssertEqual(3, ctr.tableView(tableView, numberOfRowsInSection: 0)) 25 | 26 | XCTAssertNil(lastIndexPath) 27 | let cell = ctr.tableView(tableView, cellForRowAt: IndexPath(row: 1, section: 0)) 28 | XCTAssertNotNil(cell) 29 | XCTAssertEqual(1, lastIndexPath?.row) 30 | 31 | // Test an update 32 | ctr.updateCollection([dataSet1]) 33 | XCTAssertEqual(1, ctr.numberOfSections(in: tableView)) 34 | } 35 | 36 | func testFallbackDataSource() { 37 | let ctr = TableViewItemsController<[[Model]]>(cellIdentifier: "Cell", cellType: UITableViewCell.self) { (cell, indexPath, model) in 38 | // 39 | } 40 | 41 | // Configure the controller 42 | let tableView = UITableView() 43 | ctr.tableView = tableView 44 | ctr.updateCollection([dataSet1]) 45 | 46 | let fallbackDataSource = TestDataSource() 47 | ctr.dataSource = fallbackDataSource 48 | 49 | // Test custom methods 50 | XCTAssertEqual(1, ctr.numberOfSections(in: tableView)) 51 | 52 | // Test fallback methods 53 | XCTAssertEqual("test header", ctr.tableView(tableView, titleForHeaderInSection: 0)) 54 | XCTAssertEqual("test footer", ctr.tableView(tableView, titleForFooterInSection: 0)) 55 | } 56 | 57 | func testSections() { 58 | var lastModel: Model? 59 | let ctr = TableViewItemsController<[Section]>(cellIdentifier: "Cell", cellType: UITableViewCell.self) { (cell, indexPath, model) in 60 | lastModel = model 61 | } 62 | 63 | // Configure the controller 64 | let tableView = UITableView() 65 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") 66 | 67 | ctr.tableView = tableView 68 | ctr.updateCollection(dataSet2) 69 | 70 | // Test custom section methods 71 | XCTAssertEqual("section header", ctr.tableView(tableView, titleForHeaderInSection: 0)) 72 | XCTAssertEqual("section footer", ctr.tableView(tableView, titleForFooterInSection: 0)) 73 | 74 | XCTAssertNil(lastModel) 75 | let cell = ctr.tableView(tableView, cellForRowAt: IndexPath(row: 0, section: 0)) 76 | XCTAssertNotNil(cell) 77 | XCTAssertEqual("test model", lastModel?.text) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Tests/CombineDataSourcesTests/TableView/UITableView+SubscribersTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Marin Todorov on 8/13/19. 6 | // 7 | 8 | import XCTest 9 | import UIKit 10 | @testable import CombineDataSources 11 | 12 | final class UITableView_SubscribersTests: XCTestCase { 13 | func testTableController() { 14 | let ctr = TableViewItemsController<[[Model]]>(cellIdentifier: "Cell", cellType: UITableViewCell.self) { (cell, indexPath, model) in 15 | // 16 | } 17 | 18 | // Configure the controller 19 | let tableView = UITableView() 20 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") 21 | 22 | // 23 | // Test rows subscriber 24 | // 25 | do { 26 | let subscriber = tableView.rowsSubscriber(ctr) 27 | _ = subscriber.receive(dataSet1) 28 | 29 | XCTAssertNotNil(tableView.dataSource) 30 | XCTAssertEqual(1, tableView.numberOfSections) 31 | XCTAssertEqual(3, tableView.numberOfRows(inSection: 0)) 32 | 33 | _ = subscriber.receive(dataSet1 + dataSet1) 34 | XCTAssertEqual(6, tableView.numberOfRows(inSection: 0)) 35 | } 36 | 37 | // 38 | // Test rows subscriber 39 | // 40 | do { 41 | let subscriber = tableView.sectionsSubscriber(ctr) 42 | _ = subscriber.receive([dataSet1]) 43 | 44 | XCTAssertNotNil(tableView.dataSource) 45 | XCTAssertEqual(1, tableView.numberOfSections) 46 | XCTAssertEqual(3, tableView.numberOfRows(inSection: 0)) 47 | 48 | _ = subscriber.receive([dataSet1, dataSet1]) 49 | XCTAssertEqual(2, tableView.numberOfSections) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/CombineDataSourcesTests/data/TestFixtures.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Marin Todorov on 8/14/19. 6 | // 7 | 8 | import XCTest 9 | import CombineDataSources 10 | import UIKit 11 | 12 | struct Model: Hashable { 13 | var text: String 14 | } 15 | 16 | let dataSet1 = [ 17 | Model(text: "test1"), Model(text: "test2"), Model(text: "test3") 18 | ] 19 | let dataSet2 = [ 20 | Section(header: "section header", items: [Model(text: "test model")], footer: "section footer") 21 | ] 22 | 23 | func batch(of count: Int) -> [Model] { 24 | (0.. Int { 30 | fatalError() 31 | } 32 | 33 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 34 | fatalError() 35 | } 36 | 37 | func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { 38 | return "test header" 39 | } 40 | func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { 41 | return "test footer" 42 | } 43 | } 44 | 45 | enum TestError: Error { 46 | case test 47 | } 48 | --------------------------------------------------------------------------------