From 3638feffd48fd8a7b6d437d1ed8bfb23cd81687b Mon Sep 17 00:00:00 2001 From: george-gw Date: Mon, 24 Oct 2016 18:30:09 +0200 Subject: [PATCH] Added selection API to ASTableNode and ASCollectionNode (#2453) * Added selection API to ASTableNode and ASCollectionNode (#2450) * Updated test case to use collectionNode instead of collectionView for the selection tests. * Fixed typos. Added asserts for main thread. Updated ASCollectionViewTests for multiple selections for nodes. * Added assigns to the different properties. --- AsyncDisplayKit/ASCollectionNode.h | 44 +++++++ AsyncDisplayKit/ASCollectionNode.mm | 70 +++++++++++- AsyncDisplayKit/ASTableNode.h | 53 +++++++++ AsyncDisplayKit/ASTableNode.mm | 108 +++++++++++++++++- AsyncDisplayKitTests/ASCollectionViewTests.mm | 49 ++++++-- 5 files changed, 311 insertions(+), 13 deletions(-) diff --git a/AsyncDisplayKit/ASCollectionNode.h b/AsyncDisplayKit/ASCollectionNode.h index b36d94ee88..6e3c5fd9ad 100644 --- a/AsyncDisplayKit/ASCollectionNode.h +++ b/AsyncDisplayKit/ASCollectionNode.h @@ -74,6 +74,20 @@ NS_ASSUME_NONNULL_BEGIN */ @property (weak, nonatomic) id dataSource; +/** + * A Boolean value that indicates whether users can select items in the collection node. + * If the value of this property is YES (the default), users can select items. If you want more fine-grained control over the selection of items, you must provide a delegate object and implement the appropriate methods of the UICollectionNodeDelegate protocol. + */ +@property (nonatomic, assign) BOOL allowsSelection; + +/** + * A Boolean value that determines whether users can select more than one item in the collection node. + * This property controls whether multiple items can be selected simultaneously. The default value of this property is NO. + * When the value of this property is YES, tapping a cell adds it to the current selection (assuming the delegate permits the cell to be selected). Tapping the cell again removes it from the selection. + */ +@property (nonatomic, assign) BOOL allowsMultipleSelection; + + /** * Tuning parameters for a range type in full mode. * @@ -272,6 +286,36 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)relayoutItems; +#pragma mark - Selection + +/** + * Selects the item at the specified index path and optionally scrolls it into view. + * If the `allowsSelection` property is NO, calling this method has no effect. If there is an existing selection with a different index path and the `allowsMultipleSelection` property is NO, calling this method replaces the previous selection. + * This method does not cause any selection-related delegate methods to be called. + * + * @param indexPath The index path of the item to select. Specifying nil for this parameter clears the current selection. + * + * @param animated Specify YES to animate the change in the selection or NO to make the change without animating it. + * + * @param scrollPosition An option that specifies where the item should be positioned when scrolling finishes. For a list of possible values, see `UICollectionViewScrollPosition`. + * + * @discussion This method must be called from the main thread. + */ +- (void)selectItemAtIndexPath:(nullable NSIndexPath *)indexPath animated:(BOOL)animated scrollPosition:(UICollectionViewScrollPosition)scrollPosition; + +/** + * Deselects the item at the specified index. + * If the allowsSelection property is NO, calling this method has no effect. + * This method does not cause any selection-related delegate methods to be called. + * + * @param indexPath The index path of the item to select. Specifying nil for this parameter clears the current selection. + * + * @param animated Specify YES to animate the change in the selection or NO to make the change without animating it. + * + * @discussion This method must be called from the main thread. + */ +- (void)deselectItemAtIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated; + #pragma mark - Querying Data /** diff --git a/AsyncDisplayKit/ASCollectionNode.mm b/AsyncDisplayKit/ASCollectionNode.mm index ab6123be31..b9dcc1c676 100644 --- a/AsyncDisplayKit/ASCollectionNode.mm +++ b/AsyncDisplayKit/ASCollectionNode.mm @@ -27,7 +27,9 @@ @interface _ASCollectionPendingState : NSObject @property (weak, nonatomic) id delegate; @property (weak, nonatomic) id dataSource; -@property (assign, nonatomic) ASLayoutRangeMode rangeMode; +@property (nonatomic, assign) ASLayoutRangeMode rangeMode; +@property (nonatomic, assign) BOOL allowsSelection; // default is YES +@property (nonatomic, assign) BOOL allowsMultipleSelection; // default is NO @end @implementation _ASCollectionPendingState @@ -37,6 +39,8 @@ - (instancetype)init self = [super init]; if (self) { _rangeMode = ASLayoutRangeModeCount; + _allowsSelection = YES; + _allowsMultipleSelection = NO; } return self; } @@ -143,9 +147,11 @@ - (void)didLoad if (_pendingState) { _ASCollectionPendingState *pendingState = _pendingState; - self.pendingState = nil; - view.asyncDelegate = pendingState.delegate; - view.asyncDataSource = pendingState.dataSource; + self.pendingState = nil; + view.asyncDelegate = pendingState.delegate; + view.asyncDataSource = pendingState.dataSource; + view.allowsSelection = pendingState.allowsSelection; + view.allowsMultipleSelection = pendingState.allowsMultipleSelection; if (pendingState.rangeMode != ASLayoutRangeModeCount) { [view.rangeController updateCurrentRangeWithMode:pendingState.rangeMode]; @@ -251,6 +257,44 @@ - (void)setDataSource:(id )dataSource } } +- (void)setAllowsSelection:(BOOL)allowsSelection +{ + if ([self pendingState]) { + _pendingState.allowsSelection = allowsSelection; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASCollectionNode should be loaded if pendingState doesn't exist"); + self.view.allowsSelection = allowsSelection; + } +} + +- (BOOL)allowsSelection +{ + if ([self pendingState]) { + return _pendingState.allowsSelection; + } else { + return self.view.allowsSelection; + } +} + +- (void)setAllowsMultipleSelection:(BOOL)allowsMultipleSelection +{ + if ([self pendingState]) { + _pendingState.allowsMultipleSelection = allowsMultipleSelection; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASCollectionNode should be loaded if pendingState doesn't exist"); + self.view.allowsMultipleSelection = allowsMultipleSelection; + } +} + +- (BOOL)allowsMultipleSelection +{ + if ([self pendingState]) { + return _pendingState.allowsMultipleSelection; + } else { + return self.view.allowsMultipleSelection; + } +} + #pragma mark - Range Tuning - (ASRangeTuningParameters)tuningParametersForRangeType:(ASLayoutRangeType)rangeType @@ -273,6 +317,24 @@ - (void)setTuningParameters:(ASRangeTuningParameters)tuningParameters forRangeMo return [self.rangeController setTuningParameters:tuningParameters forRangeMode:rangeMode rangeType:rangeType]; } +#pragma mark - Selection + +- (void)selectItemAtIndexPath:(nullable NSIndexPath *)indexPath animated:(BOOL)animated scrollPosition:(UICollectionViewScrollPosition)scrollPosition +{ + ASDisplayNodeAssertMainThread(); + // TODO: Solve this in a way to be able to remove this restriction (https://github.com/facebook/AsyncDisplayKit/pull/2453#discussion_r84515457) + ASDisplayNodeAssert([self isNodeLoaded], @"ASCollectionNode should be loaded before calling selectItemAtIndexPath"); + [self.view selectItemAtIndexPath:indexPath animated:animated scrollPosition:scrollPosition]; +} + +- (void)deselectItemAtIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated +{ + ASDisplayNodeAssertMainThread(); + // TODO: Solve this in a way to be able to remove this restriction (https://github.com/facebook/AsyncDisplayKit/pull/2453#discussion_r84515457) + ASDisplayNodeAssert([self isNodeLoaded], @"ASCollectionNode should be loaded before calling deselectItemAtIndexPath"); + [self.view deselectItemAtIndexPath:indexPath animated:animated]; +} + #pragma mark - Querying Data - (NSInteger)numberOfItemsInSection:(NSInteger)section diff --git a/AsyncDisplayKit/ASTableNode.h b/AsyncDisplayKit/ASTableNode.h index 854c1136f6..3111704b54 100644 --- a/AsyncDisplayKit/ASTableNode.h +++ b/AsyncDisplayKit/ASTableNode.h @@ -35,6 +35,27 @@ NS_ASSUME_NONNULL_BEGIN @property (weak, nonatomic) id delegate; @property (weak, nonatomic) id dataSource; +/* + * A Boolean value that determines whether users can select a row. + * If the value of this property is YES (the default), users can select rows. If you set it to NO, they cannot select rows. Setting this property affects cell selection only when the table view is not in editing mode. If you want to restrict selection of cells in editing mode, use `allowsSelectionDuringEditing`. + */ +@property (nonatomic, assign) BOOL allowsSelection; +/* + * A Boolean value that determines whether users can select cells while the table view is in editing mode. + * If the value of this property is YES, users can select rows during editing. The default value is NO. If you want to restrict selection of cells regardless of mode, use allowsSelection. + */ +@property (nonatomic, assign) BOOL allowsSelectionDuringEditing; +/* + * A Boolean value that determines whether users can select more than one row outside of editing mode. + * This property controls whether multiple rows can be selected simultaneously outside of editing mode. When the value of this property is YES, each row that is tapped acquires a selected appearance. Tapping the row again removes the selected appearance. If you access indexPathsForSelectedRows, you can get the index paths that identify the selected rows. + */ +@property (nonatomic, assign) BOOL allowsMultipleSelection; +/* + * A Boolean value that controls whether users can select more than one cell simultaneously in editing mode. + * The default value of this property is NO. If you set it to YES, check marks appear next to selected rows in editing mode. In addition, UITableView does not query for editing styles when it goes into editing mode. If you access indexPathsForSelectedRows, you can get the index paths that identify the selected rows. + */ +@property (nonatomic, assign) BOOL allowsMultipleSelectionDuringEditing; + /** * Tuning parameters for a range type in full mode. * @@ -230,6 +251,38 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)moveRowAtIndexPath:(NSIndexPath *)indexPath toIndexPath:(NSIndexPath *)newIndexPath; +#pragma mark - Selection + +/** + * Selects a row in the table view identified by index path, optionally scrolling the row to a location in the table view. + * This method does not cause any selection-related delegate methods to be called. + * + * @param indexPath An index path identifying a row in the table view. + * + * @param animated Specify YES to animate the change in the selection or NO to make the change without animating it. + * + * @param scrollPosition A constant that identifies a relative position in the table view (top, middle, bottom) for the row when scrolling concludes. See `UITableViewScrollPosition` for descriptions of valid constants. + * + * @discussion This method must be called from the main thread. + */ +- (void)selectRowAtIndexPath:(nullable NSIndexPath *)indexPath animated:(BOOL)animated scrollPosition:(UITableViewScrollPosition)scrollPosition; + +/* + * Deselects a given row identified by index path, with an option to animate the deselection. + * This method does not cause any selection-related delegate methods to be called. + * Calling this method does not cause any scrolling to the deselected row. + * + * @param indexPath An index path identifying a row in the table view. + * + * @param animated Specify YES to animate the change in the selection or NO to make the change without animating it. + * + * @discussion This method must be called from the main thread. + */ +- (void)deselectRowAtIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated; + + +#pragma mark - Querying Data + /** * Retrieves the number of rows in the given section. * diff --git a/AsyncDisplayKit/ASTableNode.mm b/AsyncDisplayKit/ASTableNode.mm index dc5daa0dae..5fa32a1425 100644 --- a/AsyncDisplayKit/ASTableNode.mm +++ b/AsyncDisplayKit/ASTableNode.mm @@ -24,7 +24,11 @@ @interface _ASTablePendingState : NSObject @property (weak, nonatomic) id delegate; @property (weak, nonatomic) id dataSource; -@property (assign, nonatomic) ASLayoutRangeMode rangeMode; +@property (nonatomic, assign) ASLayoutRangeMode rangeMode; +@property (nonatomic, assign) BOOL allowsSelection; +@property (nonatomic, assign) BOOL allowsSelectionDuringEditing; +@property (nonatomic, assign) BOOL allowsMultipleSelection; +@property (nonatomic, assign) BOOL allowsMultipleSelectionDuringEditing; @end @implementation _ASTablePendingState @@ -33,6 +37,10 @@ - (instancetype)init self = [super init]; if (self) { _rangeMode = ASLayoutRangeModeCount; + _allowsSelection = YES; + _allowsSelectionDuringEditing = NO; + _allowsMultipleSelection = NO; + _allowsMultipleSelectionDuringEditing = NO; } return self; } @@ -104,6 +112,10 @@ - (void)didLoad self.pendingState = nil; view.asyncDelegate = pendingState.delegate; view.asyncDataSource = pendingState.dataSource; + view.allowsSelection = pendingState.allowsSelection; + view.allowsSelectionDuringEditing = pendingState.allowsSelectionDuringEditing; + view.allowsMultipleSelection = pendingState.allowsMultipleSelection; + view.allowsMultipleSelectionDuringEditing = pendingState.allowsMultipleSelectionDuringEditing; if (pendingState.rangeMode != ASLayoutRangeModeCount) { [view.rangeController updateCurrentRangeWithMode:pendingState.rangeMode]; } @@ -215,6 +227,82 @@ - (void)setDataSource:(id )dataSource } } +- (void)setAllowsSelection:(BOOL)allowsSelection +{ + if ([self pendingState]) { + _pendingState.allowsSelection = allowsSelection; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASTableNode should be loaded if pendingState doesn't exist"); + self.view.allowsSelection = allowsSelection; + } +} + +- (BOOL)allowsSelection +{ + if ([self pendingState]) { + return _pendingState.allowsSelection; + } else { + return self.view.allowsSelection; + } +} + +- (void)setAllowsSelectionDuringEditing:(BOOL)allowsSelectionDuringEditing +{ + if ([self pendingState]) { + _pendingState.allowsSelectionDuringEditing = allowsSelectionDuringEditing; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASTableNode should be loaded if pendingState doesn't exist"); + self.view.allowsSelectionDuringEditing = allowsSelectionDuringEditing; + } +} + +- (BOOL)allowsSelectionDuringEditing +{ + if ([self pendingState]) { + return _pendingState.allowsSelectionDuringEditing; + } else { + return self.view.allowsSelectionDuringEditing; + } +} + +- (void)setAllowsMultipleSelection:(BOOL)allowsMultipleSelection +{ + if ([self pendingState]) { + _pendingState.allowsMultipleSelection = allowsMultipleSelection; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASTableNode should be loaded if pendingState doesn't exist"); + self.view.allowsMultipleSelection = allowsMultipleSelection; + } +} + +- (BOOL)allowsMultipleSelection +{ + if ([self pendingState]) { + return _pendingState.allowsMultipleSelection; + } else { + return self.view.allowsMultipleSelection; + } +} + +- (void)setAllowsMultipleSelectionDuringEditing:(BOOL)allowsMultipleSelectionDuringEditing +{ + if ([self pendingState]) { + _pendingState.allowsMultipleSelectionDuringEditing = allowsMultipleSelectionDuringEditing; + } else { + ASDisplayNodeAssert([self isNodeLoaded], @"ASTableNode should be loaded if pendingState doesn't exist"); + self.view.allowsMultipleSelectionDuringEditing = allowsMultipleSelectionDuringEditing; + } +} + +- (BOOL)allowsMultipleSelectionDuringEditing +{ + if ([self pendingState]) { + return _pendingState.allowsMultipleSelectionDuringEditing; + } else { + return self.view.allowsMultipleSelectionDuringEditing; + } +} + #pragma mark ASRangeControllerUpdateRangeProtocol - (void)updateCurrentRangeWithMode:(ASLayoutRangeMode)rangeMode @@ -253,6 +341,24 @@ - (void)setTuningParameters:(ASRangeTuningParameters)tuningParameters forRangeMo return [self.rangeController setTuningParameters:tuningParameters forRangeMode:rangeMode rangeType:rangeType]; } +#pragma mark - Selection + +- (void)selectRowAtIndexPath:(nullable NSIndexPath *)indexPath animated:(BOOL)animated scrollPosition:(UITableViewScrollPosition)scrollPosition +{ + ASDisplayNodeAssertMainThread(); + // TODO: Solve this in a way to be able to remove this restriction (https://github.com/facebook/AsyncDisplayKit/pull/2453#discussion_r84515457) + ASDisplayNodeAssert([self isNodeLoaded], @"ASTableNode should be loaded before calling selectRowAtIndexPath"); + [self.view selectRowAtIndexPath:indexPath animated:animated scrollPosition:scrollPosition]; +} + +- (void)deselectRowAtIndexPath:(NSIndexPath *)indexPath animated:(BOOL)animated +{ + ASDisplayNodeAssertMainThread(); + // TODO: Solve this in a way to be able to remove this restriction (https://github.com/facebook/AsyncDisplayKit/pull/2453#discussion_r84515457) + ASDisplayNodeAssert([self isNodeLoaded], @"ASTableNode should be loaded before calling deselectRowAtIndexPath"); + [self.view deselectRowAtIndexPath:indexPath animated:animated]; +} + #pragma mark - Querying Data - (NSInteger)numberOfRowsInSection:(NSInteger)section diff --git a/AsyncDisplayKitTests/ASCollectionViewTests.mm b/AsyncDisplayKitTests/ASCollectionViewTests.mm index d8940b2b53..0bd538cacb 100644 --- a/AsyncDisplayKitTests/ASCollectionViewTests.mm +++ b/AsyncDisplayKitTests/ASCollectionViewTests.mm @@ -222,27 +222,60 @@ - (void)testSelection NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0]; ASCellNode *node = [testController.collectionView nodeForItemAtIndexPath:indexPath]; - + + NSInteger setSelectedCount = 0; // selecting node should select cell node.selected = YES; + ++setSelectedCount; XCTAssertTrue([[testController.collectionView indexPathsForSelectedItems] containsObject:indexPath], @"Selecting node should update cell selection."); // deselecting node should deselect cell node.selected = NO; + ++setSelectedCount; XCTAssertTrue([[testController.collectionView indexPathsForSelectedItems] isEqualToArray:@[]], @"Deselecting node should update cell selection."); - // selecting cell via collectionView should select node - [testController.collectionView selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; + // selecting cell via collectionNode should select node + ++setSelectedCount; + [testController.collectionNode selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; XCTAssertTrue(node.isSelected == YES, @"Selecting cell should update node selection."); - // deselecting cell via collectionView should deselect node - [testController.collectionView deselectItemAtIndexPath:indexPath animated:NO]; + // deselecting cell via collectionNode should deselect node + ++setSelectedCount; + [testController.collectionNode deselectItemAtIndexPath:indexPath animated:NO]; XCTAssertTrue(node.isSelected == NO, @"Deselecting cell should update node selection."); // select the cell again, scroll down and back up, and check that the state persisted - [testController.collectionView selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; + [testController.collectionNode selectItemAtIndexPath:indexPath animated:NO scrollPosition:UICollectionViewScrollPositionNone]; + ++setSelectedCount; XCTAssertTrue(node.isSelected == YES, @"Selecting cell should update node selection."); - + + testController.collectionNode.allowsMultipleSelection = YES; + + NSIndexPath *indexPath2 = [NSIndexPath indexPathForItem:1 inSection:0]; + ASCellNode *node2 = [testController.collectionView nodeForItemAtIndexPath:indexPath2]; + + // selecting cell via collectionNode should select node + [testController.collectionNode selectItemAtIndexPath:indexPath2 animated:NO scrollPosition:UICollectionViewScrollPositionNone]; + XCTAssertTrue(node2.isSelected == YES, @"Selecting cell should update node selection."); + + XCTAssertTrue([[testController.collectionView indexPathsForSelectedItems] containsObject:indexPath] && + [[testController.collectionView indexPathsForSelectedItems] containsObject:indexPath2], + @"Selecting multiple cells should result in those cells being in the array of selectedItems."); + + // deselecting node should deselect cell + node.selected = NO; + ++setSelectedCount; + XCTAssertTrue(![[testController.collectionView indexPathsForSelectedItems] containsObject:indexPath] && + [[testController.collectionView indexPathsForSelectedItems] containsObject:indexPath2], @"Deselecting node should update array of selectedItems."); + + node.selected = YES; + ++setSelectedCount; + XCTAssertTrue([[testController.collectionView indexPathsForSelectedItems] containsObject:indexPath], @"Selecting node should update cell selection."); + + node2.selected = NO; + XCTAssertTrue([[testController.collectionView indexPathsForSelectedItems] containsObject:indexPath] && + ![[testController.collectionView indexPathsForSelectedItems] containsObject:indexPath2], @"Deselecting node should update array of selectedItems."); + // reload cell (-prepareForReuse is called) & check that selected state is preserved [testController.collectionView setContentOffset:CGPointMake(0,testController.collectionView.bounds.size.height)]; [testController.collectionView layoutIfNeeded]; @@ -256,7 +289,7 @@ - (void)testSelection XCTAssertTrue(node.isSelected == NO, @"Deselecting cell should update node selection."); // check setSelected not called extra times - XCTAssertTrue([(ASTextCellNodeWithSetSelectedCounter *)node setSelectedCounter] == 6, @"setSelected: should not be called on node multiple times."); + XCTAssertTrue([(ASTextCellNodeWithSetSelectedCounter *)node setSelectedCounter] == (setSelectedCount + 1), @"setSelected: should not be called on node multiple times."); } - (void)testTuningParametersWithExplicitRangeMode