先上作品图
imageUI样式设计非原创,仅用于学习。
主要的功能点
- 头部视图会跟随移动,选择介绍和攻略页的segmentController又会一直保留在最顶部
- 点击segmentController切换相应页面,滑动页面变更segmentController的index显示
- tableViewCell中嵌套UICollectionView
- tableViewCell中的实现类似朋友圈“全文”和“收起”的效果
视图结构分析
image最外层是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会冲突。
image接上面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)];
}
image
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...
方法,给需要的同学提供个思路。这里不详细展开了,挖个坑,之后计划写一篇和文字排版有关的可能会提到。
最后再把其他数据项填充一下,就是我们看到的这个样子了
image
是不是觉得少了点什么,内容怎么感觉都在一块了。对了,是section headerView 和 footerView。
section header footer
这里实现是不难,但是一样要把控细节。我们的第一个展示图片的cell是不需要headerView的,最后一个cell是不需要footerView的,这两个就像画蛇添足,有了反而不好看。这里采用了偷懒的方式,直接把footerView的部分添加到了headerView的头部。
image
图中两个剪头的位置分别是假的section footerView和section headerView
IntroduceTableViewController.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下的一个小问题
image在介绍页点击“收起”和“全文”后移动到攻略页的时候会发现,内容向下偏移了。
找了一下原因是点击按钮的时候会reload cell中的数据,导致tableView的偏移量发生的变化。下面引用来自腾讯Bugly
Self-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进行快速向下滚动操作,会出现和上图一样的情况,而且灰色区域每次都不一致。于是快速滚动一次,打印出了数据。 image这里可以很明显看出精度很不精确,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。
源码地址:https://github.com/HasjOH/NestedPage
参考:
https://ashfurrow.com/blog/putting-a-uicollectionview-in-a-uitableviewcell/
https://mp.weixin.qq.com/s/AZFrqL9dnlgA6Vt2sVhxIw
https://stackoverflow.com/questions/1054558/vertically-align-text-to-top-within-a-uilabel
网友评论