Lighter View Controller Data Sources
Inspired by the concept of Lighter View Controllers, neatly explained by Chris Eidhof on objc.io, I came up with a way of separating the view controller, data source and network code.
If you haven’t read the aforementioned article I suggest you do it and then come back to this page.
I started out with an ArrayDataSource
like Chris suggested:
self.dataSource = [[ArrayDataSource alloc] initWithItems:photos
cellIdentifier:CellIdentifier
configureCellBlock:configureCell];
tableView.dataSource = self.dataSource;
My experience using ArrayDataSource #
After some time three issues popped up:
- Most of my view controllers had collection/table views that present results from a web service call.
ArrayDataSource
only supports arrays, so I had to fetch and parse from the server before creating the data source. - I needed pagination. That means my datasource should be able to grow.
- I needed some modification features: move, replace, delete, etc…
I could easily handle the first issue by creating the data source after the network call finished. The view controller would be the usual place for that code, but I wanted to keep it light.
The second issue could be handled by replacing the array with a bigger one each time I had to add a page.
The last was the trickiest. I was starting to have a lot of code outside the data source only to mutate it. That code should go inside the data source.
Enter RemoteDataSource #
@interface RemoteDataSource : NSObject
- (instancetype)initWithServiceURL:(NSURL *)serviceURL
cellIdentifier:cellIdentifier
configureCellBlock:configureCellBlock
pageParameterName:(NSString *)pageParameterName
parser:(id<RemoteDataSourceParserProtocol>)parser;
- (void)fetchWithCompletionBlock:(void (^)(NSError *error))block;
- (BOOL)fetchMoreWithCompletionBlock:(void (^)(NSError *error, NSArray *indexPaths))block;
- (void)cancel;
@end
id<RemoteDataSourceParser> parser = ...
self.dataSource = [[RemoteDataSource alloc] initWithServiceURL:url
cellIdentifier:CellIdentifier
configureCellBlock:configureCellBlock
pageParameterName:@"page"
parser:aParser];
tableView.dataSource = self.dataSource;
As you can see I decided to separate the parsing code from the fetching code. The idea is that you might have different service results for each calls. For example, one for shops, one for locations, etc…
The parser protocol is also pretty straightforward:
@protocol RemoteDataSourceParserProtocol
- (NSArray *)parseData:(NSData *)data error:(NSError **)error;
@end
As you can see, easy as pie to implement. Here’s an example:
@implementation ShopsRemoteParser
- (NSArray *)parseData:(NSData *)data error:(NSError **)error pageValue:(inout NSUInteger *)pageValue {
NSError *err;
id o = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err];
if (err) {
if (error) *error = err;
return nil;
}
if (![o isKindOfClass:[NSArray class]]) {
if (error) *error = [NSError errorWithDomain:@"RemoteDataSourceParsingErrorDomain" code:0 userInfo:nil];
return nil;
}
NSMutableArray *arr = [NSMutableArray arrayWithCapacity:[o count]];
for (id dict in o) {
if (![dict isKindOfClass:[NSDictionary class]]) continue;
id obj = [[Shop alloc] initWithInfo:dict];
if (obj) [arr addObject:obj];
}
return [arr copy];
}
@end
What should a data source do? #
As the name implies, a data source should provide data to other object, frequently a view or view controller. So should it know how to create views? No it shouldn’t. That’s why Chris added that configureCellBlock
.
Just a quick note. I would love to know the reason behind tableView:cellForRowAtIndexPath:
. I mean, why did Apple decided it should be the data source’s job to create cells?
Let’s imagine you have a collection view of Flickr photos, as you scroll down the next page of items is appended to the bottom. When you tap one item you present a new view controller with a full screen collection view that shows the same photos. As you (horizontally) scroll to the end of this collection view the next page should be appended to the right.
You want both collection views to share the same data source. It’s the same data. And you want to reflect the same state on both as the user goes back and forth. But if the data source owns the cell identifier and configuration block you cannot share it between collection views. The cells are completely different.
A lighter RemoteDataSource #
To be able to pass data sources around they can’t have any cell configuration code. So I refactored it into UIDataSource
and RemoteDataSource
. And while at it I also introduced AbstractDataSource
.
AbstractDataSource
RemoteDataSource
ArrayDataSource
UIDataSource
UIDataSource
conforms to UITableViewDataSource
and UICollectionViewDataSource
.
@interface UIDataSource : NSObject <UICollectionViewDataSource, UITableViewDataSource>
@property(nonatomic, strong) AbstractDataSource *dataSource;
@property(nonatomic, copy, readonly) NSString *cellIdentifier;
- (instancetype)initWithDataSource:(AbstractDataSource *)dataSource cellIdentifier:(NSString *)cellIdentifier configureCellBlock:(void (^)(id cell, id object, NSIndexPath *indexPath))block;
@end
Now each view controller can have a UIDataSource
and the inner AbstractDataSource
can be shared.
Note: This concept came to me after reading how NSTableViewDataSource
works. Basically the data source only provides an object. It’s the cell’s job to display it.
Mutating the data source #
The AbstractDataSource
class is actually just a definition of the needed methods and a placeholder implementation. A bunch of your typical NSAssert(@"implement this on a subclass");
methods.
It has methods for adding/removing/moving/replacing items, all with empty implementations.
@interface AbstractDataSource : NSObject <UICollectionViewDataSource, UITableViewDataSource>
- (id)objectForKeyedSubscript:(NSIndexPath *)indexPath;
- (NSIndexPath *)indexPathForObject:(id)object;
- (NSUInteger)count;
- (void)insertObject:(id)object atIndexPath:(NSIndexPath *)indexPath;
- (BOOL)deleteItemAtIndexPath:(NSIndexPath *)indexPath;
- (BOOL)moveItemFromIndexPath:(NSIndexPath *)fromIndexPath toIndexPath:(NSIndexPath *)toIndexPath;
- (void)replaceObjectAtIndexPath:(NSIndexPath *)indexPath withObject:(id)object;
@end
One last thing you might notice is that AbstractDataSource
still conforms to UICollectionViewDataSource
and UITableViewDataSource
. I figured that the data source needs to be able to define the number of sections and rows. If you have a DictionaryDataSource
with { title : array } pairs for example.
It doesn’t make sense for a RemoteDataSource
to be modified, and the code to remove an item from ArrayDataSource
will certainly be different from SQLTableDataSource
.
The final setup #
The way I described those classes is a simplification of the actual code I ended up with, but I think it’s enough to explain the concept. You can now add caching, errors, new data sources, etc…
This is just an idea. Everybody has a way of organizing code. This happens to be mine at the moment. The best one I found so far for these types of applications.
You can find a sample project on Github.
I can’t recommend objc.io enough by the way.