iOS嵌套式游戏详情页

2018-04-14  本文已影响75人  123123dfgj656

先上作品图

image

UI样式设计非原创,仅用于学习。

主要的功能点

视图结构分析

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

上一篇下一篇

猜你喜欢

热点阅读