可折疊的分組列表在日常的開發中并不少見,基本原理不外乎利用tableview的
- (void)reloadSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation
搭配
- (NSInteger)numberOfRowsInSection:(NSInteger)section
返回0即可讓section展示收縮的效果。具體的實現可參考iOS UITableview實現展開折疊效果
最近有一個需求正好是實現一個可折疊的tableview,但是服務端那邊的接口遲遲不能確定,UI的設計稿卻已經出來了,大概長這樣:
點擊這個sectionheader需要收起整個組的cell。我擦嘞,各種小圖標紅的綠的藍的,文字還有各種狀態,置灰態、正常態等,是不是還要寫一個跑馬燈啊?
該怎么辦呢?
model層的前后分離
UI層的業務邏輯和后端接口產生依賴是一個很不好的現象。如果能分離前后端的model,代碼的可拓展性將大大加強,大概的設計邏輯長下面這樣。
后端接口model表示根據接口字段抽象出的model,完全根據后端的接口字段確定,大概會長這樣:
@interface xxxmodel : NSObject
...
@property (nonatomic, copy) str1;
@property (nonatomic, assign) num1;
...
@end
@implementation
@end
UI層的model表示決定UI展示樣式的model。中間的adapter就是一個轉換器,將接口Model轉換成UI層的model,同常這里是充滿膠水代碼的地方,不知道讀者有沒有聽過這么一句話:什么是設計模式,設計模式就是讓代碼里面所有的屎都集中在一個茅坑里,讓其他地方都干干凈凈。沒錯,如果你也沒聽過,那么這句話就是我說的- -,
在后端接口沒確定的情況下我們可以先根據設計稿長的樣式開始先著手UI層model的開發,注意這里的UI層model并不是嚴格意義上的MVVM中的viewModel。
這里可以將每一個section抽象成一個stage,將section下面的每個cell抽象成一個item。
比如這里每個cell左上角的有一個小圖標,從設計稿來看它有可能是已結業、未結業、已完成或者不顯示,item的model就可以這么寫:
typedef NS_ENUM(NSUInteger, CompositeSubItemStatus) {
/**已結業*/
CompositeSubItemCompleted = 0,
/**未結業*/
CompositeSubItemNotSatisfied,
/**已完成*/
CompositeSubItemSubmit,
/**無狀態*/
CompositeSubItemStatusNone
};
@interface CompositeSubItemModel : NSObject
...
/**是否結業、完成狀態, default statusNone*/
@property (nonatomic, assign) CompositeSubItemStatus status;
...
@end
每個cell持有一個itemModel根據itemModel中的這個字段就能判斷該怎么正確顯示。以此類推cell中其他的UI對應不同的itemModel里面的字段,這里我想說明的是設計稿中一眼能看出有兩個大類的cell,一個帶body文字的,一個不帶。
這里當然可以枚舉一個字段去做對應的區分就像下面這樣:
typedef NS_ENUM(NSUInteger, CompositeSubItemCellType) {
CompositeSubItemCellContainBody = 0,
CompositeSubItemCellNoBody = 0,
};
但是最佳實踐應該是定義一個body的字符串,cell根據body是否有值去展示不同的cell(可以認為徹底貫徹數據驅動UI的理念)
@property (nonatomic, copy) NSString *body;
好,現在你已經根據UI的樣式抽象出itemModel和stageModel了。這樣子設計除了能讓你在不知道后端接口的情況下提前開始寫代碼之外有什么好處呢?若以后有其他的場景需要這個頁面但是接口又不完全一樣的話,只需要新寫一個adapter就OK了,這塊的UI完全實現了和后端業務接口的解耦。
數據驅動UI
仔細看設計稿,發現每一個section的最后一個cell的最底下的分割線都會隱藏,right?一般的實現姿勢大概是這樣:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
...
if (indexPath.row == [tableView numberOfRowsInSection:indexPath.section]-1) {
cell.separateLine.hidden = YES;
}
return cell;
}
but,好像哪里不對勁,不是說好的數據驅動UI嗎?所以直接在vc里面去改變cell的顯示狀態是違背這個理念的。那么正確的姿勢應該是讓cell根據model的狀態自行去判斷是否應該隱藏分割線:
//CompositeSubItemModel.h
@interface CompositeSubItemModel : NSObject
...
@property (nonatomic, weak) CompositeCourseStageModel *parentStage;
...
@end
//CompositeSubItemBaseCell.m
@implementation CompositeSubItemBaseCell
- (void)setItemModel:(JYVCompositeSubItemModel *)itemModel {
...
if (_itemModel == _itemModel.parentStage.items.lastObject) {
self.separateLine.hidden = YES;
} else {
self.separateLine.hidden = NO;
}
...
- }
@end
這樣做只需要在adapter里面生成UI層model的時候賦值好parentStage這個字段就好了,但是真的需要組件的使用者在adapter里面去賦值這個字段嗎?我們回想一下cocoa里面UIView的API:
UIView *parent = [UIView new];
UIView *son = [UIView new];
[parent addSubview son];
這時候son.superview已經能指向parent了。嗯哼,所以我們要盡量保持使用的簡潔性,下面才是正確的姿勢:
@implementation CompositeCourseStageModel
- (void)setStageHeader:(CompositeStageHeaderModel *)stageHeader {
_stageHeader = stageHeader;
_stageHeader.parentStage = self;
}
- (void)setItems:(NSArray<CompositeSubItemModel *> *)items {
_items = items.copy;
for (CompositeSubItemModel *item in items) {
item.parentStage = self;
}
}
@end
所以在設計組件或者框架的時候,一定要參考cocoa的API設計理念。下面一起來實現這個展開收起的功能,section展開收起的狀態我們用一個字段fold來保存。因為section的header在展開收起時候的UI是有不同的,所以stageModel和headerModel都得持有這個狀態:
@interface CompositeCourseStageModel: NSObject
@property (nonatomic, assign) BOOL fold;
@end
@interface CompositeStageHeaderModel: NSObject
@property (nonatomic, assign) BOOL fold;
@end
oh no,明明是表示的是一個同一個stage的展開或者收起狀態,為什么需要用兩個字段?這個fold的狀態站在數據層的角度來看就是最細粒度的一個標志位,若我們在實現的model里面用了兩個或者多個字段來表示,則我們必須維護這兩個字段的一致性,這在以后的維護上是很麻煩一件事情。所以干脆不要headerModel里面的這個字段吧,畢竟headerModel有一個parentStage的指針
header.parentStage.fold
這樣就解決了多個標志位帶來的麻煩。不錯,這是一種解決方案,但這仍舊不是最優雅的實現。我還是希望用header.fold怎么辦,這樣子代碼的可讀性是最高的。
@interface CompositeStageHeaderModel : NSObject
@property (nonatomic, assign) BOOL fold;
@end
@implementation CompositeStageHeaderModel
- (BOOL)fold {
return self.parentStage.fold;
}
- (void)setFold:(BOOL)fold {
self.parentStage.fold = fold;
}
@end
header并沒有持有一個_fold的實例變量,而是將fold briding到parentStage上去,這樣底層仍舊只有stageModel持有一個_fold這個實例變量,但是對于使用者來說,既可以訪問stage.fold也可以訪問header.fold同時不同解決多個標志位帶來的一致性問題,so elegant!
說了這么多,是時候抽象出一張數據驅動UI的腦圖了:
核心是數據,數據只接受網絡請求和用戶操作的改變,然后去改變UI,UI的改動只依賴于數據。遵照這種設計,還有一個額外的好處就是可測試性大大加強,輸入不同的數據測試UI是否正確顯示。這一點和函數式編程中"純函數式"的函數的概念不謀而合,一個函數只接收一個確定的輸入產生一個輸出,輸入確定的話輸出就一定是可預測的。
可拓展性
突然有一天,策劃跑過來找你,開發大大我這里希望有一種cell能不展開收縮誒,比如最底下能有一個播放視頻的cell。
好好思考下我們抽象出的stage和item的模型,不能展開收縮的cell其實就是一個fold==NO的stage里面只有一個item,而且這個stage的header不能改變fold的狀態,剩下的工作只要根據視覺稿自定義下這種類型的stage的sectionheader就好了。
突然有一天,策劃跑過來找你,開發大大我這里希望有一個三級聯動的section收縮展開誒。
對于界面UI來說,不管幾級的展開收起都可以最終轉化成section和下面的cell的個數的變化,二級的聯動的展開收起表現為一次只有一個section的有變化,多級聯動的展開收起是一次有多個section的變化,仔細思考下這句話,right?下面圖示以三級的聯動為例:
我們上面的數據結構仍舊可以保留,只需要在最外面新建一個segment的model持有一個數組的stage。在tableview生成sectionheader的時候需要判斷生成的是哪種model的header,是segment的還是stage的。每當用戶點擊發生的時候去改變對應的fold標志位的狀態,然后reload。在tableview的代理:
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
//通過section計算出對應的segment和stage
...
if(isSegment) return 0;//恰好是segment最上層,只顯示一個section header
//正常的stage
if (_dataSource[segment].fold || _dataSource[segment][stage].fold) { return 0; }
return _dataSource[segment][stage].items.count;
}
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
//通過section計算出對應的segment和stage
...
if(isSegment) return [SegmentHeaderView new];//segment必然需要展示header
if(!isSegment && !_datasource[segment].fold) return [Stageheader new];//stage的header在segment展開的狀態才展示
return nil;
}
上面貼出了偽代碼,有興趣的讀者可以自行去實現。
結語
- 日常的工作不可避免的要遇到各種寫界面的需求,數據驅動UI的思想能使寫出的代碼更具可拓展性和可讀性,易于單元測試。同時數據驅動UI的思路能將主要工作集中在對model層的抽象上,對我們以后寫其他的框架也是很有好處的。
- model層的抽象要注意控制model的粒度,盡量減少不同字段的重復表意,重讀表意的字段將為以后的維護帶來很大的麻煩。
- 接口的設計要盡量遵循cocoa的設計風格。這里面接口的設計又是有大文章可以研究,即要控制好接口的功能粒度,粒度太大的接口一次性完成了很多工作但是是去了靈活性,粒度太小的接口會讓使用者失去便利性(如實現一個功能需要組合調用幾個接口)。同時接口設計要為以后的拓展預留出空間又要避免過度設計。設計是永遠需要鉆研的一門藝術,建議讀者多研究一些出名開源庫的源碼。
- 最后,數據驅動UI換成另外一種表達就是"UI只是數據的一種表現形式" (這個逼就要裝不下去了,逃。。)
- 數據驅動UI再往深處探究下就是以ReactiveCocoa為代表的函數式編程了,有興趣的讀者可以參考王巍大神的這篇單向數據流動的函數式 View Controller