From 288a75c66d9f67075118d6a3b0e94e8956c4e362 Mon Sep 17 00:00:00 2001 From: Adlai Holler Date: Mon, 12 Jun 2017 16:50:33 -0700 Subject: [PATCH] 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 --- Source/ASCellNode.h | 9 ++ Source/ASCollectionNode.h | 22 +++ Source/ASCollectionNode.mm | 6 + Source/ASCollectionView.mm | 12 ++ Source/ASTableView.mm | 6 + Source/Details/ASCollectionElement.h | 4 +- Source/Details/ASCollectionElement.mm | 5 +- Source/Details/ASDataController.h | 2 + Source/Details/ASDataController.mm | 5 +- Tests/ASCollectionModernDataSourceTests.m | 132 +++++++++++++++--- .../CatDealsCollectionView/Sample/ItemNode.h | 18 +-- .../CatDealsCollectionView/Sample/ItemNode.m | 20 +-- 12 files changed, 201 insertions(+), 40 deletions(-) diff --git a/Source/ASCellNode.h b/Source/ASCellNode.h index 715d45d13..afbbe04c3 100644 --- a/Source/ASCellNode.h +++ b/Source/ASCellNode.h @@ -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. diff --git a/Source/ASCollectionNode.h b/Source/ASCollectionNode.h index c614cd85c..22f4ae601 100644 --- a/Source/ASCollectionNode.h +++ b/Source/ASCollectionNode.h @@ -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. diff --git a/Source/ASCollectionNode.mm b/Source/ASCollectionNode.mm index 4e3a5e43f..3412dd67e 100644 --- a/Source/ASCollectionNode.mm +++ b/Source/ASCollectionNode.mm @@ -590,6 +590,12 @@ - (ASCellNode *)nodeForItemAtIndexPath:(NSIndexPath *)indexPath 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]; diff --git a/Source/ASCollectionView.mm b/Source/ASCollectionView.mm index e3a95cd40..519f0ecda 100644 --- a/Source/ASCollectionView.mm +++ b/Source/ASCollectionView.mm @@ -207,6 +207,7 @@ @interface ASCollectionView () )asyncDataSource _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 @@ - (void)_beginBatchFetching #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; diff --git a/Source/ASTableView.mm b/Source/ASTableView.mm index ac01af981..b18b655d6 100644 --- a/Source/ASTableView.mm +++ b/Source/ASTableView.mm @@ -1629,6 +1629,12 @@ - (void)rangeController:(ASRangeController *)rangeController didUpdateWithChange #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; diff --git a/Source/Details/ASCollectionElement.h b/Source/Details/ASCollectionElement.h index 5c31d28af..337e6b0be 100644 --- a/Source/Details/ASCollectionElement.h +++ b/Source/Details/ASCollectionElement.h @@ -31,8 +31,10 @@ AS_SUBCLASSING_RESTRICTED @property (nonatomic, assign) ASSizeRange constrainedSize; @property (nonatomic, readonly, weak) id 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)owningNode diff --git a/Source/Details/ASCollectionElement.mm b/Source/Details/ASCollectionElement.mm index c7803e1d8..955cc5f11 100644 --- a/Source/Details/ASCollectionElement.mm +++ b/Source/Details/ASCollectionElement.mm @@ -31,7 +31,8 @@ @implementation ASCollectionElement { ASCellNode *_node; } -- (instancetype)initWithNodeBlock:(ASCellNodeBlock)nodeBlock +- (instancetype)initWithViewModel:(id)viewModel + nodeBlock:(ASCellNodeBlock)nodeBlock supplementaryElementKind:(NSString *)supplementaryElementKind constrainedSize:(ASSizeRange)constrainedSize owningNode:(id)owningNode @@ -40,6 +41,7 @@ - (instancetype)initWithNodeBlock:(ASCellNodeBlock)nodeBlock 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 @@ - (ASCellNode *)node node.owningNode = _owningNode; node.collectionElement = self; ASTraitCollectionPropagateDown(node, _traitCollection); + node.viewModel = _viewModel; _node = node; } return _node; diff --git a/Source/Details/ASDataController.h b/Source/Details/ASDataController.h index fc4d9ee5b..474f3fe49 100644 --- a/Source/Details/ASDataController.h +++ b/Source/Details/ASDataController.h @@ -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 /** diff --git a/Source/Details/ASDataController.mm b/Source/Details/ASDataController.mm index da4b5e4bd..b1ab365d1 100644 --- a/Source/Details/ASDataController.mm +++ b/Source/Details/ASDataController.mm @@ -384,6 +384,8 @@ - (void)_insertElementsIntoMap:(ASMutableElementMap *)map id dataSource = self.dataSource; id 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 @@ - (void)_insertElementsIntoMap:(ASMutableElementMap *)map 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 diff --git a/Tests/ASCollectionModernDataSourceTests.m b/Tests/ASCollectionModernDataSourceTests.m index e7ba88140..fcb0c8383 100644 --- a/Tests/ASCollectionModernDataSourceTests.m +++ b/Tests/ASCollectionModernDataSourceTests.m @@ -27,10 +27,15 @@ @implementation ASCollectionModernDataSourceTests { UIWindow *window; UIViewController *viewController; ASCollectionNode *collectionNode; + 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 @@ - (void)setUp { @selector(numberOfSectionsInCollectionNode:), @selector(collectionNode:numberOfItemsInSection:), @selector(collectionNode:nodeBlockForItemAtIndexPath:), + @selector(collectionNode:viewModelForItemAtIndexPath:), nil]; [mockDataSource setExpectationOrderMatters:YES]; @@ -59,18 +65,65 @@ - (void)tearDown [super tearDown]; } -- (void)testInitialDataLoadingCallPattern +#pragma mark - Test Methods + +- (void)testInitialDataLoading +{ + [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++) { - NSArray *counts = @[ @2 ]; - [self expectDataSourceMethodsWithCounts:counts]; + // 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]; -} -#pragma mark - Helpers + // Assert item counts & content: + [self assertCollectionNodeContent]; +} /** * Adds expectations for the sequence: @@ -78,29 +131,72 @@ - (void)testInitialDataLoadingCallPattern * numberOfSectionsInCollectionNode: * for section in countsArray * numberOfItemsInSection: - * for item < itemCount - * nodeBlockForItemAtIndexPath: */ -- (void)expectDataSourceMethodsWithCounts:(NSArray *)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 *)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 diff --git a/examples/CatDealsCollectionView/Sample/ItemNode.h b/examples/CatDealsCollectionView/Sample/ItemNode.h index 7fc1dedc1..678327e11 100644 --- a/examples/CatDealsCollectionView/Sample/ItemNode.h +++ b/examples/CatDealsCollectionView/Sample/ItemNode.h @@ -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 diff --git a/examples/CatDealsCollectionView/Sample/ItemNode.m b/examples/CatDealsCollectionView/Sample/ItemNode.m index 79d8c2ff2..50ce9e106 100644 --- a/examples/CatDealsCollectionView/Sample/ItemNode.m +++ b/examples/CatDealsCollectionView/Sample/ItemNode.m @@ -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 @@ - (instancetype)initWithViewModel:(ItemViewModel *)viewModel { self = [super init]; if (self != nil) { - _viewModel = viewModel; + self.viewModel = viewModel; [self setup]; [self updateLabels]; [self updateBackgroundColor];