Add first-pass view model support to collection node. #trivial (#356)

* Add first-pass view model support for collection node. Much more to come!

* Address issues

* Update the gorram license header

* Dear lord
This commit is contained in:
Adlai Holler 2017-06-12 16:50:33 -07:00 committed by GitHub
parent c297060113
commit 83111de0cc
12 changed files with 203 additions and 42 deletions

View File

@ -117,6 +117,15 @@ typedef NS_ENUM(NSUInteger, ASCellNodeVisibilityEvent) {
*/
@property (atomic, readonly, nullable) NSIndexPath *indexPath;
/**
* BETA: API is under development. We will attempt to provide an easy migration pathway for any changes.
*
* The view-model currently assigned to this node, if any.
*
* This property may be set off the main thread, but this method will never be invoked concurrently on the
*/
@property (atomic, nullable) id viewModel;
/**
* The backing view controller, or @c nil if the node wasn't initialized with backing view controller
* @note This property must be accessed on the main thread.

View File

@ -418,6 +418,17 @@ NS_ASSUME_NONNULL_BEGIN
*/
- (nullable __kindof ASCellNode *)nodeForItemAtIndexPath:(NSIndexPath *)indexPath AS_WARN_UNUSED_RESULT;
/**
* Retrieves the view-model for the item at the given index path, if any.
*
* @param indexPath The index path of the requested item.
*
* @return The view-model for the given item, or @c nil if no item exists at the specified path or no view-model was provided.
*
* @warning This API is beta and subject to change. We'll try to provide an easy migration path.
*/
- (nullable id)viewModelForItemAtIndexPath:(NSIndexPath *)indexPath AS_WARN_UNUSED_RESULT;
/**
* Retrieve the index path for the item with the given node.
*
@ -503,6 +514,17 @@ NS_ASSUME_NONNULL_BEGIN
*/
- (NSInteger)numberOfSectionsInCollectionNode:(ASCollectionNode *)collectionNode;
/**
* --BETA--
* Asks the data source for a view-model for the item at the given index path.
*
* @param collectionNode The sender.
* @param indexPath The index path of the item.
*
* @return An object that contains all the data for this item.
*/
- (nullable id)collectionNode:(ASCollectionNode *)collectionNode viewModelForItemAtIndexPath:(NSIndexPath *)indexPath;
/**
* Similar to -collectionNode:nodeForItemAtIndexPath:
* This method takes precedence over collectionNode:nodeForItemAtIndexPath: if implemented.

View File

@ -590,6 +590,12 @@
return [self.dataController.pendingMap elementForItemAtIndexPath:indexPath].node;
}
- (id)viewModelForItemAtIndexPath:(NSIndexPath *)indexPath
{
[self reloadDataInitiallyIfNeeded];
return [self.dataController.pendingMap elementForItemAtIndexPath:indexPath].viewModel;
}
- (NSIndexPath *)indexPathForNode:(ASCellNode *)cellNode
{
return [self.dataController.pendingMap indexPathForElement:cellNode.collectionElement];

View File

@ -207,6 +207,7 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier";
unsigned int collectionViewNumberOfItemsInSection:1;
unsigned int collectionNodeNodeForItem:1;
unsigned int collectionNodeNodeBlockForItem:1;
unsigned int viewModelForItem:1;
unsigned int collectionNodeNodeForSupplementaryElement:1;
unsigned int collectionNodeNodeBlockForSupplementaryElement:1;
unsigned int collectionNodeSupplementaryElementKindsInSection:1;
@ -450,6 +451,7 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier";
_asyncDataSourceFlags.collectionNodeNodeForSupplementaryElement = [_asyncDataSource respondsToSelector:@selector(collectionNode:nodeForSupplementaryElementOfKind:atIndexPath:)];
_asyncDataSourceFlags.collectionNodeNodeBlockForSupplementaryElement = [_asyncDataSource respondsToSelector:@selector(collectionNode:nodeBlockForSupplementaryElementOfKind:atIndexPath:)];
_asyncDataSourceFlags.collectionNodeSupplementaryElementKindsInSection = [_asyncDataSource respondsToSelector:@selector(collectionNode:supplementaryElementKindsInSection:)];
_asyncDataSourceFlags.viewModelForItem = [_asyncDataSource respondsToSelector:@selector(collectionNode:viewModelForItemAtIndexPath:)];
_asyncDataSourceFlags.interop = [_asyncDataSource conformsToProtocol:@protocol(ASCollectionDataSourceInterop)];
if (_asyncDataSourceFlags.interop) {
@ -1611,6 +1613,16 @@ static NSString * const kReuseIdentifier = @"_ASCollectionReuseIdentifier";
#pragma mark - ASDataControllerSource
- (id)dataController:(ASDataController *)dataController viewModelForItemAtIndexPath:(NSIndexPath *)indexPath
{
if (!_asyncDataSourceFlags.viewModelForItem) {
return nil;
}
GET_COLLECTIONNODE_OR_RETURN(collectionNode, nil);
return [_asyncDataSource collectionNode:collectionNode viewModelForItemAtIndexPath:indexPath];
}
- (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAtIndexPath:(NSIndexPath *)indexPath
{
ASCellNodeBlock block = nil;

View File

@ -1629,6 +1629,12 @@ static NSString * const kCellReuseIdentifier = @"_ASTableViewCell";
#pragma mark - ASDataControllerSource
- (id)dataController:(ASDataController *)dataController viewModelForItemAtIndexPath:(NSIndexPath *)indexPath
{
// Not currently supported for tables. Will be added when the collection API stabilizes.
return nil;
}
- (ASCellNodeBlock)dataController:(ASDataController *)dataController nodeBlockAtIndexPath:(NSIndexPath *)indexPath {
ASCellNodeBlock block = nil;

View File

@ -31,8 +31,10 @@ AS_SUBCLASSING_RESTRICTED
@property (nonatomic, assign) ASSizeRange constrainedSize;
@property (nonatomic, readonly, weak) id<ASRangeManagingNode> owningNode;
@property (nonatomic, assign) ASPrimitiveTraitCollection traitCollection;
@property (nonatomic, readonly, nullable) id viewModel;
- (instancetype)initWithNodeBlock:(ASCellNodeBlock)nodeBlock
- (instancetype)initWithViewModel:(nullable id)viewModel
nodeBlock:(ASCellNodeBlock)nodeBlock
supplementaryElementKind:(nullable NSString *)supplementaryElementKind
constrainedSize:(ASSizeRange)constrainedSize
owningNode:(id<ASRangeManagingNode>)owningNode

View File

@ -31,7 +31,8 @@
ASCellNode *_node;
}
- (instancetype)initWithNodeBlock:(ASCellNodeBlock)nodeBlock
- (instancetype)initWithViewModel:(id)viewModel
nodeBlock:(ASCellNodeBlock)nodeBlock
supplementaryElementKind:(NSString *)supplementaryElementKind
constrainedSize:(ASSizeRange)constrainedSize
owningNode:(id<ASRangeManagingNode>)owningNode
@ -40,6 +41,7 @@
NSAssert(nodeBlock != nil, @"Node block must not be nil");
self = [super init];
if (self) {
_viewModel = viewModel;
_nodeBlock = nodeBlock;
_supplementaryElementKind = [supplementaryElementKind copy];
_constrainedSize = constrainedSize;
@ -62,6 +64,7 @@
node.owningNode = _owningNode;
node.collectionElement = self;
ASTraitCollectionPropagateDown(node, _traitCollection);
node.viewModel = _viewModel;
_node = node;
}
return _node;

View File

@ -76,6 +76,8 @@ extern NSString * const ASCollectionInvalidUpdateException;
*/
- (BOOL)dataController:(ASDataController *)dataController presentedSizeForElement:(ASCollectionElement *)element matchesSize:(CGSize)size;
- (nullable id)dataController:(ASDataController *)dataController viewModelForItemAtIndexPath:(NSIndexPath *)indexPath;
@optional
/**

View File

@ -384,6 +384,8 @@ typedef void (^ASDataControllerCompletionBlock)(NSArray<ASCollectionElement *> *
id<ASDataControllerSource> dataSource = self.dataSource;
id<ASRangeManagingNode> node = self.node;
for (NSIndexPath *indexPath in indexPaths) {
id viewModel = [dataSource dataController:self viewModelForItemAtIndexPath:indexPath];
ASCellNodeBlock nodeBlock;
if (isRowKind) {
nodeBlock = [dataSource dataController:self nodeBlockAtIndexPath:indexPath];
@ -396,7 +398,8 @@ typedef void (^ASDataControllerCompletionBlock)(NSArray<ASCollectionElement *> *
constrainedSize = [self constrainedSizeForNodeOfKind:kind atIndexPath:indexPath];
}
ASCollectionElement *element = [[ASCollectionElement alloc] initWithNodeBlock:nodeBlock
ASCollectionElement *element = [[ASCollectionElement alloc] initWithViewModel:viewModel
nodeBlock:nodeBlock
supplementaryElementKind:isRowKind ? nil : kind
constrainedSize:constrainedSize
owningNode:node

View File

@ -27,10 +27,15 @@
UIWindow *window;
UIViewController *viewController;
ASCollectionNode *collectionNode;
NSMutableArray<NSMutableArray *> *sections;
}
- (void)setUp {
[super setUp];
// Default is 2 sections: 2 items in first, 1 item in second.
sections = [NSMutableArray array];
[sections addObject:[NSMutableArray arrayWithObjects:[NSObject new], [NSObject new], nil]];
[sections addObject:[NSMutableArray arrayWithObjects:[NSObject new], nil]];
window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
viewController = [[UIViewController alloc] init];
@ -46,6 +51,7 @@
@selector(numberOfSectionsInCollectionNode:),
@selector(collectionNode:numberOfItemsInSection:),
@selector(collectionNode:nodeBlockForItemAtIndexPath:),
@selector(collectionNode:viewModelForItemAtIndexPath:),
nil];
[mockDataSource setExpectationOrderMatters:YES];
@ -59,48 +65,138 @@
[super tearDown];
}
- (void)testInitialDataLoadingCallPattern
#pragma mark - Test Methods
- (void)testInitialDataLoading
{
/// BUG: these methods are called twice in a row i.e. this for-loop shouldn't be here. https://github.com/TextureGroup/Texture/issues/351
for (int i = 0; i < 2; i++) {
NSArray *counts = @[ @2 ];
[self expectDataSourceMethodsWithCounts:counts];
}
[window layoutIfNeeded];
[self loadInitialData];
}
- (void)testReloadingAnItem
{
[self loadInitialData];
// Reload at (0, 0)
NSIndexPath *reloadedPath = [NSIndexPath indexPathForItem:0 inSection:0];
sections[reloadedPath.section][reloadedPath.item] = [NSObject new];
[self performUpdateInvalidatingItems:@[ reloadedPath ] block:^{
[collectionNode reloadItemsAtIndexPaths:@[ reloadedPath ]];
}];
}
- (void)testInsertingAnItem
{
[self loadInitialData];
// Insert at (1, 0)
NSIndexPath *insertedPath = [NSIndexPath indexPathForItem:0 inSection:1];
[sections[insertedPath.section] insertObject:[NSObject new] atIndex:insertedPath.item];
[self performUpdateInvalidatingItems:@[ insertedPath ] block:^{
[collectionNode insertItemsAtIndexPaths:@[ insertedPath ]];
}];
}
#pragma mark - Helpers
- (void)loadInitialData
{
/// BUG: these methods are called twice in a row i.e. this for-loop shouldn't be here. https://github.com/TextureGroup/Texture/issues/351
for (int i = 0; i < 2; i++) {
// It reads all the counts
[self expectDataSourceCountMethods];
// It reads the contents for each item.
for (NSInteger section = 0; section < sections.count; section++) {
NSArray *items = sections[section];
// For each item:
for (NSInteger i = 0; i < items.count; i++) {
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:section];
[self expectContentMethodsForItemAtIndexPath:indexPath];
}
}
}
[window layoutIfNeeded];
// Assert item counts & content:
[self assertCollectionNodeContent];
}
/**
* Adds expectations for the sequence:
*
* numberOfSectionsInCollectionNode:
* for section in countsArray
* numberOfItemsInSection:
* for item < itemCount
* nodeBlockForItemAtIndexPath:
*/
- (void)expectDataSourceMethodsWithCounts:(NSArray<NSNumber *> *)counts
- (void)expectDataSourceCountMethods
{
// -numberOfSectionsInCollectionNode
OCMExpect([mockDataSource numberOfSectionsInCollectionNode:collectionNode])
.andReturn(counts.count);
.andReturn(sections.count);
// For each section:
// Note: Skip fast enumeration for readability.
for (NSInteger section = 0; section < counts.count; section++) {
NSInteger itemCount = counts[section].integerValue;
for (NSInteger section = 0; section < sections.count; section++) {
NSInteger itemCount = sections[section].count;
OCMExpect([mockDataSource collectionNode:collectionNode numberOfItemsInSection:section])
.andReturn(itemCount);
// For each item:
for (NSInteger i = 0; i < itemCount; i++) {
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:section];
OCMExpect([mockDataSource collectionNode:collectionNode nodeBlockForItemAtIndexPath:indexPath])
.andReturn((ASCellNodeBlock)^{ return [[ASCellNode alloc] init]; });
}
}
// Expects viewModelForItemAtIndexPath: and nodeBlockForItemAtIndexPath:
- (void)expectContentMethodsForItemAtIndexPath:(NSIndexPath *)indexPath
{
id viewModel = sections[indexPath.section][indexPath.item];
OCMExpect([mockDataSource collectionNode:collectionNode viewModelForItemAtIndexPath:indexPath])
.andReturn(viewModel);
OCMExpect([mockDataSource collectionNode:collectionNode nodeBlockForItemAtIndexPath:indexPath])
.andReturn((ASCellNodeBlock)^{ return [ASCellNode new]; });
}
- (void)assertCollectionNodeContent
{
// Assert section count
XCTAssertEqual(collectionNode.numberOfSections, sections.count);
for (NSInteger section = 0; section < sections.count; section++) {
NSArray *items = sections[section];
// Assert item count
XCTAssertEqual([collectionNode numberOfItemsInSection:section], items.count);
for (NSInteger item = 0; item < items.count; item++) {
// Assert view model
// Could use pointer equality but the error message is less readable.
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:item inSection:section];
id viewModel = sections[indexPath.section][indexPath.item];
XCTAssertEqualObjects(viewModel, [collectionNode viewModelForItemAtIndexPath:indexPath]);
ASCellNode *node = [collectionNode nodeForItemAtIndexPath:indexPath];
XCTAssertEqualObjects(node.viewModel, viewModel);
}
}
}
/**
* Updates the collection node, with expectations and assertions about the call-order and the correctness of the
* new data. You should update the data source _before_ calling this method.
*
* invalidatedIndexPaths are the items we expect to get refetched (reloaded/inserted).
*/
- (void)performUpdateInvalidatingItems:(NSArray<NSIndexPath *> *)invalidatedIndexPaths block:(void(^)())update
{
// When we do an edit, it'll read the new counts
[self expectDataSourceCountMethods];
// Then it'll load the contents for inserted/reloaded items.
for (NSIndexPath *indexPath in invalidatedIndexPaths) {
[self expectContentMethodsForItemAtIndexPath:indexPath];
}
[collectionNode performBatchUpdates:update completion:nil];
[self assertCollectionNodeContent];
}
@end

View File

@ -1,18 +1,18 @@
//
// ItemNode.h
// Sample
// Texture
//
// Copyright (c) 2014-present, Facebook, Inc. All rights reserved.
// This source code is licensed under the BSD-style license found in the
// LICENSE file in the root directory of this source tree. An additional grant
// of patent rights can be found in the PATENTS file in the same directory.
// LICENSE file in the /ASDK-Licenses directory of this source tree. An additional
// grant of patent rights can be found in the PATENTS file in the same directory.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// Modifications to this file made after 4/13/2017 are: Copyright (c) 2017-present,
// Pinterest, Inc. Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
#import <AsyncDisplayKit/AsyncDisplayKit.h>

View File

@ -1,18 +1,18 @@
//
// ItemNode.m
// Sample
// Texture
//
// Copyright (c) 2014-present, Facebook, Inc. All rights reserved.
// This source code is licensed under the BSD-style license found in the
// LICENSE file in the root directory of this source tree. An additional grant
// of patent rights can be found in the PATENTS file in the same directory.
// LICENSE file in the /ASDK-Licenses directory of this source tree. An additional
// grant of patent rights can be found in the PATENTS file in the same directory.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
// FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
// ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// Modifications to this file made after 4/13/2017 are: Copyright (c) 2017-present,
// Pinterest, Inc. Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
#import "ItemNode.h"
@ -50,7 +50,7 @@ const CGFloat kSoldOutGBHeight = 50.0;
{
self = [super init];
if (self != nil) {
_viewModel = viewModel;
self.viewModel = viewModel;
[self setup];
[self updateLabels];
[self updateBackgroundColor];