├── .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 | 
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 | 
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 | 
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 | 
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