Skip to content
This repository was archived by the owner on Feb 2, 2023. It is now read-only.

Commit 2d575fc

Browse files
committed
Relayout table view cell nodes if there is a mismatch between content view size and the node's constrained size
- Above is the generic case. Correctly handling it means relayout when the table view enters or leaves editing mode is solved as well. - Async data source API removal: In a table view, cell nodes should always fill its content view and table view widths. Thus async data source can no longer provide custom constrained size for cell nodes. This removal allows table view to better handle relayout. - Some more tests are added to ASTableViewTests to check against use cases handled in this diff.
1 parent fff8a0b commit 2d575fc

3 files changed

Lines changed: 191 additions & 55 deletions

File tree

AsyncDisplayKit/ASTableView.h

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -266,17 +266,6 @@
266266

267267
@optional
268268

269-
/**
270-
* Provides the constrained size range for measuring the node at the index path.
271-
*
272-
* @param tableView The sender.
273-
*
274-
* @param indexPath The index path of the node.
275-
*
276-
* @returns A constrained size range for layout the node at this index path.
277-
*/
278-
- (ASSizeRange)tableView:(ASTableView *)tableView constrainedSizeForNodeAtIndexPath:(NSIndexPath *)indexPath;
279-
280269
/**
281270
* Indicator to lock the data source for data fetching in async mode.
282271
* We should not update the data source until the data source has been unlocked. Otherwise, it will incur data inconsistence or exception

AsyncDisplayKit/ASTableView.mm

Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#import "ASDisplayNodeInternal.h"
1717
#import "ASBatchFetching.h"
1818
#import "ASInternalHelpers.h"
19+
#import "ASLayout.h"
1920

2021
//#define LOG(...) NSLog(__VA_ARGS__)
2122
#define LOG(...)
@@ -104,22 +105,30 @@ - (id)forwardingTargetForSelector:(SEL)aSelector
104105
#pragma mark -
105106
#pragma mark ASCellNode<->UITableViewCell bridging.
106107

108+
@class _ASTableViewCell;
109+
107110
@protocol _ASTableViewCellDelegate <NSObject>
108-
- (void)tableViewCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath didTransitionToState:(UITableViewCellStateMask)state;
111+
- (void)willLayoutSubviewsOfTableViewCell:(_ASTableViewCell *)tableViewCell;
109112
@end
110113

111114
@interface _ASTableViewCell : UITableViewCell
112115
@property (nonatomic, weak) id<_ASTableViewCellDelegate> delegate;
113-
@property (nonatomic) NSIndexPath *indexPath;
116+
@property (nonatomic, weak) ASCellNode *node;
114117
@end
115118

116119
@implementation _ASTableViewCell
117120
// TODO add assertions to prevent use of view-backed UITableViewCell properties (eg .textLabel)
118121

122+
- (void)layoutSubviews
123+
{
124+
[_delegate willLayoutSubviewsOfTableViewCell:self];
125+
[super layoutSubviews];
126+
}
127+
119128
- (void)didTransitionToState:(UITableViewCellStateMask)state
120129
{
130+
[self setNeedsLayout];
121131
[super didTransitionToState:state];
122-
[_delegate tableViewCell:self atIndexPath:_indexPath didTransitionToState:state];
123132
}
124133

125134
@end
@@ -146,8 +155,6 @@ @interface ASTableView () <ASRangeControllerDelegate, ASDataControllerSource, _A
146155
NSIndexPath *_contentOffsetAdjustmentTopVisibleRow;
147156
CGFloat _contentOffsetAdjustment;
148157

149-
BOOL _asyncDataSourceImplementsConstrainedSizeForNode;
150-
151158
CGFloat _maxWidthForNodesConstrainedSize;
152159
BOOL _ignoreMaxWidthChange;
153160
}
@@ -271,12 +278,10 @@ - (void)setAsyncDataSource:(id<ASTableViewDataSource>)asyncDataSource
271278
super.dataSource = nil;
272279
_asyncDataSource = nil;
273280
_proxyDataSource = nil;
274-
_asyncDataSourceImplementsConstrainedSizeForNode = NO;
275281
} else {
276282
_asyncDataSource = asyncDataSource;
277283
_proxyDataSource = [[_ASTableViewProxy alloc] initWithTarget:_asyncDataSource interceptor:self];
278284
super.dataSource = (id<UITableViewDataSource>)_proxyDataSource;
279-
_asyncDataSourceImplementsConstrainedSizeForNode = ([_asyncDataSource respondsToSelector:@selector(tableView:constrainedSizeForNodeAtIndexPath:)] ? 1 : 0);
280285
}
281286
}
282287

@@ -507,7 +512,7 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N
507512
ASCellNode *node = [_dataController nodeAtIndexPath:indexPath];
508513
[_rangeController configureContentView:cell.contentView forCellNode:node];
509514

510-
cell.indexPath = indexPath;
515+
cell.node = node;
511516
cell.backgroundColor = node.backgroundColor;
512517
cell.selectionStyle = node.selectionStyle;
513518

@@ -798,11 +803,6 @@ - (ASCellNode *)dataController:(ASDataController *)dataController nodeAtIndexPat
798803

799804
- (ASSizeRange)dataController:(ASDataController *)dataController constrainedSizeForNodeAtIndexPath:(NSIndexPath *)indexPath
800805
{
801-
if (_asyncDataSourceImplementsConstrainedSizeForNode) {
802-
return [_asyncDataSource tableView:self constrainedSizeForNodeAtIndexPath:indexPath];
803-
}
804-
805-
// Default size range
806806
return ASSizeRangeMake(CGSizeMake(_maxWidthForNodesConstrainedSize, 0),
807807
CGSizeMake(_maxWidthForNodesConstrainedSize, FLT_MAX));
808808
}
@@ -845,22 +845,31 @@ - (NSUInteger)dataControllerNumberOfSections:(ASDataController *)dataController
845845

846846
#pragma mark - _ASTableViewCellDelegate
847847

848-
- (void)tableViewCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath didTransitionToState:(UITableViewCellStateMask)state
848+
- (void)willLayoutSubviewsOfTableViewCell:(_ASTableViewCell *)tableViewCell
849849
{
850-
[self beginUpdates];
851-
ASCellNode *node = [_dataController nodeAtIndexPath:indexPath];
850+
CGFloat contentViewWidth = tableViewCell.contentView.bounds.size.width;
851+
ASCellNode *node = tableViewCell.node;
852+
ASSizeRange constrainedSize = node.constrainedSizeForCalculatedLayout;
852853

853-
ASSizeRange constrainedSize = [self dataController:_dataController constrainedSizeForNodeAtIndexPath:indexPath];
854-
if (state != UITableViewCellStateDefaultMask) {
855-
// Edit control or delete confirmation was shown and size of content view was changed.
856-
// The new size should be taken into consideration.
857-
constrainedSize.min.width = MIN(cell.contentView.frame.size.width, constrainedSize.min.width);
858-
constrainedSize.max.width = MIN(cell.contentView.frame.size.width, constrainedSize.max.width);
854+
// Table view cells should always fill its content view width.
855+
// Normally the content view width equals to the constrained size width (which equals to the table view width).
856+
// If there is a mismatch between these values, for example after the table view entered or left editing mode,
857+
// content view width is preferred and used to re-measure the cell node.
858+
if (contentViewWidth != constrainedSize.max.width) {
859+
constrainedSize.min.width = contentViewWidth;
860+
constrainedSize.max.width = contentViewWidth;
861+
862+
// Re-measurement is done on main to ensure thread affinity. In the worst case, this is as fast as UIKit's implementation.
863+
//
864+
// Unloaded nodes *could* be re-measured off the main thread, but only with the assumption that content view width
865+
// is the same for all cells (because there is no easy way to get that individual value before the node being assigned to a _ASTableViewCell).
866+
// Also, in many cases, some nodes may not need to be re-measured at all, such as when user enters and then immediately leaves editing mode.
867+
// To avoid premature optimization and making such assumption, as well as to keep ASTableView simple, re-measurement is strictly done on main.
868+
[self beginUpdates];
869+
CGSize calculatedSize = [[node measureWithSizeRange:constrainedSize] size];
870+
node.frame = CGRectMake(0, 0, calculatedSize.width, calculatedSize.height);
871+
[self endUpdates];
859872
}
860-
861-
[node measureWithSizeRange:constrainedSize];
862-
node.frame = CGRectMake(0, 0, node.calculatedSize.width, node.calculatedSize.height);
863-
[self endUpdates];
864873
}
865874

866875
@end

AsyncDisplayKitTests/ASTableViewTests.m

Lines changed: 156 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#import <XCTest/XCTest.h>
1010

1111
#import "ASTableView.h"
12+
#import "ASDisplayNode+Subclasses.h"
1213

1314
#define NumberOfSections 10
1415
#define NumberOfRowsPerSection 20
@@ -56,9 +57,24 @@ - (void)dealloc
5657

5758
@end
5859

60+
@interface ASTestTextCellNode : ASTextCellNode
61+
/** Calculated by counting how many times -layoutSpecThatFits: is called on the main thread. */
62+
@property (atomic) int numberOfLayoutsOnMainThread;
63+
@end
64+
65+
@implementation ASTestTextCellNode
66+
67+
- (ASLayoutSpec *)layoutSpecThatFits:(ASSizeRange)constrainedSize
68+
{
69+
if ([NSThread isMainThread]) {
70+
_numberOfLayoutsOnMainThread++;
71+
}
72+
return [super layoutSpecThatFits:constrainedSize];
73+
}
74+
75+
@end
76+
5977
@interface ASTableViewFilledDataSource : NSObject <ASTableViewDataSource, ASTableViewDelegate>
60-
/** Calculated by counting how many times a constrained size is asked for the first node on main thread. */
61-
@property (atomic) int numberOfRelayouts;
6278
@end
6379

6480
@implementation ASTableViewFilledDataSource
@@ -75,22 +91,12 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger
7591

7692
- (ASCellNode *)tableView:(ASTableView *)tableView nodeForRowAtIndexPath:(NSIndexPath *)indexPath
7793
{
78-
ASTextCellNode *textCellNode = [ASTextCellNode new];
94+
ASTestTextCellNode *textCellNode = [ASTestTextCellNode new];
7995
textCellNode.text = indexPath.description;
8096

8197
return textCellNode;
8298
}
8399

84-
- (ASSizeRange)tableView:(ASTableView *)tableView constrainedSizeForNodeAtIndexPath:(NSIndexPath *)indexPath
85-
{
86-
if ([NSThread isMainThread] && indexPath.section == 0 && indexPath.row == 0) {
87-
_numberOfRelayouts++;
88-
}
89-
CGFloat maxWidth = tableView.bounds.size.width;
90-
return ASSizeRangeMake(CGSizeMake(maxWidth, 0),
91-
CGSizeMake(maxWidth, FLT_MAX));
92-
}
93-
94100
@end
95101

96102
@interface ASTableViewTests : XCTestCase
@@ -260,7 +266,7 @@ - (void)testRelayoutAllRowsWithZeroSizeInitially
260266

261267
- (void)triggerSizeChangeAndAssertRelayoutAllRowsForTableView:(ASTableView *)tableView newSize:(CGSize)newSize
262268
{
263-
XCTestExpectation *nodesMeasuredUsingNewConstrainedSizeExpectation = [self expectationWithDescription:@"nodesMeasuredUsingNewConstrainedSizeExpectation"];
269+
XCTestExpectation *nodesMeasuredUsingNewConstrainedSizeExpectation = [self expectationWithDescription:@"nodesMeasuredUsingNewConstrainedSize"];
264270

265271
[tableView beginUpdates];
266272

@@ -270,13 +276,11 @@ - (void)triggerSizeChangeAndAssertRelayoutAllRowsForTableView:(ASTableView *)tab
270276
[tableView layoutIfNeeded];
271277

272278
[tableView endUpdatesAnimated:NO completion:^(BOOL completed) {
273-
int numberOfRelayouts = ((ASTableViewFilledDataSource *)(tableView.asyncDataSource)).numberOfRelayouts;
274-
XCTAssertEqual(numberOfRelayouts, 1);
275-
276279
for (int section = 0; section < NumberOfSections; section++) {
277280
for (int row = 0; row < NumberOfRowsPerSection; row++) {
278281
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
279-
ASCellNode *node = [tableView nodeForRowAtIndexPath:indexPath];
282+
ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath];
283+
XCTAssertEqual(node.numberOfLayoutsOnMainThread, 1);
280284
XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, newSize.width);
281285
}
282286
}
@@ -290,4 +294,138 @@ - (void)triggerSizeChangeAndAssertRelayoutAllRowsForTableView:(ASTableView *)tab
290294
}];
291295
}
292296

297+
- (void)testRelayoutVisibleRowsWhenEditingModeIsChanged
298+
{
299+
CGSize tableViewSize = CGSizeMake(100, 500);
300+
ASTestTableView *tableView = [[ASTestTableView alloc] initWithFrame:CGRectMake(0, 0, tableViewSize.width, tableViewSize.height)
301+
style:UITableViewStylePlain
302+
asyncDataFetching:YES];
303+
ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new];
304+
305+
tableView.asyncDelegate = dataSource;
306+
tableView.asyncDataSource = dataSource;
307+
308+
XCTestExpectation *reloadDataExpectation = [self expectationWithDescription:@"reloadData"];
309+
[tableView reloadDataWithCompletion:^{
310+
for (int section = 0; section < NumberOfSections; section++) {
311+
for (int row = 0; row < NumberOfRowsPerSection; row++) {
312+
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
313+
ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath];
314+
XCTAssertEqual(node.numberOfLayoutsOnMainThread, 0);
315+
XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width);
316+
}
317+
}
318+
[reloadDataExpectation fulfill];
319+
}];
320+
[self waitForExpectationsWithTimeout:5 handler:^(NSError *error) {
321+
if (error) {
322+
XCTFail(@"Expectation failed: %@", error);
323+
}
324+
}];
325+
326+
NSArray *visibleNodes = [tableView visibleNodes];
327+
XCTAssertGreaterThan(visibleNodes.count, 0);
328+
329+
// Cause table view to enter editing mode.
330+
// Visibile nodes should be re-measured on main thread with the new (smaller) content view width.
331+
// Other nodes are untouched.
332+
XCTestExpectation *relayoutAfterEnablingEditingExpectation = [self expectationWithDescription:@"relayoutAfterEnablingEditing"];
333+
[tableView beginUpdates];
334+
[tableView setEditing:YES];
335+
[tableView endUpdatesAnimated:YES completion:^(BOOL completed) {
336+
for (int section = 0; section < NumberOfSections; section++) {
337+
for (int row = 0; row < NumberOfRowsPerSection; row++) {
338+
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
339+
ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath];
340+
if ([visibleNodes containsObject:node]) {
341+
XCTAssertEqual(node.numberOfLayoutsOnMainThread, 1);
342+
XCTAssertLessThan(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width);
343+
} else {
344+
XCTAssertEqual(node.numberOfLayoutsOnMainThread, 0);
345+
XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width);
346+
}
347+
}
348+
}
349+
[relayoutAfterEnablingEditingExpectation fulfill];
350+
}];
351+
[self waitForExpectationsWithTimeout:5 handler:^(NSError *error) {
352+
if (error) {
353+
XCTFail(@"Expectation failed: %@", error);
354+
}
355+
}];
356+
357+
// Cause table view to leave editing mode.
358+
// Visibile nodes should be re-measured again.
359+
// All nodes should have max constrained width equals to the table view width.
360+
XCTestExpectation *relayoutAfterDisablingEditingExpectation = [self expectationWithDescription:@"relayoutAfterDisablingEditing"];
361+
[tableView beginUpdates];
362+
[tableView setEditing:NO];
363+
[tableView endUpdatesAnimated:YES completion:^(BOOL completed) {
364+
for (int section = 0; section < NumberOfSections; section++) {
365+
for (int row = 0; row < NumberOfRowsPerSection; row++) {
366+
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
367+
ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath];
368+
BOOL visible = [visibleNodes containsObject:node];
369+
XCTAssertEqual(node.numberOfLayoutsOnMainThread, visible ? 2: 0);
370+
XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width);
371+
}
372+
}
373+
[relayoutAfterDisablingEditingExpectation fulfill];
374+
}];
375+
[self waitForExpectationsWithTimeout:5 handler:^(NSError *error) {
376+
if (error) {
377+
XCTFail(@"Expectation failed: %@", error);
378+
}
379+
}];
380+
}
381+
382+
- (void)testRelayoutRowsAfterEditingModeIsChangedAndTheyBecomeVisible
383+
{
384+
CGSize tableViewSize = CGSizeMake(100, 500);
385+
ASTestTableView *tableView = [[ASTestTableView alloc] initWithFrame:CGRectMake(0, 0, tableViewSize.width, tableViewSize.height)
386+
style:UITableViewStylePlain
387+
asyncDataFetching:YES];
388+
ASTableViewFilledDataSource *dataSource = [ASTableViewFilledDataSource new];
389+
390+
tableView.asyncDelegate = dataSource;
391+
tableView.asyncDataSource = dataSource;
392+
393+
XCTestExpectation *reloadDataExpectation = [self expectationWithDescription:@"reloadData"];
394+
[tableView reloadDataWithCompletion:^{
395+
for (int section = 0; section < NumberOfSections; section++) {
396+
for (int row = 0; row < NumberOfRowsPerSection; row++) {
397+
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:row inSection:section];
398+
ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:indexPath];
399+
XCTAssertEqual(node.numberOfLayoutsOnMainThread, 0);
400+
XCTAssertEqual(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width);
401+
}
402+
}
403+
[reloadDataExpectation fulfill];
404+
}];
405+
[self waitForExpectationsWithTimeout:5 handler:^(NSError *error) {
406+
if (error) {
407+
XCTFail(@"Expectation failed: %@", error);
408+
}
409+
}];
410+
411+
// Cause table view to enter editing mode and then scroll to the bottom.
412+
// The last node should be re-measured on main thread with the new (smaller) content view width.
413+
NSIndexPath *lastRowIndexPath = [NSIndexPath indexPathForRow:(NumberOfRowsPerSection - 1) inSection:(NumberOfSections - 1)];
414+
XCTestExpectation *relayoutExpectation = [self expectationWithDescription:@"relayout"];
415+
[tableView beginUpdates];
416+
[tableView setEditing:YES];
417+
[tableView setContentOffset:CGPointMake(0, CGFLOAT_MAX) animated:YES];
418+
[tableView endUpdatesAnimated:YES completion:^(BOOL completed) {
419+
ASTestTextCellNode *node = (ASTestTextCellNode *)[tableView nodeForRowAtIndexPath:lastRowIndexPath];
420+
XCTAssertEqual(node.numberOfLayoutsOnMainThread, 1);
421+
XCTAssertLessThan(node.constrainedSizeForCalculatedLayout.max.width, tableViewSize.width);
422+
[relayoutExpectation fulfill];
423+
}];
424+
[self waitForExpectationsWithTimeout:5 handler:^(NSError *error) {
425+
if (error) {
426+
XCTFail(@"Expectation failed: %@", error);
427+
}
428+
}];
429+
}
430+
293431
@end

0 commit comments

Comments
 (0)