從設計一個可折疊tableview組件談談數(shù)據(jù)驅動UI

可折疊的分組列表在日常的開發(fā)中并不少見,基本原理不外乎利用tableview的
- (void)reloadSections:(NSIndexSet *)sections withRowAnimation:(UITableViewRowAnimation)animation
搭配
- (NSInteger)numberOfRowsInSection:(NSInteger)section
返回0即可讓section展示收縮的效果烹困。具體的實現(xiàn)可參考iOS UITableview實現(xiàn)展開折疊效果

最近有一個需求正好是實現(xiàn)一個可折疊的tableview玄妈,但是服務端那邊的接口遲遲不能確定,UI的設計稿卻已經(jīng)出來了,大概長這樣:

設計稿.jpg

點擊這個sectionheader需要收起整個組的cell髓梅。我擦嘞拟蜻,各種小圖標紅的綠的藍的,文字還有各種狀態(tài)枯饿,置灰態(tài)酝锅、正常態(tài)等,是不是還要寫一個跑馬燈吧莘健搔扁?



該怎么辦呢?

model層的前后分離

UI層的業(yè)務邏輯和后端接口產(chǎn)生依賴是一個很不好的現(xiàn)象蟋字。如果能分離前后端的model稿蹲,代碼的可拓展性將大大加強,大概的設計邏輯長下面這樣鹊奖。

前后端model分離.jpg

后端接口model表示根據(jù)接口字段抽象出的model,完全根據(jù)后端的接口字段確定苛聘,大概會長這樣:

@interface xxxmodel : NSObject
...
@property (nonatomic, copy) str1;
@property (nonatomic, assign) num1;
...
@end

@implementation
@end

UI層的model表示決定UI展示樣式的model。中間的adapter就是一個轉換器忠聚,將接口Model轉換成UI層的model焰盗,同常這里是充滿膠水代碼的地方,不知道讀者有沒有聽過這么一句話:什么是設計模式咒林,設計模式就是讓代碼里面所有的屎都集中在一個茅坑里熬拒,讓其他地方都干干凈凈。沒錯垫竞,如果你也沒聽過澎粟,那么這句話就是我說的- -,

在后端接口沒確定的情況下我們可以先根據(jù)設計稿長的樣式開始先著手UI層model的開發(fā)蛀序,注意這里的UI層model并不是嚴格意義上的MVVM中的viewModel。

這里可以將每一個section抽象成一個stage活烙,將section下面的每個cell抽象成一個item徐裸。

uiModel.jpg

比如這里每個cell左上角的有一個小圖標,從設計稿來看它有可能是已結業(yè)啸盏、未結業(yè)重贺、已完成或者不顯示,item的model就可以這么寫:

typedef NS_ENUM(NSUInteger, CompositeSubItemStatus) {
    /**已結業(yè)*/
    CompositeSubItemCompleted = 0,
    /**未結業(yè)*/
    CompositeSubItemNotSatisfied,
    /**已完成*/
    CompositeSubItemSubmit,
    /**無狀態(tài)*/
    CompositeSubItemStatusNone
};

@interface CompositeSubItemModel : NSObject
...
/**是否結業(yè)回懦、完成狀態(tài), default statusNone*/
@property (nonatomic, assign) CompositeSubItemStatus status;
...
@end

每個cell持有一個itemModel根據(jù)itemModel中的這個字段就能判斷該怎么正確顯示气笙。以此類推cell中其他的UI對應不同的itemModel里面的字段,這里我想說明的是設計稿中一眼能看出有兩個大類的cell怯晕,一個帶body文字的潜圃,一個不帶。

設計稿_標注.jpg

這里當然可以枚舉一個字段去做對應的區(qū)分就像下面這樣:

typedef NS_ENUM(NSUInteger, CompositeSubItemCellType) {
    CompositeSubItemCellContainBody = 0,
    CompositeSubItemCellNoBody = 0,
};

但是最佳實踐應該是定義一個body的字符串舟茶,cell根據(jù)body是否有值去展示不同的cell(可以認為徹底貫徹數(shù)據(jù)驅動UI的理念)

@property (nonatomic, copy) NSString *body;

好谭期,現(xiàn)在你已經(jīng)根據(jù)UI的樣式抽象出itemModel和stageModel了。這樣子設計除了能讓你在不知道后端接口的情況下提前開始寫代碼之外有什么好處呢吧凉?若以后有其他的場景需要這個頁面但是接口又不完全一樣的話隧出,只需要新寫一個adapter就OK了,這塊的UI完全實現(xiàn)了和后端業(yè)務接口的解耦阀捅。

數(shù)據(jù)驅動UI

仔細看設計稿胀瞪,發(fā)現(xiàn)每一個section的最后一個cell的最底下的分割線都會隱藏,right也搓?一般的實現(xiàn)姿勢大概是這樣:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
   ...
   if (indexPath.row == [tableView numberOfRowsInSection:indexPath.section]-1) {
        cell.separateLine.hidden = YES;
    }
    return cell;
}

but,好像哪里不對勁赏廓,不是說好的數(shù)據(jù)驅動UI嗎?所以直接在vc里面去改變cell的顯示狀態(tài)是違背這個理念的傍妒。那么正確的姿勢應該是讓cell根據(jù)model的狀態(tài)自行去判斷是否應該隱藏分割線:

//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已經(jīng)能指向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設計理念嗦玖。下面一起來實現(xiàn)這個展開收起的功能患雇,section展開收起的狀態(tài)我們用一個字段fold來保存。因為section的header在展開收起時候的UI是有不同的宇挫,所以stageModel和headerModel都得持有這個狀態(tài):

@interface CompositeCourseStageModel: NSObject
@property (nonatomic, assign) BOOL fold;
@end

@interface CompositeStageHeaderModel: NSObject
@property (nonatomic, assign) BOOL fold;
@end

oh no,明明是表示的是一個同一個stage的展開或者收起狀態(tài)苛吱,為什么需要用兩個字段?這個fold的狀態(tài)站在數(shù)據(jù)層的角度來看就是最細粒度的一個標志位器瘪,若我們在實現(xiàn)的model里面用了兩個或者多個字段來表示翠储,則我們必須維護這兩個字段的一致性绘雁,這在以后的維護上是很麻煩一件事情。所以干脆不要headerModel里面的這個字段吧援所,畢竟headerModel有一個parentStage的指針

header.parentStage.fold

這樣就解決了多個標志位帶來的麻煩庐舟。不錯,這是一種解決方案住拭,但這仍舊不是最優(yōu)雅的實現(xiàn)挪略。我還是希望用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!
說了這么多趴俘,是時候抽象出一張數(shù)據(jù)驅動UI的腦圖了:

數(shù)據(jù)驅動UI.jpg

核心是數(shù)據(jù),數(shù)據(jù)只接受網(wǎng)絡請求和用戶操作的改變奏赘,然后去改變UI寥闪,UI的改動只依賴于數(shù)據(jù)。遵照這種設計磨淌,還有一個額外的好處就是可測試性大大加強疲憋,輸入不同的數(shù)據(jù)測試UI是否正確顯示。這一點和函數(shù)式編程中"純函數(shù)式"的函數(shù)的概念不謀而合梁只,一個函數(shù)只接收一個確定的輸入產(chǎn)生一個輸出缚柳,輸入確定的話輸出就一定是可預測的。

可拓展性

突然有一天搪锣,策劃跑過來找你秋忙,開發(fā)大大我這里希望有一種cell能不展開收縮誒,比如最底下能有一個播放視頻的cell构舟。

好好思考下我們抽象出的stage和item的模型灰追,不能展開收縮的cell其實就是一個fold==NO的stage里面只有一個item,而且這個stage的header不能改變fold的狀態(tài)狗超,剩下的工作只要根據(jù)視覺稿自定義下這種類型的stage的sectionheader就好了弹澎。

突然有一天,策劃跑過來找你努咐,開發(fā)大大我這里希望有一個三級聯(lián)動的section收縮展開誒苦蒿。



對于界面UI來說,不管幾級的展開收起都可以最終轉化成section和下面的cell的個數(shù)的變化渗稍,二級的聯(lián)動的展開收起表現(xiàn)為一次只有一個section的有變化佩迟,多級聯(lián)動的展開收起是一次有多個section的變化溃肪,仔細思考下這句話,right音五?下面圖示以三級的聯(lián)動為例:

三級聯(lián)動.jpg

我們上面的數(shù)據(jù)結構仍舊可以保留惫撰,只需要在最外面新建一個segment的model持有一個數(shù)組的stage。在tableview生成sectionheader的時候需要判斷生成的是哪種model的header躺涝,是segment的還是stage的厨钻。每當用戶點擊發(fā)生的時候去改變對應的fold標志位的狀態(tài),然后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展開的狀態(tài)才展示
    return nil;
}

上面貼出了偽代碼,有興趣的讀者可以自行去實現(xiàn)苍蔬。

結語

  1. 日常的工作不可避免的要遇到各種寫界面的需求诱建,數(shù)據(jù)驅動UI的思想能使寫出的代碼更具可拓展性和可讀性,易于單元測試碟绑。同時數(shù)據(jù)驅動UI的思路能將主要工作集中在對model層的抽象上俺猿,對我們以后寫其他的框架也是很有好處的。
  2. model層的抽象要注意控制model的粒度格仲,盡量減少不同字段的重復表意押袍,重讀表意的字段將為以后的維護帶來很大的麻煩。
  3. 接口的設計要盡量遵循cocoa的設計風格凯肋。這里面接口的設計又是有大文章可以研究谊惭,即要控制好接口的功能粒度,粒度太大的接口一次性完成了很多工作但是是去了靈活性侮东,粒度太小的接口會讓使用者失去便利性(如實現(xiàn)一個功能需要組合調(diào)用幾個接口)圈盔。同時接口設計要為以后的拓展預留出空間又要避免過度設計。設計是永遠需要鉆研的一門藝術悄雅,建議讀者多研究一些出名開源庫的源碼驱敲。
  4. 最后,數(shù)據(jù)驅動UI換成另外一種表達就是"UI只是數(shù)據(jù)的一種表現(xiàn)形式" (這個逼就要裝不下去了煤伟,逃癌佩。。)
  5. 數(shù)據(jù)驅動UI再往深處探究下就是以ReactiveCocoa為代表的函數(shù)式編程了便锨,有興趣的讀者可以參考王巍大神的這篇單向數(shù)據(jù)流動的函數(shù)式 View Controller
最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末围辙,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子放案,更是在濱河造成了極大的恐慌姚建,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件吱殉,死亡現(xiàn)場離奇詭異掸冤,居然都是意外死亡厘托,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進店門稿湿,熙熙樓的掌柜王于貴愁眉苦臉地迎上來铅匹,“玉大人,你說我怎么就攤上這事饺藤“撸” “怎么了?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵涕俗,是天一觀的道長罗丰。 經(jīng)常有香客問我,道長再姑,這世上最難降的妖魔是什么萌抵? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮元镀,結果婚禮上绍填,老公的妹妹穿的比我還像新娘。我一直安慰自己凹联,他們只是感情好沐兰,可當我...
    茶點故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布哆档。 她就那樣靜靜地躺著蔽挠,像睡著了一般。 火紅的嫁衣襯著肌膚如雪瓜浸。 梳的紋絲不亂的頭發(fā)上澳淑,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天,我揣著相機與錄音插佛,去河邊找鬼杠巡。 笑死,一個胖子當著我的面吹牛雇寇,可吹牛的內(nèi)容都是我干的氢拥。 我是一名探鬼主播,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼锨侯,長吁一口氣:“原來是場噩夢啊……” “哼嫩海!你這毒婦竟也來了?” 一聲冷哼從身側響起囚痴,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤叁怪,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后深滚,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體奕谭,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡涣觉,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了血柳。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片官册。...
    茶點故事閱讀 39,690評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖难捌,靈堂內(nèi)的尸體忽然破棺而出攀隔,到底是詐尸還是另有隱情,我是刑警寧澤栖榨,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布昆汹,位于F島的核電站,受9級特大地震影響婴栽,放射性物質(zhì)發(fā)生泄漏满粗。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一愚争、第九天 我趴在偏房一處隱蔽的房頂上張望映皆。 院中可真熱鬧,春花似錦轰枝、人聲如沸捅彻。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽步淹。三九已至,卻和暖如春诚撵,著一層夾襖步出監(jiān)牢的瞬間缭裆,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工寿烟, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留澈驼,地道東北人。 一個月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓筛武,卻偏偏與公主長得像缝其,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子徘六,可洞房花燭夜當晚...
    茶點故事閱讀 44,577評論 2 353

推薦閱讀更多精彩內(nèi)容