Empty data set for ASDisplayNode
// | |
// ASDisplayNode+BFREmptyView.m | |
// Buffer | |
// | |
// Created by Jordan Morgan on 10/27/17. | |
// | |
#import "ASDisplayNode+BFREmptyView.h" | |
#import <objc/runtime.h> | |
#import <AsyncDisplayKit/AsyncDisplayKit.h> | |
static NSMutableDictionary *implementationLookupTable; | |
static BOOL experimentEnableGesture; | |
@interface ASDisplayNode () | |
void swizzleInstanceUpdateMethods(id self); | |
void swizzledBatchUpdates(id self, SEL _cmd, void(^updates)(), void(^completion)(BOOL)); | |
void swizzledReloadDataWithCompletion(id self, SEL _cmd, void(^completion)()); | |
@property (strong, nonatomic, nullable, readonly) UIView *emptyView; | |
@property (strong, nonatomic, nullable, readonly) UIPanGestureRecognizer *pan; | |
@end | |
@implementation ASDisplayNode (BFREmptyView) | |
#pragma mark - Getters/Setters | |
static char const * BFREmptyDataViewDataSourcePropertyKey = "BFREmptyDataViewDataSourcePropertyKey"; | |
static char const * BFREmptyDataViewPropertyKey = "BFREmptyDataViewPropertyKey"; | |
static char const * BFREmptyDataViewPanPropertyKey = "BFREmptyDataViewPanPropertyKey"; | |
- (id <BFREmptyDataViewDataSource>)emptyDataViewDataSourceDelegate { | |
return objc_getAssociatedObject(self, BFREmptyDataViewDataSourcePropertyKey); | |
} | |
- (void)setEmptyDataViewDataSourceDelegate:(id <BFREmptyDataViewDataSource>)emptyDataSource { | |
objc_setAssociatedObject(self, BFREmptyDataViewDataSourcePropertyKey, emptyDataSource, OBJC_ASSOCIATION_RETAIN_NONATOMIC); | |
if ([self respondsToSelector:@selector(performBatchUpdates:completion:)]) { | |
if ([self isKindOfClass:[ASCollectionNode class]]) { | |
static dispatch_once_t onceCollectionNodeToken; | |
dispatch_once(&onceCollectionNodeToken, ^{ | |
swizzleInstanceUpdateMethods(self); | |
}); | |
} else if ([self isKindOfClass:[ASTableNode class]]) { | |
static dispatch_once_t onceTableNodeToken; | |
dispatch_once(&onceTableNodeToken, ^{ | |
swizzleInstanceUpdateMethods(self); | |
}); | |
} | |
} | |
// Ensuring the empty view stays centered is a nightware with offsets. For that reason we add it to the view controller's view, | |
// Not the collection node. This mimics the user scrollingn down on the collection node during pull to refresh. | |
if (experimentEnableGesture) { | |
// We will implement this later if we want it to scroll with the refresh | |
self.pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)]; | |
self.pan.delegate = self; | |
[self.view addGestureRecognizer:self.pan]; | |
} | |
} | |
- (UIView *)emptyView { | |
return objc_getAssociatedObject(self, BFREmptyDataViewPropertyKey); | |
} | |
- (void)setEmptyView:(UIView *)view { | |
objc_setAssociatedObject(self, BFREmptyDataViewPropertyKey, view, OBJC_ASSOCIATION_RETAIN_NONATOMIC); | |
} | |
- (UIPanGestureRecognizer *)pan { | |
return objc_getAssociatedObject(self, BFREmptyDataViewPanPropertyKey); | |
} | |
- (void)setPan:(UIPanGestureRecognizer *)pan { | |
objc_setAssociatedObject(self, BFREmptyDataViewPanPropertyKey, pan, OBJC_ASSOCIATION_RETAIN_NONATOMIC); | |
} | |
#pragma mark - Swizzle | |
void swizzleInstanceUpdateMethods(id self) { | |
if (!implementationLookupTable) implementationLookupTable = [[NSMutableDictionary alloc] initWithCapacity:2]; | |
if ([self isKindOfClass:[ASCollectionNode class]]) { | |
// Swizzle batchUpdates for collection node which is all we'll need | |
Method methodBatchUpdates = class_getInstanceMethod([self class], @selector(performBatchUpdates:completion:)); | |
IMP performBatchUpdates_orig = method_setImplementation(methodBatchUpdates, (IMP)swizzledBatchUpdates); | |
[implementationLookupTable setValue:[NSValue valueWithPointer:performBatchUpdates_orig] forKey:[self instanceLookupKeyForSelector:@selector(performBatchUpdates:completion:)]]; | |
} else if ([self isKindOfClass:[ASTableNode class]]) { | |
// Swizzle batchUpdates and reloadData for table node since we'll need both | |
Method methodBatchUpdates = class_getInstanceMethod([self class], @selector(performBatchUpdates:completion:)); | |
IMP performBatchUpdates_orig = method_setImplementation(methodBatchUpdates, (IMP)swizzledBatchUpdates); | |
[implementationLookupTable setValue:[NSValue valueWithPointer:performBatchUpdates_orig] forKey:[self instanceLookupKeyForSelector:@selector(performBatchUpdates:completion:)]]; | |
Method methodReloadDataWithCompletion = class_getInstanceMethod([self class], @selector(reloadDataWithCompletion:)); | |
IMP performReloadDataWithCompletion_orig = method_setImplementation(methodReloadDataWithCompletion, (IMP)swizzledReloadDataWithCompletion); | |
[implementationLookupTable setValue:[NSValue valueWithPointer:performReloadDataWithCompletion_orig] forKey:[self instanceLookupKeyForSelector:@selector(reloadDataWithCompletion:)]]; | |
} | |
} | |
void swizzledBatchUpdates(id self, SEL _cmd, void(^updates)(), void(^completion)(BOOL)) { | |
// Get the original performBatchUpdates | |
NSValue *impValue = [implementationLookupTable valueForKey:[self instanceLookupKeyForSelector:_cmd]]; | |
IMP performBatchUpdates_orig = [impValue pointerValue]; | |
// Call OG implementation for whichever instance we have | |
if (performBatchUpdates_orig) { | |
((void(*)(id,SEL, void(^updates)(), void(^completion)(BOOL)))performBatchUpdates_orig)(self,_cmd, updates, completion); | |
} | |
// Now trigger empty view | |
[self showEmptyViewIfNeeded]; | |
} | |
void swizzledReloadDataWithCompletion(id self, SEL _cmd, void(^completion)()) { | |
// Get the original reloadDataWithCompletion | |
NSValue *impValue = [implementationLookupTable valueForKey:[self instanceLookupKeyForSelector:_cmd]]; | |
IMP performReloadDataWithCompletion_orig = [impValue pointerValue]; | |
// Call OG implementation for table node | |
if (performReloadDataWithCompletion_orig) { | |
((void(*)(id,SEL, void(^completion)()))performReloadDataWithCompletion_orig)(self,_cmd, completion); | |
} | |
// Now trigger empty view | |
[self showEmptyViewIfNeeded]; | |
} | |
- (NSString *)instanceLookupKeyForSelector:(SEL)selector { | |
return [NSString stringWithFormat:@"%@-%@", NSStringFromClass([self class]), NSStringFromSelector(selector)]; | |
} | |
#pragma mark - Empty Data View Hide/Show | |
- (void)showEmptyViewIfNeeded { | |
BOOL dataIsEmpty; | |
NSInteger sections = 1; | |
NSInteger totalItems = 0; | |
if ([self isKindOfClass:[ASCollectionNode class]]) { | |
id <ASCollectionDataSource> nodeDataSource = ((ASCollectionNode *)self).dataSource; | |
if ([nodeDataSource respondsToSelector:@selector(numberOfSectionsInCollectionNode:)]) { | |
sections = [nodeDataSource numberOfSectionsInCollectionNode:(ASCollectionNode *)self]; | |
} | |
if ([nodeDataSource respondsToSelector:@selector(collectionNode:numberOfItemsInSection:)]) { | |
for (NSInteger sectionIDX = 0; sectionIDX < sections; sectionIDX++) { | |
totalItems += [nodeDataSource collectionNode:(ASCollectionNode *)self numberOfItemsInSection:sectionIDX]; | |
} | |
} | |
} else if ([self isKindOfClass:[ASTableNode class]]) { | |
id <ASTableDataSource> nodeDataSource = ((ASTableNode *)self).dataSource; | |
if ([nodeDataSource respondsToSelector:@selector(numberOfSectionsInTableNode:)]) { | |
sections = [nodeDataSource numberOfSectionsInTableNode:(ASTableNode *)self]; | |
} | |
if ([nodeDataSource respondsToSelector:@selector(tableNode:numberOfRowsInSection:)]) { | |
for (NSInteger sectionIDX = 0; sectionIDX < sections; sectionIDX++) { | |
totalItems += [nodeDataSource tableNode:(ASTableNode *)self numberOfRowsInSection:sectionIDX]; | |
} | |
} | |
} | |
dataIsEmpty = totalItems <= 0; | |
if (self.emptyDataViewDataSourceDelegate != nil && [NSThread currentThread].isMainThread) { | |
if (dataIsEmpty) { | |
if (self.emptyView.superview) [self.emptyView removeFromSuperview]; | |
self.emptyView = [self.emptyDataViewDataSourceDelegate viewForEmptyData]; | |
if (self.emptyView == nil) return; | |
// Add empty view | |
CGFloat offset = 0; | |
if ([self.emptyDataViewDataSourceDelegate respondsToSelector:@selector(offsetForEmptyView)]) { | |
offset = [self.emptyDataViewDataSourceDelegate offsetForEmptyView]; | |
} | |
if (self.closestViewController == nil) { | |
DDLogWarn(@"Closet view controller was nil when attempting to display empty data set."); | |
return; | |
} | |
[self.closestViewController.view addSubview:self.emptyView]; | |
[self.emptyView mas_makeConstraints:^(MASConstraintMaker *make) { | |
if (@available(iOS 11.0, *)) { | |
make.width.equalTo(self.closestViewController.view.mas_safeAreaLayoutGuideWidth); | |
make.height.equalTo(self.closestViewController.view.mas_safeAreaLayoutGuideHeight); | |
make.centerX.equalTo(self.closestViewController.view.mas_safeAreaLayoutGuideCenterX); | |
make.centerY.equalTo(self.closestViewController.view.mas_safeAreaLayoutGuideCenterY).with.offset(offset); | |
} else { | |
make.width.equalTo(self.closestViewController.view.mas_width); | |
make.height.equalTo(self.closestViewController.view.mas_height); | |
make.centerX.equalTo(self.closestViewController.view.mas_centerX); | |
make.centerY.equalTo(self.closestViewController.view.mas_centerY).with.offset(offset); | |
} | |
}]; | |
} else { | |
[self.emptyView removeFromSuperview]; | |
} | |
} | |
} | |
#pragma mark - UIGestureRecognizer Delegate | |
// Pans the empty view down, making it appear like it's part of the collectionnode's content view | |
- (void)handlePan:(UIPanGestureRecognizer *)pan { | |
CGPoint pointsToMove = [pan translationInView:self.emptyView]; | |
[self.emptyView setCenter:CGPointMake(self.emptyView.center.x, self.emptyView.center.y + pointsToMove.y)]; | |
[pan setTranslation:CGPointZero inView:self.emptyView]; | |
if (pan.state == UIGestureRecognizerStateEnded) { | |
[self.emptyView setCenter:self.closestViewController.view.center]; | |
} | |
} | |
// This allows users to still scroll the collection node to pull to refresh | |
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer { | |
return YES; | |
} | |
@end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment