前面
目前項目功能做的差不多了. 需要完善和打磨, 今天需要為所有的 TableView 列表頁沒有數據的時候展示一個友好的提示視圖, 一個一個改太麻煩了. 而且業務邏輯煩雜改起來也不容易. 所以花了點時間寫了一個小東西.在項目中按照項目的規范前綴使用了AN
, 自己提取出來還是按照自己的喜好將前綴改為了XY
.
Demo
優點
- 拖拽即可使用, 無需 import , 對原有代碼無需進行任何修改
- 也可以選擇實現方法, 實現快捷的自定義和完全的自定義
感謝
今天搜索到了這篇文章, 也是我的思路來源.
UITableView沒數據時用戶提示如何做
原理
無入侵
使用 Runtime 交換方法實現對原有代碼無入侵.
創建一個 TableView 的分類, 在 .m 中
#import <objc/runtime.h>
目前我想到的思路是在 reloadData
的時候進行實現, 所以定義一個
xy_reloadData
方法, 然后和原有的 reloadData
方法進行交換.
也就是說:
- 在代碼中所有調用
reloadData
的方法最終會調用我們自定義的xy_reloadData
方法. - 我們
xy_reloadData
方法中, 如果想調用系統的reloadData
方法, 則需要調用xy_reloadData
方法.
+ (void)load {
Method reloadData = class_getInstanceMethod(self, @selector(reloadData));
Method xy_reloadData = class_getInstanceMethod(self, @selector(xy_reloadData));
method_exchangeImplementations(reloadData, xy_reloadData);
}
對 load 方法的描述是
Invoked whenever a class or category is added to the Objective-C runtime; implement this method to perform class-specific behavior upon loading.
當一個 類 或 分類 添加到 Objective-C Runtime 時;實現這個方法來加載后執行特定類的行為。
所以可以實現無需 import 就可以實現加載.
獲取 TableView 的數據量
TableView 有可能有多個 Sections 每個 Section 都有可能有很多 Cell. 所以不能單單判斷第一個 Section 是否有數據. 所以要:
- 獲取 Section 的數量
- 獲取每一個 Section 當中 Cell 的數量
NSInteger numberOfSections = [self numberOfSections];
BOOL havingData = NO;
for (NSInteger i = 0; i < numberOfSections; i++) {
if ([self numberOfRowsInSection:i] > 0) {
havingData = YES;
break;
}
}
這樣這個布爾值 havingData 即是是否有數據的標記.
如何實現 reloadData 完成之后再獲取數量.
因為 TableView 的 reloadData 方法具體實現是異步的.想要獲取到加載完成的狀態有兩種方法
- 使用
layoutIfNeeded
方法 - 獲取
主隊列
異步執行
第一種方法實現代碼為:
[self xy_reloadData];
[self layoutIfNeeded];
//接下來的代碼
這樣的話線程會一直阻塞, 當然我們不希望原來業務代碼中的 reloadData 會阻塞, 直到加載完成之后再繼續執行代碼.
所以我選擇第二種方法
[self xy_reloadData];
dispatch_async(dispatch_get_main_queue(), ^{
//接下來的代碼
});
那么我們 xy_reloadData
中的方法實現為:
- (void)xy_reloadData {
[self xy_reloadData];
// 刷新完成之后檢測數據量
dispatch_async(dispatch_get_main_queue(), ^{
NSInteger numberOfSections = [self numberOfSections];
BOOL havingData = NO;
for (NSInteger i = 0; i < numberOfSections; i++) {
if ([self numberOfRowsInSection:i] > 0) {
havingData = YES;
break;
}
}
[self xy_havingData:havingData];
});
}
展示一個占位視圖
TableView 有一個 backgroundView 的屬性可以很好的勝任這個需求
可以根據 havingData 的狀態來進行賦值
- (void)xy_havingData:(BOOL)havingData {
if (havingData) {
self.backgroundView = nil;
} else {
self.backgroundView = 自定義視圖;
}
}
如何讓控制器自定義視圖
當然我們不滿足于簡簡單單的視圖的需求, 我們希望對應的控制器可以根據自己的需求自定義自己的視圖.
我們最習慣的方法當然是在 TableView 的代理類(通常是控制器)中去處理 TableView 的一些邏輯
那么假設我們希望代理類實現一個方法 xy_noDataView
if ([self.delegate respondsToSelector:@selector(xy_noDataView)]) {
self.backgroundView = [self.delegate performSelector:@selector(xy_noDataView)];
return ;
}
這個地方會有一個編譯警告, 我選擇在 .m 文件中定義一個 protocol 來消除, 我還定義了一些其他的方法來更好的完成我的需求.
/**
消除警告
*/
@protocol XYTableViewDelegate <NSObject>
@optional
- (UIView *)xy_noDataView; // 完全自定義占位圖
- (UIImage *)xy_noDataViewImage; // 使用默認占位圖, 提供一張圖片, 可不提供, 默認不顯示
- (NSString *)xy_noDataViewMessage; // 使用默認占位圖, 提供顯示文字, 可不提供, 默認為暫無數據
- (UIColor *)xy_noDataViewMessageColor; // 使用默認占位圖, 提供顯示文字顏色, 可不提供, 默認為灰色
- (NSNumber *)xy_noDataViewCenterYOffset; // 使用默認占位圖, CenterY 向下的偏移量
@end
之所以沒有在. h 中聲明, 然后要求控制器實現我們的代理, 然后在去實現方法是想盡可能的無侵入, 契約式編程, 按規則實現方法既可以生效.
我希望能實現 拖來即用, 想扔就扔
我還實現了一些簡單的功能. 詳細的可以查看 Demo.
完整的xy_havingData
方法如下:
- (void)xy_havingData:(BOOL)havingData {
// 不需要顯示占位圖
if (havingData) {
self.backgroundView = nil;
return ;
}
// 不需要重復創建
if (self.backgroundView) {
return ;
}
// 自定義了占位圖
if ([self.delegate respondsToSelector:@selector(xy_noDataView)]) {
self.backgroundView = [self.delegate performSelector:@selector(xy_noDataView)];
return ;
}
// 使用自帶的
UIImage *img = nil;
NSString *msg = @"暫無數據";
UIColor *color = [UIColor lightGrayColor];
CGFloat offset = 0;
// 獲取圖片
if ([self.delegate respondsToSelector:@selector(xy_noDataViewImage)]) {
img = [self.delegate performSelector:@selector(xy_noDataViewImage)];
}
// 獲取文字
if ([self.delegate respondsToSelector:@selector(xy_noDataViewMessage)]) {
msg = [self.delegate performSelector:@selector(xy_noDataViewMessage)];
}
// 獲取顏色
if ([self.delegate respondsToSelector:@selector(xy_noDataViewMessageColor)]) {
color = [self.delegate performSelector:@selector(xy_noDataViewMessageColor)];
}
// 獲取偏移量
if ([self.delegate respondsToSelector:@selector(xy_noDataViewCenterYOffset)]) {
offset = [[self.delegate performSelector:@selector(xy_noDataViewCenterYOffset)] floatValue];
}
// 創建占位圖
self.backgroundView = [self xy_defaultNoDataViewWithImage :img message:msg color:color offsetY:offset];
}
實現了, 可以通過完全自定義 View 的方法實現完全自定義, 也可以使用自帶的一些樣式, 指定圖片, 文字, 文字顏色, 以及位置偏移量, 當然其中任何一個都是可以不指定的, 使用默認設定.
界面的一些代碼
/**
默認的占位圖
*/
- (UIView *)xy_defaultNoDataViewWithImage:(UIImage *)image message:(NSString *)message color:(UIColor *)color offsetY:(CGFloat)offset {
// 計算位置, 垂直居中, 圖片默認中心偏上.
CGFloat sW = self.bounds.size.width;
CGFloat cX = sW / 2;
CGFloat cY = self.bounds.size.height * (1 - 0.618) + offset;
CGFloat iW = image.size.width;
CGFloat iH = image.size.height;
// 圖片
UIImageView *imgView = [[UIImageView alloc] init];
imgView.frame = CGRectMake(cX - iW / 2, cY - iH / 2, iW, iH);
imgView.image = image;
// 文字
UILabel *label = [[UILabel alloc] init];
label.font = [UIFont systemFontOfSize:17];
label.textColor = color;
label.text = message;
label.textAlignment = NSTextAlignmentCenter;
label.frame = CGRectMake(0, CGRectGetMaxY(imgView.frame) + 24, sW, label.font.lineHeight);
// 視圖
XYNoDataView *view = [[XYNoDataView alloc] init];
[view addSubview:imgView];
[view addSubview:label];
// 實現跟隨 TableView 滾動
[view addObserver:self forKeyPath:kXYNoDataViewObserveKeyPath options:NSKeyValueObservingOptionNew context:nil];
return view;
}
細節優化
如何實現頁面加載的時候不展示占位圖
在 TableView 顯示到界面上時, 相當于調用了 reloadData
方法, 所以按照我們目前的邏輯會先展示一個占位圖, 然后數據加載完成后, 再次調用 reloadData
方法以隱藏占位圖.
數據加載之前, 我們肯定不希望展示無數據的占位圖, 所以可以忽略掉第一次調用 reloadData
的處理, 在 xy_reloadData
方法中增加如下校驗在 [self xy_reloadData];
之后, 如果沒有加載完成數據時, 我們默認當做有數據去處理, 即相當于占位圖不顯示. 然后記錄一下, 數據已經加載完成了
// 忽略第一次加載
if (![self isInitFinish]) {
[self xy_havingData:YES];
[self setIsInitFinish:YES];
return ;
}
為 TableView 綁定一個屬性用來記錄是否已經加載完
/// 加載完數據的標記屬性名
static NSString * const kXYTableViewPropertyInitFinish = @"kXYTableViewPropertyInitFinish";
/**
設置已經加載完成數據了
*/
- (void)setIsInitFinish:(BOOL)finish {
objc_setAssociatedObject(self, &kXYTableViewPropertyInitFinish, @(finish), OBJC_ASSOCIATION_ASSIGN);
}
/**
是否已經加載完成數據
*/
- (BOOL)isInitFinish {
id obj = objc_getAssociatedObject(self, &kXYTableViewPropertyInitFinish);
return [obj boolValue];
}
滾動時如何讓占位圖跟隨 TableView 的滾動而滾動.
因為我們的占位圖是賦值在 TableView 的 backgroundView
屬性上的, 相當于增加到了 TableView 上, 通過調試可以發現, 在 TableView 滾動 contentOffset
改變時, backgroundView
的frame.origin.y
也是同步改變的, 所以我們看起來無論 TableView 怎么滾動占位圖都是無動于衷的, 如果我們想讓占位圖跟隨滾動的話, 只要取消掉backgroundView
的 frame.origin.y
的同步更新就好了, 也就是說要保證 frame.origin.y
的值一直為0.
我這里沒有找到更好的辦法, 暫時使用 KVO 來實現, 記得 View 銷毀的時候要移除 KVO 的監聽, 詳細實現可以看 Demo 啦...
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:kXYNoDataViewObserveKeyPath]) {
/**
在 TableView 滾動 ContentOffset 改變時, 會同步改變 backgroundView 的 frame.origin.y
可以實現, backgroundView 位置相對于 TableView 不動, 但是我們希望
backgroundView 跟隨 TableView 的滾動而滾動, 只能強制設置 frame.origin.y 永遠為 0
兼容 MJRefresh
*/
CGRect frame = [[change objectForKey:NSKeyValueChangeNewKey] CGRectValue];
if (frame.origin.y != 0) {
frame.origin.y = 0;
self.backgroundView.frame = frame;
}
}
}
如果不想顯示占位圖怎么辦?
在對應的控制器實現如下方法即可
- (NSString *)xy_noDataViewMessage {
return @"";
}
關于分割線
在我上面提到的那篇文章中. 在修改 backgroundView 屬性的同時修改了 TableView 的 separatorStyle
屬性, 沒數據的時候將分割線取消掉, 有數據的時候在添加上, 可是我在項目中使用的 TableView 的分割線 separatorStyle 風格不一. 所以我沒有修改分割線屬性, 如果想讓 TableView 沒有數據的時候隱藏分割線, 可以看我的 Demo 在對應的控制器添加這樣一行代碼即可.
self.tableView.tableFooterView = [UIView new];
最后
CollectionView 同理, 代碼復制一遍, 將獲取數據量的地方, 獲取每個 Section 中 Cell 的數量的 numberOfRowsInSection
方法改為 numberOfItemsInSection
即可使用.
菜鳥一枚, 如果有大神不吝賜教, 必將感激不盡.