在日常的開發中,有時會遇到內容塊比較多,且又可變的介面:
這個介面中有些內容塊是固定出現的,比如最上面的商品詳情圖片、商品名稱、價格等。而有些內容塊則是不一定出現的,比如促銷(顯然不是每個商品都有促銷)、已選規格(有的商品沒有規格)、店鋪資訊(有的商品屬於自營,就沒有店鋪)等。還有些內容要根據情況進行變化,比如評論,這裡最多列出4條評論,如果沒有評論,則顯示“暫無評論”且不顯示“檢視所有評論”按鈕。
對於這樣的介面,相信很多人第一感覺會用TableView來做,因為中間要列出評論內容,這個用TableView的cell來填充比較合適。但如何處理評論內容之外的其他內容呢?我之前的做法是,評論內容之上的用HeaderView做,下面的用FooterView做,雖然最終實現了功能,但做起來十分麻煩。佈局我是用Auto Layout來做的,由於Auto Layout本身的特點,程式碼中就涉及很多判斷處理。比如“已選規格”塊,最開始的是有一個和“促銷”內容塊的頂部間距約束,但“促銷”內容塊不一定會有,就得根據情況,調整“已選規格”塊本身的約束(例如讓其頂部間距約束指向“價格”內容塊)。同樣,“促銷”內容塊本身也需要類似的處理,如果沒有“促銷”時,要隱藏自己,而隱藏自己最簡單的辦法,就是將自己的高度約束設定為0(因為它還有底部到“已選規格”的間距約束,不能隨意將自身移除,否則“已選規格”相關的約束得再進行調整)。
此外,還有一個麻煩的問題。介面剛進來的時候,是需要請求網路資料,這時介面就要顯示成一個初始狀態,而顯然初始狀態有些內容塊是不應該顯示的,比如促銷,只有完成了資料請求,才能知道是否有促銷,有的話才顯示促銷內容;比如評論,初始時應該顯示成“暫無評論”,資料請求完成後,才顯示相應的內容。這樣,我們需要處理初始進入和資料請求完成兩種狀態下各個內容塊的顯示,十分複雜繁瑣。
總結來說,用TableView的 HeaderView + 評論內容cell + FooterView + Auto Layout 的方式會帶來如下問題:
- 約束本身需要依賴其他View的,而所依賴的View又是可變的內容塊,會導致約束需要繁瑣的判斷修改增刪
- 需要處理初始進入和資料請求完成兩種狀態的介面展示,使程式碼更加複雜繁瑣
- 需要額外計算相應內容的高度,以更新HeaderView、FooterView的高度
可見,這種方式並不是理想的解決方案。可能有人會說,那不要用Auto Layout,直接操作frame來佈局就好,這樣或許能減少一些麻煩,但總體上並沒有減少複雜度。也有人說,直接用ScrollView來做,這樣的話,所有的內容包括評論內容的cell,都得自己手動拼接,可以想象這種做法也是比較麻煩的。所以,我們得另闢蹊徑,使用其他方法來達到目的。下面就為大家介紹一種比較簡便的做法,這種做法也是一個前同事分享給我的,我就借花獻佛,分享給大家。
我們還是用TableView來做這個介面,和之前不同的是,我們把每一個可變內容塊做成一個獨立的cell,cell的粒度可以自行控制,比如可以用一個cell囊括商品圖片、標題、副標題、價格,也可以拆得更細,圖片、標題、副標題、價格都各自對應一個cell。這裡我們選擇後者,因為圖片內容塊,我們需要按螢幕寬度等比例拉伸;標題、副標題的文字內容可能是一行,也可能是兩行,高度可變,用單獨的cell來控制會更簡單明瞭。
下面先定義好各種型別的cell:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
//基礎cell,這裡為了演示簡便,定義這個cell,其他cell繼承自這個cell @interface MultipleVariantBasicTableViewCell : UITableViewCell @property (nonatomic, weak) UILabel *titleTextLabel; @end //滾動圖片 @interface CycleImagesTableViewCell : MultipleVariantBasicTableViewCell @end //正標題 @interface MainTitleTableViewCell : MultipleVariantBasicTableViewCell @end //副標題 @interface SubTitleTableViewCell : MultipleVariantBasicTableViewCell @end //價格 @interface PriceTableViewCell : MultipleVariantBasicTableViewCell @end // ...其他內容塊的cell宣告 // 各種內容塊cell的實現,這裡為了演示簡便,cell中就只放了一個Label @implementation MultipleVariantBasicTableViewCell - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; if (self) { UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 320, 44)]; label.numberOfLines = 0; [self.contentView addSubview:label]; self.titleTextLabel = label; } return self; } @end @implementation CycleImagesTableViewCell @end @implementation MainTitleTableViewCell @end // ...其他內容塊的cell實現 // 評論內容cell使用Auto Layout,配合iOS 8 TableView的自動算高,實現內容自適應 @implementation CommentContentTableViewCell - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; if (self) { self.titleTextLabel.translatesAutoresizingMaskIntoConstraints = NO; self.titleTextLabel.preferredMaxLayoutWidth = [UIScreen mainScreen].bounds.size.width - 8; NSLayoutConstraint *leftConstraint = [NSLayoutConstraint constraintWithItem:self.titleTextLabel attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeLeading multiplier:1.0f constant:4.0f]; NSLayoutConstraint *rightConstraint = [NSLayoutConstraint constraintWithItem:self.titleTextLabel attribute:NSLayoutAttributeTrailing relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeTrailing multiplier:1.0f constant:-4.0f]; NSLayoutConstraint *topConstraint = [NSLayoutConstraint constraintWithItem:self.titleTextLabel attribute:NSLayoutAttributeTop relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeTop multiplier:1.0f constant:4.0f]; NSLayoutConstraint *bottomConstraint = [NSLayoutConstraint constraintWithItem:self.titleTextLabel attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual toItem:self.contentView attribute:NSLayoutAttributeBottom multiplier:1.0f constant:-4.0f]; [self.contentView addConstraints:@[leftConstraint, rightConstraint, topConstraint, bottomConstraint]]; } return self; } @end |
接下來就是重點,就是如何來控制顯示哪些cell及cell顯示的數量。這一步如果處理不好,也會使開發變得複雜。如下面的方式:
1 2 3 4 5 6 7 8 9 |
// 載入完資料 self.cellCount = 0; if (存在促銷) { self.cellCount++; } if (存在規格) { self.cellCount++; } ...... |
如果以這種方式來記錄cell的數量,那麼後續cell的展示、點選判斷等都會很麻煩。這裡我們採用的方式是,使用單獨的類(作為一種資料結構)來儲存所要展示的cell資訊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
// SKRow.h @interface SKRow : NSObject @property (nonatomic, copy) NSString *cellIdentifier; @property (nonatomic, strong) id data; @property (nonatomic, assign) float rowHeight; - (instancetype)initWithCellIdentifier:(NSString *)cellIdentifier data:(id)data rowHeight:(float)rowHeight; @end // SKRow.m #import "SKRow.h" @implementation SKRow - (instancetype)initWithCellIdentifier:(NSString *)cellIdentifier data:(id)data rowHeight:(float)rowHeight { if (self = [super init]) { self.cellIdentifier = cellIdentifier; self.data = data; self.rowHeight = rowHeight; } return self; } @end |
SKRow用來儲存每個cell所需的資訊,包括重用標識、資料項、高度。接下來,我們就開始拼接cell資訊。
1 2 3 |
@interface ViewController () @property (nonatomic, strong) NSMutableArray *> *tableSections; @end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
self.tableSections = [NSMutableArray array]; /* 初始載入資料 * 初始化時,只顯示滾動圖片、價格、評論頭、無評論 */ // 滾動圖片(寬高保持比例) SKRow *cycleImagesRow = [[SKRow alloc] initWithCellIdentifier:@"CycleImagesCellIdentifier" data:@[@"滾動圖片地址"] rowHeight:120*[UIScreen mainScreen].bounds.size.width / 320.f]; // 價格 SKRow *priceRow = [[SKRow alloc] initWithCellIdentifier:@"PriceCellIdentifier" data:@"0" rowHeight:44]; [self.tableSections addObject:@[cycleImagesRow, priceRow]]; // 評論頭 SKRow *commentSummaryRow = [[SKRow alloc] initWithCellIdentifier:@"CommentSummaryCellIdentifier" data:@{@"title":@"商品評價", @"count":@"0"} rowHeight:44]; // 無評論 SKRow *noCommentRow = [[SKRow alloc] initWithCellIdentifier:@"NoCommentCellIdentifier" data:@"暫無評論" rowHeight:44]; [self.tableSections addObject:@[commentSummaryRow, noCommentRow]]; |
以上是初始狀態時要顯示的cell,我們在ViewController中宣告一個陣列,用來儲存TableView各個section要顯示的cell資訊。這裡我們將cell分成不同的section,實際中,要不要分,分成幾個section都可以自行決定。初始狀態我們有兩個section,第一個section用於顯示基本資訊,第二個section用於顯示評論資訊,這樣就完成了cell資訊的拼接,接下來就是顯示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
- (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"]; return cell; } else if ([row.cellIdentifier isEqualToString:@"MainTitleCellIdentifier"]) { MainTitleTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath]; cell.titleTextLabel.text = row.data; return cell; } else if ([row.cellIdentifier isEqualToString:@"PriceCellIdentifier"]) { PriceTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath]; cell.titleTextLabel.text = [NSString stringWithFormat:@"¥%@", row.data]; return cell; } else if ([row.cellIdentifier isEqualToString:@"SalePromotionCellIdentifier"]) { SalePromotionTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath]; NSArray *salePromotionStringArray = row.data; cell.titleTextLabel.text = [salePromotionStringArray componentsJoinedByString:@"\n"]; return cell; } else if ([row.cellIdentifier isEqualToString:@"SpecificationCellIdentifier"]) { SpecificationTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath]; cell.titleTextLabel.text = [NSString stringWithFormat:@"已選:%@", row.data]; return cell; } else if ([row.cellIdentifier isEqualToString:@"CommentSummaryCellIdentifier"]) { CommentSummaryTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath]; NSDictionary *commentSummary = row.data; cell.titleTextLabel.text = [NSString stringWithFormat:@"%@(%@)", commentSummary[@"title"], commentSummary[@"count"]]; return cell; } else if ([row.cellIdentifier isEqualToString:@"CommentContentCellIdentifier"]) { CommentContentTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath]; cell.titleTextLabel.text = row.data; return cell; } else if ([row.cellIdentifier isEqualToString:@"AllCommentCellIdentifier"]) { AllCommentTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath]; cell.titleTextLabel.text = row.data; return cell; } else if ([row.cellIdentifier isEqualToString:@"NoCommentCellIdentifier"]) { NoCommentTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:row.cellIdentifier forIndexPath:indexPath]; cell.titleTextLabel.text = row.data; return cell; } return nil; } |
上面的程式碼進行了刪減,沒有處理所有型別。雖然稍嫌冗長,但是邏輯非常簡單,就是獲取cell資訊,根據重用標識來區分不同型別的內容塊,將資料處理後放到cell中展示。
例如,對於商品圖片,因為是滾動圖片,滾動圖片可以有多張,前面我們傳入的資料就是陣列data:@[@"滾動圖片地址"]
。後面獲取到資料後,cell.titleTextLabel.text = [urlStringArray componentsJoinedByString:@"\n"];
,出於演示,商品圖片cell我們只放了一個Label,所以只是簡單的將地址資訊分行顯示出來。在實際的開發中,可以放入一個圖片滾動顯示控制元件,並將圖片地址的陣列資料傳給控制元件展示。
其他型別的cell處理也是大同小異,出於演示的原因,都只是簡單的資料處理展示。當然,別忘了,設定一下TableView相關的dataSource和delegate:
1 2 3 4 5 6 7 8 9 10 11 12 |
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { SKRow *row = self.tableSections[indexPath.section][indexPath.row]; return row.rowHeight; } - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { return self.tableSections.count; } - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.tableSections[section].count; } |
這樣我們就完成了初始狀態時介面的展示
完成了cell的顯示處理,接下來我們來模擬一下網路請求資料後,介面如何顯示所需的cell
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
self.tableSections = [NSMutableArray array]; NSMutableArray *section1 = [NSMutableArray array]; // 滾動圖片(寬高保持比例) SKRow *cycleImagesRow = [[SKRow alloc] initWithCellIdentifier:@"CycleImagesCellIdentifier" data:@[@"滾動圖片地址1", @"滾動圖片地址2", @"滾動圖片地址3"] rowHeight:120*[UIScreen mainScreen].bounds.size.width / 320.f]; // 主標題 SKRow *mainTitleRow = [[SKRow alloc] initWithCellIdentifier:@"MainTitleCellIdentifier" data:@"商品名稱" rowHeight:44]; // 副標題 SKRow *subTitleRow = [[SKRow alloc] initWithCellIdentifier:@"SubTitleCellIdentifier" data:@"節日促銷,快來買啊" rowHeight:44]; // 價格 SKRow *priceRow = [[SKRow alloc] initWithCellIdentifier:@"PriceCellIdentifier" data:@(arc4random()) rowHeight:44]; [section1 addObjectsFromArray:@[cycleImagesRow, mainTitleRow, subTitleRow, priceRow]]; // 促銷(隨機出現) if (arc4random() % 2 == 0) { SKRow *salePromotionRow = [[SKRow alloc] initWithCellIdentifier:@"SalePromotionCellIdentifier" data:@[@"促銷資訊1", @"促銷資訊2", @"促銷資訊3"] rowHeight:44]; [section1 addObject:salePromotionRow]; } [self.tableSections addObject:section1]; NSMutableArray *section2 = [NSMutableArray array]; // 規格(隨機出現) if (arc4random() % 2 == 0) { SKRow *specificationRow = [[SKRow alloc] initWithCellIdentifier:@"SpecificationCellIdentifier" data:@"銀色,13.3英寸" rowHeight:44]; [section2 addObject:specificationRow]; } if (section2.count > 0) { [self.tableSections addObject:section2]; } NSMutableArray *section3 = [NSMutableArray array]; NSArray *commentArray = [NSMutableArray array]; // 評論內容資料(隨機出現) 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 = [[SKRow alloc] initWithCellIdentifier:@"CommentSummaryCellIdentifier" data:@{@"title":@"商品評價", @"count":@(commentArray.count)} rowHeight:44]; [section3 addObject:commentSummaryRow]; if (commentArray.count > 0) { for (NSString *commentString in commentArray) { // 評論內容需要自適應高度,高度值指定為UITableViewAutomaticDimension SKRow *commentContentRow = [[SKRow alloc] initWithCellIdentifier:@"CommentContentCellIdentifier" data:commentString rowHeight:UITableViewAutomaticDimension]; [section3 addObject:commentContentRow]; } // 檢視所有評論 SKRow *allCommentRow = [[SKRow alloc] initWithCellIdentifier:@"AllCommentCellIdentifier" data:@"檢視所有評論" rowHeight:44]; [section3 addObject:allCommentRow]; } else { // 無評論 SKRow *noCommentRow = [[SKRow alloc] 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資料的程式碼順序即可
- 易於擴充套件增加新的內容塊。要增加新的內容塊,只需建立新的cell,在資料拼接時,增加拼接新cell型別的資料程式碼,同樣在顯示的地方增加顯示新cell型別的程式碼即可,幾乎不需要修改原有的邏輯
最後,附上Demo工程程式碼。注意,這個工程是用XCode 8建立的,低版本的XCode可能執行會有問題(XCode 8的storyboard預設好像不相容老版本),示例是基於iOS 8,如果要相容老版本,請自行修改(主要是涉及cell自動算高的部分)。