原文地址:http://www.lxweimin.com/p/9fc838d46f5e
在日常的開發中,有時會遇到內容塊比較多,且又可變的界面:
多個可變cell復雜界面
這個界面中有些內容塊是固定出現的,比如最上面的商品詳情圖片、商品名稱、價格等。而有些內容塊則是不一定出現的,比如促銷(顯然不是每個商品都有促銷)、已選規格(有的商品沒有規格)、店鋪信息(有的商品屬于自營,就沒有店鋪)等。還有些內容要根據情況進行變化,比如評論,這里最多列出4條評論,如果沒有評論,則顯示“暫無評論”且不顯示“查看所有評論”按鈕。
對于這樣的界面,相信很多人第一感覺會用TableView來做,因為中間要列出評論內容,這個用TableView的cell來填充比較合適。但如何處理評論內容之外的其他內容呢?我之前的做法是,評論內容之上的用HeaderView做,下面的用FooterView做,雖然最終實現了功能,但做起來十分麻煩。布局我是用Auto
Layout來做的,由于Auto Layout本身的特點,這種做法在控制內容塊View的顯示與否,需要比較多的操作:
View的高度約束設置為0
最好還要把子View全部移除,否則,子View里的約束可能會因為View高度約束設置為0而出現約束沖突
如果View本身有距離前面View的間距約束,也需要將間距約束設置為0
View的hidden設置為yes,以減少視圖繪制
此外,還有一個麻煩的問題。界面剛進來的時候,是需要請求網絡數據,這時界面就要顯示成一個初始狀態,而顯然初始狀態有些內容塊是不應該顯示的,比如促銷,只有完成了數據請求,才能知道是否有促銷,有的話才顯示促銷內容;比如評論,初始時應該顯示成“暫無評論”,數據請求完成后,才顯示相應的內容。這樣,我們需要處理初始進入和數據請求完成兩種狀態下各個內容塊的顯示,十分復雜繁瑣。
總結來說,用TableView的 HeaderView + 評論內容cell + FooterView + Auto Layout 的方式會帶來如下問題:
約束在View與View之間是有依賴關系的,對View的顯示與否,需要比較多的操作
需要處理初始進入和數據請求完成兩種狀態的界面展示,使代碼更加復雜繁瑣
需要額外計算相應內容的高度,以更新HeaderView、FooterView的高度
可見,這種方式并不是理想的解決方案。可能有人會說,那不要用Auto
Layout,直接操作frame來布局就好,這樣或許能減少一些麻煩,但總體上并沒有減少復雜度。也有人說,直接用ScrollView來做,這樣的話,所有的內容包括評論內容的cell,都得自己手動拼接,可以想象這種做法也是比較麻煩的。所以,我們得另辟蹊徑,使用其他方法來達到目的。下面就為大家介紹一種比較簡便的做法,這種做法也是一個前同事分享給我的,我就借花獻佛,分享給大家。
我們還是用TableView來做這個界面,和之前不同的是,我們把每一個可變內容塊做成一個獨立的cell,cell的粒度可以自行控制,比如可以用一個cell囊括商品圖片、標題、副標題、價格,也可以拆得更細,圖片、標題、副標題、價格都各自對應一個cell。這里我們選擇后者,因為圖片內容塊,我們需要按屏幕寬度等比例拉伸;標題、副標題的文字內容可能是一行,也可能是兩行,高度可變,用單獨的cell來控制會更簡單明了,也更加靈活。
下面先定義好各種類型的cell:
//基礎cell,這里為了演示簡便,定義這個cell,其他cell繼承自這個cell@interfaceMultipleVariantBasicTableViewCell:UITableViewCell@property(nonatomic,weak)UILabel*titleTextLabel;@end//滾動圖片@interfaceCycleImagesTableViewCell:MultipleVariantBasicTableViewCell@end//正標題@interfaceMainTitleTableViewCell:MultipleVariantBasicTableViewCell@end//副標題@interfaceSubTitleTableViewCell:MultipleVariantBasicTableViewCell@end//價格@interfacePriceTableViewCell:MultipleVariantBasicTableViewCell@end// ...其他內容塊的cell聲明// 各種內容塊cell的實現,這里為了演示簡便,cell中就只放了一個Label@implementationMultipleVariantBasicTableViewCell- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString*)reuseIdentifier {self= [superinitWithStyle:style reuseIdentifier:reuseIdentifier];if(self) {UILabel*label = [[UILabelalloc] initWithFrame:CGRectMake(0,0,320,44)];? ? ? ? label.numberOfLines =0;? ? ? ? [self.contentView addSubview:label];self.titleTextLabel = label;? ? }returnself;}@end@implementationCycleImagesTableViewCell@end@implementationMainTitleTableViewCell@end// ...其他內容塊的cell實現// 評論內容cell使用Auto Layout,配合iOS 8 TableView的自動算高,實現內容自適應@implementationCommentContentTableViewCell- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString*)reuseIdentifier {self= [superinitWithStyle:style reuseIdentifier:reuseIdentifier];if(self) {self.titleTextLabel.translatesAutoresizingMaskIntoConstraints =NO;self.titleTextLabel.preferredMaxLayoutWidth = [UIScreenmainScreen].bounds.size.width -8;NSLayoutConstraint*leftConstraint = [NSLayoutConstraintconstraintWithItem:self.titleTextLabel attribute:NSLayoutAttributeLeadingrelatedBy:NSLayoutRelationEqualtoItem:self.contentView attribute:NSLayoutAttributeLeadingmultiplier:1.0f constant:4.0f];NSLayoutConstraint*rightConstraint = [NSLayoutConstraintconstraintWithItem:self.titleTextLabel attribute:NSLayoutAttributeTrailingrelatedBy:NSLayoutRelationEqualtoItem:self.contentView attribute:NSLayoutAttributeTrailingmultiplier:1.0f constant:-4.0f];NSLayoutConstraint*topConstraint = [NSLayoutConstraintconstraintWithItem:self.titleTextLabel attribute:NSLayoutAttributeToprelatedBy:NSLayoutRelationEqualtoItem:self.contentView attribute:NSLayoutAttributeTopmultiplier:1.0f constant:4.0f];NSLayoutConstraint*bottomConstraint = [NSLayoutConstraintconstraintWithItem:self.titleTextLabel attribute:NSLayoutAttributeBottomrelatedBy:NSLayoutRelationEqualtoItem:self.contentView attribute:NSLayoutAttributeBottommultiplier:1.0f constant:-4.0f];? ? ? ? [self.contentView addConstraints:@[leftConstraint, rightConstraint, topConstraint, bottomConstraint]];? ? }returnself;}@end
接下來就是重點,就是如何來控制顯示哪些cell及cell顯示的數量。這一步如果處理不好,也會使開發變得復雜。如下面的方式:
// 加載完數據self.cellCount =0;if(存在促銷) {self.cellCount++;}if(存在規格) {self.cellCount++;}......
如果以這種方式來記錄cell的數量,那么后續cell的展示、點擊判斷等都會很麻煩。這里我們采用的方式是,使用單獨的類(作為一種數據結構)來保存所要展示的cell信息。
// SKRow.h@interfaceSKRow:NSObject@property(nonatomic,copy)NSString*cellIdentifier;@property(nonatomic,strong)iddata;@property(nonatomic,assign)floatrowHeight;- (instancetype)initWithCellIdentifier:(NSString*)cellIdentifier? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? data:(id)data? ? ? ? ? ? ? ? ? ? ? ? ? ? rowHeight:(float)rowHeight;@end// SKRow.m#import"SKRow.h"@implementationSKRow- (instancetype)initWithCellIdentifier:(NSString*)cellIdentifier data:(id)data rowHeight:(float)rowHeight {if(self= [superinit]) {self.cellIdentifier = cellIdentifier;self.data = data;self.rowHeight = rowHeight;? ? }returnself;}@end
SKRow用來存儲每個cell所需的信息,包括重用標識、數據項、高度。接下來,我們就開始拼接cell信息。
@interfaceViewController() @property(nonatomic,strong)NSMutableArray *> *tableSections;@end
self.tableSections = [NSMutableArrayarray];/* 初始加載數據
* 初始化時,只顯示滾動圖片、價格、評論頭、無評論
*/// 滾動圖片(寬高保持比例)SKRow*cycleImagesRow = [[SKRowalloc] initWithCellIdentifier:@"CycleImagesCellIdentifier"data:@[@"滾動圖片地址"] rowHeight:120*[UIScreenmainScreen].bounds.size.width /320.f];// 價格SKRow*priceRow = [[SKRowalloc] initWithCellIdentifier:@"PriceCellIdentifier"data:@"0"rowHeight:44];[self.tableSections addObject:@[cycleImagesRow, priceRow]];// 評論頭SKRow*commentSummaryRow = [[SKRowalloc] initWithCellIdentifier:@"CommentSummaryCellIdentifier"data:@{@"title":@"商品評價",@"count":@"0"} rowHeight:44];// 無評論SKRow*noCommentRow = [[SKRowalloc] initWithCellIdentifier:@"NoCommentCellIdentifier"data:@"暫無評論"rowHeight:44];[self.tableSections addObject:@[commentSummaryRow, noCommentRow]];
以上是初始狀態時要顯示的cell,我們在ViewController中聲明一個數組,用來存儲TableView各個section要顯示的cell信息。這里我們將cell分成不同的section,實際中,要不要分,分成幾個section都可以自行決定。初始狀態我們有兩個section,第一個section用于顯示基本信息,第二個section用于顯示評論信息,這樣就完成了cell信息的拼接,接下來就是顯示:
- (UITableViewCell*)tableView:(UITableView*)tableView cellForRowAtIndexPath:(NSIndexPath*)indexPath {// 這里可以通過判斷cellIdentifier來區分處理各種不同的cell,cell所需的數據從row.data上獲取SKRow*row =self.tableSections[indexPath.section][indexPath.row];if([row.cellIdentifier isEqualToString:@"CycleImagesCellIdentifier"]) {? ? ? ? CycleImagesTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];NSArray *urlStringArray = row.data;? ? ? ? cell.titleTextLabel.text = [urlStringArray componentsJoinedByString:@"\n"];returncell;? ? }elseif([row.cellIdentifier isEqualToString:@"MainTitleCellIdentifier"]) {? ? ? ? MainTitleTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];? ? ? ? cell.titleTextLabel.text = row.data;returncell;? ? }elseif([row.cellIdentifier isEqualToString:@"PriceCellIdentifier"]) {? ? ? ? PriceTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];? ? ? ? cell.titleTextLabel.text = [NSStringstringWithFormat:@"¥%@", row.data];returncell;? ? }elseif([row.cellIdentifier isEqualToString:@"SalePromotionCellIdentifier"]) {? ? ? ? SalePromotionTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];NSArray *salePromotionStringArray = row.data;? ? ? ? cell.titleTextLabel.text = [salePromotionStringArray componentsJoinedByString:@"\n"];returncell;? ? }elseif([row.cellIdentifier isEqualToString:@"SpecificationCellIdentifier"]) {? ? ? ? SpecificationTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];? ? ? ? cell.titleTextLabel.text = [NSStringstringWithFormat:@"已選:%@", row.data];returncell;? ? }elseif([row.cellIdentifier isEqualToString:@"CommentSummaryCellIdentifier"]) {? ? ? ? CommentSummaryTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];NSDictionary*commentSummary = row.data;? ? ? ? cell.titleTextLabel.text = [NSStringstringWithFormat:@"%@(%@)", commentSummary[@"title"], commentSummary[@"count"]];returncell;? ? }elseif([row.cellIdentifier isEqualToString:@"CommentContentCellIdentifier"]) {? ? ? ? CommentContentTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];? ? ? ? cell.titleTextLabel.text = row.data;returncell;? ? }elseif([row.cellIdentifier isEqualToString:@"AllCommentCellIdentifier"]) {? ? ? ? AllCommentTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];? ? ? ? cell.titleTextLabel.text = row.data;returncell;? ? }elseif([row.cellIdentifier isEqualToString:@"NoCommentCellIdentifier"]) {? ? ? ? NoCommentTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath];? ? ? ? cell.titleTextLabel.text = row.data;returncell;? ? }returnnil;}
上面的代碼進行了刪減,沒有處理所有類型。雖然稍嫌冗長,但是邏輯非常簡單,就是獲取cell信息,根據重用標識來區分不同類型的內容塊,將數據處理后放到cell中展示。
例如,對于商品圖片,因為是滾動圖片,滾動圖片可以有多張,前面我們傳入的數據就是數組data:@[@"滾動圖片地址"]。后面獲取到數據后,cell.titleTextLabel.text = [urlStringArray componentsJoinedByString:@"\n"];,出于演示,商品圖片cell我們只放了一個Label,所以只是簡單的將地址信息分行顯示出來。在實際的開發中,可以放入一個圖片滾動顯示控件,并將圖片地址的數組數據傳給控件展示。
其他類型的cell處理也是大同小異,出于演示的原因,都只是簡單的數據處理展示。當然,別忘了,設置一下TableView相關的dataSource和delegate:
- (CGFloat)tableView:(UITableView*)tableView heightForRowAtIndexPath:(NSIndexPath*)indexPath {SKRow*row =self.tableSections[indexPath.section][indexPath.row];returnrow.rowHeight;}- (NSInteger)numberOfSectionsInTableView:(UITableView*)tableView {returnself.tableSections.count;}- (NSInteger)tableView:(UITableView*)tableView numberOfRowsInSection:(NSInteger)section {returnself.tableSections[section].count;}
這樣我們就完成了初始狀態時界面的展示:
初始狀態時界面顯示
完成了cell的顯示處理,接下來我們來模擬一下網絡請求數據后,界面如何顯示所需的cell:
self.tableSections = [NSMutableArrayarray];NSMutableArray *section1 = [NSMutableArrayarray];// 滾動圖片(寬高保持比例)SKRow*cycleImagesRow = [[SKRowalloc] initWithCellIdentifier:@"CycleImagesCellIdentifier"data:@[@"滾動圖片地址1",@"滾動圖片地址2",@"滾動圖片地址3"] rowHeight:120*[UIScreenmainScreen].bounds.size.width /320.f];// 主標題SKRow*mainTitleRow = [[SKRowalloc] initWithCellIdentifier:@"MainTitleCellIdentifier"data:@"商品名稱"rowHeight:44];// 副標題SKRow*subTitleRow = [[SKRowalloc] initWithCellIdentifier:@"SubTitleCellIdentifier"data:@"節日促銷,快來買啊"rowHeight:44];// 價格SKRow*priceRow = [[SKRowalloc] initWithCellIdentifier:@"PriceCellIdentifier"data:@(arc4random()) rowHeight:44];[section1 addObjectsFromArray:@[cycleImagesRow, mainTitleRow, subTitleRow, priceRow]];// 促銷(隨機出現)if(arc4random() %2==0) {SKRow*salePromotionRow = [[SKRowalloc] initWithCellIdentifier:@"SalePromotionCellIdentifier"data:@[@"促銷信息1",@"促銷信息2",@"促銷信息3"] rowHeight:44];? ? [section1 addObject:salePromotionRow];}[self.tableSections addObject:section1];NSMutableArray *section2 = [NSMutableArrayarray];// 規格(隨機出現)if(arc4random() %2==0) {SKRow*specificationRow = [[SKRowalloc] initWithCellIdentifier:@"SpecificationCellIdentifier"data:@"銀色,13.3英寸"rowHeight:44];? ? [section2 addObject:specificationRow];}if(section2.count >0) {? ? [self.tableSections addObject:section2];}NSMutableArray *section3 = [NSMutableArrayarray];NSArray *commentArray = [NSMutableArrayarray];// 評論內容數據(隨機出現)if(arc4random() %2==0) {? ? commentArray = @[@"評論內容1",@"評論內容2",@"2016年6月,蘋果系統iOS 10正式亮相,蘋果為iOS 10帶來了十大項更新。2016年6月13日,蘋果開發者大會WWDC在舊金山召開,會議宣布iOS 10的測試版在2016年夏天推出,正式版將在秋季發布。2016年9月7日,蘋果發布iOS 10。iOS10正式版于9月13日(北京時間9月14日凌晨一點)全面推送。",@"評論內容4"];}// 評論頭SKRow*commentSummaryRow = [[SKRowalloc] initWithCellIdentifier:@"CommentSummaryCellIdentifier"data:@{@"title":@"商品評價",@"count":@(commentArray.count)} rowHeight:44];[section3 addObject:commentSummaryRow];if(commentArray.count >0) {for(NSString*commentStringincommentArray) {// 評論內容需要自適應高度,高度值指定為UITableViewAutomaticDimensionSKRow*commentContentRow = [[SKRowalloc] initWithCellIdentifier:@"CommentContentCellIdentifier"data:commentString rowHeight:UITableViewAutomaticDimension];? ? ? ? [section3 addObject:commentContentRow];? ? }// 查看所有評論SKRow*allCommentRow = [[SKRowalloc] initWithCellIdentifier:@"AllCommentCellIdentifier"data:@"查看所有評論"rowHeight:44];? ? [section3 addObject:allCommentRow];}else{// 無評論SKRow*noCommentRow = [[SKRowalloc] initWithCellIdentifier:@"NoCommentCellIdentifier"data:@"暫無評論"rowHeight:44];? ? [section3 addObject:noCommentRow];}[self.tableSections addObject:section3];[self.tableView reloadData];
上面的代碼同樣比較冗長,但邏輯也同樣十分簡單。按顯示順序拼湊cell數據,有些不一定顯示的內容塊,如促銷,則隨機判斷,如果顯示,將數據加入到section數組中[section1 addObject:salePromotionRow];。其他類型的cell也是類似的,不再贅述。要注意的是,評論內容的文本可能有多行,我們將它的cell高設置為UITableViewAutomaticDimension:
[[SKRow alloc] initWithCellIdentifier:@"CommentContentCellIdentifier" data:commentString rowHeight:UITableViewAutomaticDimension];
由于評論內容cell我們使用了Auto Layout,這樣就可以利用iOS 8 TableView的新特性,自動計算cell的高度。拼接完數據后,只要調用[self.tableView reloadData];讓TableView重新加載即可。
好了,這樣就大功告成:
最終效果
使用上述方式制作這種內容塊可變的界面雖然寫起來較為啰嗦,但有如下優點:
邏輯清晰簡單,易于理解。視圖間不存在像先前HeaderView + Auto Layout + FooterView那種麻煩的約束處理,內容塊的顯示與否處理非常簡便。
性能比較好。有些cell可以復用,減少開銷。并且只加載需要顯示的View,如果是之前的做法,或者用scrollView來做,雖然最終也是只顯示需要的View,但不需要顯示的View還是要加載進來,有性能損耗。
易于靜態調整。如果產品經理要求調換內容塊的顯示順序,只要移動下拼湊cell數據的代碼順序即可。如果是去除某個內容塊,代碼上的調整也不復雜。
易于動態調整內容塊的顯示順序。所謂的動態調整,是指界面要根據接口返回的數據,來決定哪些內容塊顯示在前面,哪些顯示的后面。比如接口返回type=0時,價格項顯示在商品名稱之上,而type=1時,價格項顯示在商品子標題之下。
易于處理相似但又不同的界面。比如商品有好幾種不同的類型,有特惠專區,有免費專區的。免費專區的商品詳情在價格內容塊上要顯示不一樣的內容。這時,就可以多做一種類型的cell,根據接口返回type進行判斷,如果是免費專區則選取免費專區的cell來顯示。用之前HeaderView
+ Auto Layout的做法,就要費神地去調整約束,事倍功半。
易于擴展增加新的內容塊。要增加新的內容塊,只需創建新的cell,在數據拼接時,增加拼接新cell類型的數據代碼,同樣在顯示的地方增加顯示新cell類型的代碼即可,幾乎不需要修改原有的邏輯。
最后,附上Demo工程代碼。注意,這個工程是用XCode 8創建的,低版本的XCode可能運行會有問題(XCode 8的storyboard默認好像不兼容老版本),示例是基于iOS 8,如果要兼容老版本,請自行修改(主要是涉及cell自動算高的部分)。
后記
寫這篇文章之初,只是作為一個note,想著有哪個做iOS的朋友遇到類似的問題,可以給他做個參考。沒想到,竟引來不少關注,還被推上公眾號,收到不少評論,自己也因此打了一些“口水仗”。
從中,我也意識到我少強調了一件“顯而易見”的事件。我想說,我的方法并不適用所有情況,也不是要解決所有的問題。那些持批評觀點的,大多是面臨的問題需求不同所致。就好比我的方法是一把切水果的刀,而你要拿它去剁骨頭,那當然是不行的,它本來就不是用來剁骨頭的。
當然,很慶幸的是,從這些討論批評中,我也發現這種方法的一些不足,也有不少有益的收獲。
不足之處在于:
不大適用于交互比較多的界面。如:點擊某個按鈕顯示/隱藏某個數據項、填寫表單項等
交互比較多的界面
像這個界面,選了優惠券后,要更新顯示優惠信息,更新對應的應付款,用這種方法就不方便了
多個接口獲取數據并依次展示內容項。比如基本信息一個接口、促銷信息一個接口、評論信息一個接口,請求到基本信息數據就要展示基本信息,請求到促銷數據就要展示促銷信息,以此類推,那么數據拼接會比較麻煩
收獲在于,因此認識了@sun6boys,多了一個朋友,可謂是不打不相識。雖然他在文章的評論中并沒有詳細展示他的方法,但在私下的討論中,我已經窺探到了他的方法全貌。他對我方法不足的指責是有道理的,他的方法在數據的處理上比我規整,我的方法顯得原始粗暴。對于有交互的界面及從多個接口獲取數據依次展示的處理上,也要更加容易,整體的思路實現也非常簡潔,可以看作是我這種方法的升級改進版。更難能可貴的是,他為此專門寫了一個demo放到了github上:https://github.com/sun6boys/CRVisibleCellsDemo,大家可以學習參考。
同樣,也有其他的解決方法,比如完全用scrollview實現的:《復雜界面開發之所思》。我并不贊同用scrollview來做這種界面,原因在那篇文章也多有評論,但不管如何,多參考下其他的方法也是有益處的,至于如何取舍,就看各位的選擇了。
其實不論用什么方法,都是一種權衡,需要根據自身的情境去考慮。比如你所面臨的需求,是純展示型的界面,還是交互比較多的界面。比如團隊的開發習慣,我的團隊比較不習慣用scrollview,自然解決方法就會向tableview靠。比如團隊技能、學習成本,有的方法對約束的使用要求較高,這就是一種技能要求、一種門檻,會對團隊開發和新人融入產生影響。有人說我的方法偏傻瓜式,這是對的,因為這也是我所追求的。傻瓜式就意味著容易上手,團隊成員可以很容易使用這個方法,不管是接手別人的代碼,還是有新人進入團隊,做這一塊的東西,都不會多高的門檻。并且這個方法也已經足夠解決目前的問題,我覺得這樣就夠了。
所以,我覺得用哪種方法都不奇怪,甚至綜合各種因素后,使用H5去做也可以啊。當然,那樣的話,也就沒iOS多少事了。