先上作品圖
UI樣式設計非原創,僅用於學習。
主要的功能點
- 頭部檢視會跟隨移動,選擇介紹和攻略頁的segmentController又會一直保留在最頂部
- 點選segmentController切換相應頁面,滑動頁面變更segmentController的index顯示
- tableViewCell中巢狀UICollectionView
- tableViewCell中的實現類似朋友圈“全文”和“收起”的效果
檢視結構分析
最外層是scrollView和帶有segmentController的頭部檢視並列(為什麼scrollView的高度是這個值而不是減去頭部檢視的值,下文會補充)。 scrollView的contentView新增了左右兩個tableView,介紹頁(左),攻略頁(右)。介紹頁tableViewCell又巢狀了一個UIcollectionView。大致上層級就是這樣的。
一步一步來完成
頭部檢視和外層scrollView
自定義一個HeaderView的類,HeadView內部實現就不累贅了。需要的先放個原始碼 在ViewController中新增HeaderView和scrollView。
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
[self.view addSubview:self.scrollView];
[self.view addSubview:self.headerView];
}
- (HeaderView *)headerView {
if (!_headerView) {
CGFloat headerHeight = SCREEN_HEIGHT / 6 + SEGMENT_HEIGHT;
_headerView = [[HeaderView alloc] initWithFrame:CGRectMake(0, 0, self.view.width, headerHeight)
title:@"王者榮耀"
downloads:@"n+ 次下載"
descripe:@"1.1 GB"];
[_headerView.segmentedControl addObserver:self
forKeyPath:@"selectedSegmentIndex"
options:NSKeyValueObservingOptionNew
context:nil];
}
return _headerView;
}
- (UIScrollView *)scrollView {
if (!_scrollView) {
self.scrollViewHeight = self.view.height - 64;
_scrollView = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, self.view.width, self.scrollViewHeight)];
_scrollView.contentSize = CGSizeMake(self.view.width*2, 0);
_scrollView.bounces = NO;
_scrollView.showsHorizontalScrollIndicator = NO;
_scrollView.pagingEnabled = YES;
_scrollView.showsVerticalScrollIndicator = NO;
_scrollView.delegate = self;
}
return _scrollView;
}
複製程式碼
segmentController和scrollView相互作用
上面程式碼已經設定了scrollView按頁滾動,點選segmentController的介紹頁,則讓scrollView滾動到左邊的頁面。反之亦然。這裡通過KVO來實現,也就是觀察segmentController的index值的變化來更改scrollView的contentOffset。KVO的釋放不能忘!
#pragma mark - KVO
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
// segmentController選中不同按鈕切換scrollView的頁面
if ([keyPath isEqualToString:@"selectedSegmentIndex"]) {
// 點選介紹
if (self.headerView.segmentedControl.selectedSegmentIndex == 0) {
self.scrollView.contentOffset = CGPointZero;
// 點選攻略
} else {
self.scrollView.contentOffset = CGPointMake(self.view.width, 0);
}
}
}
- (void)dealloc {
[self.headerView.segmentedControl removeObserver:self forKeyPath:@"selectedSegmentIndex"];
}
複製程式碼
而當scrollView滾動時,用代理方法來更改segmentController的index。這裡當然也可以使用KVO的,為什麼使用代理方法會更合適?下文會提到。
#pragma mark - scrollView delegate
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
if (scrollView == self.scrollView) {
// 滑動到介紹頁
if (scrollView.contentOffset.x == 0) {
if (self.headerView.segmentedControl.selectedSegmentIndex != 0) {
self.headerView.segmentedControl.selectedSegmentIndex = 0;
}
// 滑動到攻略頁
} else if (scrollView.contentOffset.x == self.view.width) {
if (self.headerView.segmentedControl.selectedSegmentIndex != 1) {
self.headerView.segmentedControl.selectedSegmentIndex = 1;
}
}
}
}
複製程式碼
頭部檢視跟隨移動
實現思路:給表檢視新增一個空的headerView,大小和我們上面定義的頭部檢視一樣,如下圖。滾動表檢視的時候,根據表檢視的偏移量來設定真正的頭部檢視的y值到達到滾動的假象,並且同步左右兩個表檢視的偏移量,而segmentController滾動到頂部的時候便令其y值保持不變就不再滾動了。
還記得上文說的scrollView的高度為什麼不減去headerView的高度了嗎,看了這個圖理解了嗎。
另外一點,這裡採用KVO監聽tableView的contentOffset的值,變化後更改頭部檢視的y。所以KVO的keyPath是”contentOffset”。這就是上文提到的為什麼scrollView不使用KVO而使用代理方法。因為keyPath會衝突。
接上面ViewController程式碼,為了讓程式碼更好地分離,這裡使用childViewController。
ViewController.m
- (void)viewDidLoad {
// ...
self.introduceTVC = [[IntroduceTableViewController alloc] init];
self.strategyTVC = [[StrategyTableViewController alloc] init];
[self setupChildViewController:self.introduceTVC x:0];
[self setupChildViewController:self.strategyTVC x:SCREEN_WIDTH];
}
- (void)setupChildViewController:(UITableViewController *)tableViewController x:(CGFloat)x {
UITableViewController *tableVC = tableViewController;
tableVC.view.frame = CGRectMake(x, 0, self.view.width, self.scrollViewHeight);
[self addChildViewController:tableVC];
[self.scrollView addSubview:tableVC.view];
[tableVC.tableView addObserver:self
forKeyPath:@"contentOffset"
options:NSKeyValueObservingOptionInitial
context:nil];
}
#pragma mark - KVO
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
// 頭部檢視移動跟隨滑動
if ([keyPath isEqualToString:@"contentOffset"]) {
CGFloat headerViewScrollStopY = SCREEN_HEIGHT/6 - 15;
UITableView *tableView = object;
CGFloat contentOffsetY = tableView.contentOffset.y;
// 滑動沒有超過停止點
if (contentOffsetY < headerViewScrollStopY) {
self.headerView.y = - tableView.contentOffset.y;
// 同步tableView的contentOffset
for (UITableViewController *vc in self.childViewControllers) {
if (vc.tableView.contentOffset.y != tableView.contentOffset.y) {
vc.tableView.contentOffset = tableView.contentOffset;
}
}
} else {
self.headerView.y = - headerViewScrollStopY;
}
}
複製程式碼
兩個childViewController中的方法
- (void)viewDidLoad {
[super viewDidLoad];
self.tableView.backgroundColor = DEFAULT_BACKGROUND_COLOR;
self.tableView.showsVerticalScrollIndicator = NO;
CGFloat headerHeight = SCREEN_HEIGHT / 6 + SEGMENT_HEIGHT;
// 假的tableview,高度同GameDetailHeadView
self.tableView.tableHeaderView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.width, headerHeight)];
}
複製程式碼
tableViewCell巢狀collectionView
在介紹頁的第一個section中需要顯示一系列的介紹圖片,圖片有需要滾動,這時候巢狀UIcollectionView就很合適了。
自定義tableViewCell,cell中新增collectionView,由於初始化時大小並未確定下來,所以需要在layoutSubviews方法中設定collectionView的大小,讓其填充滿cell。
下面的...setCollectionViewDataSourceDelegate...
方法可以幫助cell把相應的indexPath傳遞給collectionView,該專案並沒有使用到tableViewCell的indexPath,沒有也是可以的,只是為了增加其通用性,當有多個cell需要巢狀collectionView的時候,這時候就需要用indexPath來判斷具體是哪個section哪個row了。
這裡還有個坑[self.collectionView setContentOffset:CGPointZero animated:NO];
方法不能用[self.collectionView setContentOffset:CGPointZero];
代替,這是因為滾動過程中,很可能還未停下來,如果用了後面的方法,那麼設定contentOffset之後,collectionView還會持續把剛才未滾動完的繼續完成,位置就會出現偏差。
ImageTableViewCell.h
#import <UIKit/UIKit.h>
static NSString *CollectionViewCellID = @"CollectionViewCellID";
@interface ImageCollectionView : UICollectionView
// collectionView所在的tableViewCell的indexPath
@property (nonatomic, strong) NSIndexPath *indexPath;
@end
@interface ImageTableViewCell : UITableViewCell
@property (nonatomic, strong) ImageCollectionView *collectionView;
- (void)setCollectionViewDataSourceDelegate:(id<UICollectionViewDataSource, UICollectionViewDelegate>)dataSourceDelegate indexPath:(NSIndexPath *)indexPath;
@end
複製程式碼
ImageTableViewCell.m
@implementation ImageCollectionView
@end
@implementation ImageTableViewCell
- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
layout.sectionInset = UIEdgeInsetsMake(10, 10, 10, 10);
layout.itemSize = CGSizeMake(SCREEN_WIDTH/3, SCREEN_HEIGHT/4);
layout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
self.collectionView = [[ImageCollectionView alloc] initWithFrame:self.contentView.bounds collectionViewLayout:layout];
[self.collectionView registerClass:[ImageCollectionViewCell class] forCellWithReuseIdentifier:CollectionViewCellID];
self.collectionView.backgroundColor = [UIColor whiteColor];
self.collectionView.showsHorizontalScrollIndicator = NO;
[self.contentView addSubview:self.collectionView];
}
return self;
}
-(void)layoutSubviews {
[super layoutSubviews];
self.collectionView.frame = self.contentView.bounds;
}
/// 設定delegate,dataSource等
- (void)setCollectionViewDataSourceDelegate:(id<UICollectionViewDataSource, UICollectionViewDelegate>)dataSourceDelegate indexPath:(NSIndexPath *)indexPath {
self.collectionView.dataSource = dataSourceDelegate;
self.collectionView.delegate = dataSourceDelegate;
self.collectionView.indexPath = indexPath;
[self.collectionView setContentOffset:CGPointZero animated:NO];
[self.collectionView reloadData];
}
複製程式碼
collectionViewCell的程式碼也不關鍵,不佔地方了,需要的看原始碼。
之後在IntroduceTableViewController中設定相應的資料來源和代理方法。這裡有一個...willDisplayCell...
方法,用來配置collectionView的,理論上裡面的操作也可以在...cellForRowAtIndexPath...
完成,只是資料來源載入好當cell要顯示的時候再去執行配置collectionView的方法更符合邏輯一些。
IntroduceTableViewController.m
static NSString *kCellID0 = @"cellID0";
@interface IntroduceTableViewController () <UICollectionViewDelegate, UICollectionViewDataSource>
@end
#pragma mark - tableView dataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
ImageTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kCellID0];
if (!cell) {
cell = [[ImageTableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:kCellID0];
}
return cell;
}
#pragma mark - tableView delegate
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return SCREEN_HEIGHT / 3;
}
- (void)tableView:(UITableView *)tableView willDisplayCell:(ImageTableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath {
[cell setCollectionViewDataSourceDelegate:self indexPath:indexPath];
}
#pragma mark - collection view deta source
-(NSInteger)collectionView:(UICollectionView *)collectionView numberOfItemsInSection:(NSInteger)section {
return 5;
}
-(UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath {
ImageCollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:CollectionViewCellID forIndexPath:indexPath];
if (!cell) {
cell = [[ImageCollectionViewCell alloc] initWithFrame:CGRectMake(0, 0, SCREEN_HEIGHT/4, SCREEN_HEIGHT/4)];
}
cell.imageView.image = [UIImage imageNamed:@"fake_game"];
return cell;
}
複製程式碼
tableViewCell中段落的“全文”和“收起”
實現思路:
這裡的文字採用UILabel來展示,收起狀態下,返回固定的cell高度,並且儲存初始的UILabel和UIButton的frame值。展開狀態下,根據需要顯示的文字計算其文字高度,根據高度來更改cell高度,還有其他控制元件的frame。
定義indexPath把自身所處的indexPath在控制器傳進來,點選按鈕後回撥showMoreBlock根據indexPath重新整理cell的內容和高度。所以這裡的...layoutSubview...
方法很關鍵。
ContentTableViewCell.h
typedef void (^ShowMoreBlock)(NSIndexPath *indexPath);
@interface ContentTableViewCell : UITableViewCell
@property (nonatomic, copy) NSString *gameIntroduce; // 遊戲介紹內容
@property (nonatomic, strong) NSIndexPath *indexPath; // 用於重新整理指定cell
@property (nonatomic, assign, getter=isShowMoreContent) BOOL showMoreContent; // 是否顯示更多內容
@property (nonatomic, copy) ShowMoreBlock showMoreBlock; // 點選更多按鈕回撥
/// 預設高度(收起)
+ (CGFloat)cellDefaultHeight;
/// 顯示全文的高度
+ (CGFloat)cellMoreContentHeight:(NSString *)content;
複製程式碼
ContentTableViewCell.m
static const CGFloat kBlankLength = 10;
@interface ContentTableViewCell ()
@property (nonatomic, strong) UILabel *contentLabel;
@property (nonatomic, strong) UIButton *showMoreButton;
// 記錄button初始的frame
@property (nonatomic, assign) CGRect btnOriFrame;
@end
@implementation ContentTableViewCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (self) {
self.contentLabel = [[UILabel alloc] initWithFrame:CGRectMake(kBlankLength, kBlankLength, SCREEN_WIDTH-kBlankLength*2, 100)];
self.contentLabel.numberOfLines = 0;
self.contentLabel.font = [UIFont systemFontOfSize:14];
[self.contentView addSubview:self.contentLabel];
self.showMoreButton = [UIButton buttonWithType:UIButtonTypeSystem];
[self.showMoreButton setTitle:@"更多" forState:UIControlStateNormal];
[self.showMoreButton addTarget:self
action:@selector(showMoreOrLessContent)
forControlEvents:UIControlEventTouchUpInside];
[self.showMoreButton sizeToFit];
CGFloat unFoldButtonX = SCREEN_WIDTH - kBlankLength - self.showMoreButton.width;
CGFloat unFoldButtonY = kBlankLength * 2 + self.contentLabel.height;
CGRect buttonFrame = self.showMoreButton.frame;
buttonFrame.origin.x = unFoldButtonX;
buttonFrame.origin.y = unFoldButtonY;
self.showMoreButton.frame = buttonFrame;
self.btnOriFrame = buttonFrame;
[self.contentView addSubview:self.showMoreButton];
}
return self;
}
- (void)layoutSubviews {
[super layoutSubviews];
if (self.isShowMoreContent) {
// 計算文字高度
NSDictionary *attribute = @{NSFontAttributeName: [UIFont systemFontOfSize:14]};
NSStringDrawingOptions option = (NSStringDrawingOptions)(NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading);
CGSize size = [self.gameIntroduce boundingRectWithSize:CGSizeMake(SCREEN_WIDTH-kBlankLength*2, 1000) options:option attributes:attribute context:nil].size;
self.contentLabel.frame = CGRectMake(kBlankLength, kBlankLength, SCREEN_WIDTH-kBlankLength*2, size.height+20);
CGFloat buttonMoreContentY = kBlankLength * 2 + self.contentLabel.height;
CGRect buttonMoreContentRect = self.btnOriFrame;
buttonMoreContentRect.origin.y = buttonMoreContentY;
self.showMoreButton.frame = buttonMoreContentRect;
[self.showMoreButton setTitle:@"收起" forState:UIControlStateNormal];
} else {
self.contentLabel.frame = CGRectMake(kBlankLength, kBlankLength, SCREEN_WIDTH-kBlankLength*2, 100);
self.showMoreButton.frame = self.btnOriFrame;
[self.showMoreButton setTitle:@"全文" forState:UIControlStateNormal];
}
}
- (void)showMoreOrLessContent {
if (self.showMoreBlock) {
self.showMoreBlock(self.indexPath);
}
}
- (void)setGameIntroduce:(NSString *)gameIntroduce {
self.contentLabel.text = gameIntroduce;
_gameIntroduce = gameIntroduce;
}
+ (CGFloat)cellDefaultHeight {
return 160;
}
+ (CGFloat)cellMoreContentHeight:(NSString *)content {
// 計算文字高度
NSDictionary *attribute = @{NSFontAttributeName: [UIFont systemFontOfSize:14]};
NSStringDrawingOptions option = (NSStringDrawingOptions)(NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading);
CGSize size = [content boundingRectWithSize:CGSizeMake(SCREEN_WIDTH-kBlankLength*2, 1000) options:option attributes:attribute context:nil].size;
return size.height + 80;
}
複製程式碼
**PS:**如果有留心觀察的童鞋會發現這裡的Label的內容在”全文”和“收起”狀態下的高度並不對齊,這是因為UILabel預設內容是居中對齊的。
嘗試過使用UITextField,但是內容只能顯示一行,棄用。
嘗試過使用UITextView,收起的內容末尾不會出現省略號,而且不是根據文字內容按行壓縮,可能會出現文字的一半被壓縮的情況,棄用。
找到的一種相對滿意的方法是自定義一個UILabel,重寫其...drawRect...
方法,給需要的同學提供個思路。這裡不詳細展開了,挖個坑,之後計劃寫一篇和文字排版有關的可能會提到。
最後再把其他資料項填充一下,就是我們看到的這個樣子了
是不是覺得少了點什麼,內容怎麼感覺都在一塊了。對了,是section headerView 和 footerView。section header footer
這裡實現是不難,但是一樣要把控細節。我們的第一個展示圖片的cell是不需要headerView的,最後一個cell是不需要footerView的,這兩個就像畫蛇添足,有了反而不好看。這裡採用了偷懶的方式,直接把footerView的部分新增到了headerView的頭部。
圖中兩個剪頭的位置分別是假的section footerView和section headerViewIntroduceTableViewController.m
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
UIView *backgroundView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, SCREEN_WIDTH, 40)];
backgroundView.backgroundColor = [UIColor clearColor];
SectionHeaderView *view = [[SectionHeaderView alloc] initWithFrame:CGRectMake(0, 10, self.view.width, 30)];
if (section == 0) {
return nil;
}
switch (section) {
case CellTypeContentText:
view.labelText = @"內容摘要";
break;
case CellTypeRelatedList:
view.labelText = @"遊戲相關";
default:
break;
}
[backgroundView addSubview:view];
return backgroundView;
}
複製程式碼
iOS 11下的一個小問題
在介紹頁點選“收起”和“全文”後移動到攻略頁的時候會發現,內容向下偏移了。 找了一下原因是點選按鈕的時候會reload cell中的資料,導致tableView的偏移量發生的變化。下面引用來自騰訊BuglySelf-Sizing在iOS11下是預設開啟的,Headers, footers, and cells都預設開啟Self-Sizing,所有estimated 高度預設值從iOS11之前的 0 改變為UITableViewAutomaticDimension。
如果目前專案中沒有使用estimateRowHeight屬性,在iOS11的環境下就要注意了,因為開啟Self-Sizing之後,tableView是使用estimateRowHeight屬性的,這樣就會造成contentSize和contentOffset值的變化,如果是有動畫是觀察這兩個屬性的變化進行的,就會造成動畫的異常,因為在估算行高機制下,contentSize的值是一點點地變化更新的,所有cell顯示完後才是最終的contentSize值。因為不會快取正確的行高,tableView reloadData的時候,會重新計算contentSize,就有可能會引起contentOffset的變化。
這時候需要在tableViewController中新增如下即可解決。
self.tableView.estimatedRowHeight = 0;
self.tableView.estimatedSectionFooterHeight = 0;
self.tableView.estimatedSectionHeaderHeight = 0;
複製程式碼
就當認為已經解決的時候,問題再次出現。
又一個問題誕生
對tableView進行快速向下滾動操作,會出現和上圖一樣的情況,而且灰色區域每次都不一致。於是快速滾動一次,列印出了資料。
這裡可以很明顯看出精度很不精確,keyPath無法獲取準確的停止點,因此同步兩個頁面的tableView的contentOffset會不準確。這裡需要的精度至少是1。
原本以為...scrollViewDidScroll...
可以獲取到想要的精度,嘗試之後發現也不可行。
後來想到一種不太優雅卻能解決問題的方法,既然還未顯示的tableView的contentOffset會偏下,那麼如果小於頭部檢視的y值,就直接設定成這個值就好了。修改方法為
if ([keyPath isEqualToString:@"contentOffset"]) {
CGFloat headerViewScrollStopY = (int)SCREEN_HEIGHT/6 - 15.0;
UITableView *tableView = object;
CGFloat contentOffsetY = tableView.contentOffset.y;
// 滑動沒有超過停止點,頭部檢視跟隨移動
if (contentOffsetY < headerViewScrollStopY) {
self.headerView.y = - tableView.contentOffset.y;
// 同步tableView的contentOffset
for (UITableViewController *vc in self.childViewControllers) {
if (vc.tableView.contentOffset.y != tableView.contentOffset.y) {
vc.tableView.contentOffset = tableView.contentOffset;
}
}
// 頭部檢視固定位置
} else {
self.headerView.y = - headerViewScrollStopY;
// 解決高速滑動下tableView偏移量錯誤的問題
if (self.headerView.segmentedControl.selectedSegmentIndex == 0) {
UITableViewController *vc = self.childViewControllers[1];
if (vc.tableView.contentOffset.y < headerViewScrollStopY) {
CGPoint contentOffset = vc.tableView.contentOffset;
contentOffset.y = headerViewScrollStopY;
vc.tableView.contentOffset = contentOffset;
}
} else {
UITableViewController *vc = self.childViewControllers[1];
if (vc.tableView.contentOffset.y < headerViewScrollStopY) {
CGPoint contentOffset = vc.tableView.contentOffset;
contentOffset.y = headerViewScrollStopY;
vc.tableView.contentOffset = contentOffset;
}
}
}
}
複製程式碼
##末 原始碼和部落格相輔相成,部落格幫助理解,原始碼更具有結構性。推薦下載原始碼來看一下幫助理解。覺得有幫助的希望來個star。 原始碼地址:github.com/HasjOH/Nest…
參考: ashfurrow.com/blog/puttin… mp.weixin.qq.com/s/AZFrqL9dn… stackoverflow.com/questions/1…