Canoe

SGPagingView源码阅读

2018.05.30

GitHub - kingsic/SGPagingView: A powerful and easy to use segment control (美团、淘宝、京东、微博、腾讯、网易、今日头条等标题滚动视图)

SGPagingView 是一个标题页面切换的库,实现起来也很简单,我在自己的项目中也封装了一个控制页面切换的类似的库,同时也很好奇其他人的实现方式,于是在 GitHub 上寻找合适的,星星数较高的有WMPageController,但是用它来实现需要继承他的类,而我们正常的项目中都有一个自定义的基类,所以放弃了,其他的实现方式大同小异,最终我选择了 SGPagingView,因为这个库包含了两种实现方式,更加方便我研究差异性,以及该库的写法较为规范,可以学习一下。

内部结构



SGPageTitleViewConfigure(SGPageTitleView 初始化配置信息)
SGPageTitleView(用于与 SGPageContent 联动)
SGPageContentScrollView(内部由 UIScrollView 实现)
SGPageContentCollectionView(内部由 UICollectionView 实现)

实现原理

作者将所有的 titleView 显示配置信息放在了 SGPageTitleViewConfigure 中,然后初始化 titleView 之后将配置信息传入 titleView 的构造函数中。

SGPageTitleViewConfigure

为了能够显示多种样式的 indicator,分别使用了两个枚举

typedef enum : NSUInteger {
    /// 下划线样式
    SGIndicatorStyleDefault,
    /// 遮盖样式
    SGIndicatorStyleCover,
    /// 固定样式
    SGIndicatorStyleFixed,
    /// 动态样式(仅在 SGIndicatorScrollStyleDefault 样式下支持)
    SGIndicatorStyleDynamic
} SGIndicatorStyle;

typedef enum : NSUInteger {
    /// 指示器位置跟随内容滚动而改变
    SGIndicatorScrollStyleDefault,
    /// 内容滚动一半时指示器位置改变
    SGIndicatorScrollStyleHalf,
    /// 内容滚动结束时指示器位置改变
    SGIndicatorScrollStyleEnd
} SGIndicatorScrollStyle;

这个类里面都是一些配置信息。

SGPageTitleView

这个是用于滚动的头部视图,需要仔细看的有几个地方,一个是固定按钮个数的情况和能够滚动的情况,这里作者是根据按钮标题的长度以及我们的配置间距计算得出是采用滚动还是静态的头部视图。

NSInteger titleCount = self.titleArr.count;

    // 计算所有按钮的文字宽度
    [self.titleArr enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        CGSize tempSize = [obj SG_sizeWithFont:self.configure.titleFont];
        CGFloat tempWidth = tempSize.width;
        self.allBtnTextWidth += tempWidth;
    }];
    // 所有按钮文字宽度 + 所有按钮额外增加的宽度
    self.allBtnWidth = self.allBtnTextWidth + self.configure.titleAdditionalWidth * titleCount;
    self.allBtnWidth = ceilf(self.allBtnWidth);
    
    if (self.allBtnWidth <= self.bounds.size.width) { // SGPageTitleView 静止样式
//添加按钮
        } else { // SGPageTitleView 滚动样式
//添加按钮
}

另一个需要注意的点在于当 titleView 是静止状态的时候与可以滚动的状态两种状态下,indicator 的动画方式会有所区别。这里需要进行大量的计算,这里暂且不再讨论,有需要的可以看源码。
当默认的界面配置完成之后,我们就需要设置初始状态了,作者的处理是在

- (void)layoutSubviews {
    [super layoutSubviews];

    // 选中按钮下标初始值
    [self P_btn_action:self.btnMArr[_selectedIndex]];
}

这个方法中做初始的点击配置。

SGPageContentCollectionView

这个类由名字可以看出是使用了 collectionView 实现滚动视图,在将多个 ViewController 的 View 加入滚动视图的时候我们主要需要考虑的是 Controller 以及 View 的创建和释放,包括 viewWillAppearviewDidDissAppear,因为我们希望在使用的时候能够尽量小的占用内存,并且可以像普通的 ViewController 一样使用。
在使用 CollectionView 加载 ViewController 的时候,首先传入 controller 数组。

ChildVCOne *oneVC = [[ChildVCOne alloc] init];
    ChildVCTwo *twoVC = [[ChildVCTwo alloc] init];
    ChildVCThree *threeVC = [[ChildVCThree alloc] init];
    ChildVCFour *fourVC = [[ChildVCFour alloc] init];
    NSArray *childArr = @[oneVC, twoVC, threeVC, fourVC];
    /// pageContentCollectionView
    CGFloat ContentCollectionViewHeight = self.view.frame.size.height - CGRectGetMaxY(_pageTitleView.frame);
    self.pageContentCollectionView = [[SGPageContentCollectionView alloc] initWithFrame:CGRectMake(0, CGRectGetMaxY(_pageTitleView.frame), self.view.frame.size.width, ContentCollectionViewHeight) parentVC:self childVCs:childArr];
    _pageContentCollectionView.delegatePageContentCollectionView = self;
    [self.view addSubview:_pageContentCollectionView];

当我们点击 titleView 上的按钮的时候,会调用 SGPageContentCollectionView 类中的一个方法。

- (void)setPageContentCollectionViewCurrentIndex:(NSInteger)currentIndex {
    CGFloat offsetX = currentIndex * self.collectionView.SG_width;
    _startOffsetX = offsetX;
    // 1、处理内容偏移
    if (_previousCVCIndex != currentIndex) {
        [self.collectionView setContentOffset:CGPointMake(offsetX, 0) animated:_isAnimated];
    }
    // 2、记录上个子控制器下标
    _previousCVCIndex = currentIndex;
    // 3、pageContentCollectionView:index:
    if (self.delegatePageContentCollectionView && [self.delegatePageContentCollectionView respondsToSelector:@selector(pageContentCollectionView:index:)]) {
        [self.delegatePageContentCollectionView pageContentCollectionView:self index:currentIndex];
    }
}

这个时候由于我们是手动控制 contentoffset,所以只会加载当前显示的 VC ,调用 viewdidloadviewWillAppear 方法。
如果我们不使用点击 titleView 的方式,使用滑动 collectionView 的方式,根据 CollectionView 的特性,当滚动到第三页的时候,会提前加载好前一页和后一页,这样体验上会更加好一点,不需要等到滚动到当前页才开始加载。
同时,collectionView 默认只加载前一页和后一页的特性导致我们往后滚动的时候,除了相邻页面的其他页面会调用 viewWillDisappear,但是不会 dealloc,他只会保持三个页面 appear 的状态,只有当整个页面退出,才会将所有的页面释放。

SGPageContentScrollView

SGPageContentScrollView 是使用 scrollView 实现的。相对而言,使用 scrollView 自由度更高,我们可以随心所欲的控制加载和释放,但是操作起来也更加麻烦,有得有失,有利有弊。
当使用 scrollView 的时候,我们想要做到的效果就是所见即所得,当前显示哪个 Controller,当前 Controller 就 viewDidLoad 并且 viewWillAppear,当滑动或者点击到另一个的时候,前面那个就调用 viewWillDisappear,新的 ViewController 就 viewWillAppear,同时 ViewController 都采用懒加载,退出父 ViewController 的时候再全部释放。
那这种效果如何实现呢?我们首先需要明白一个原理:
当 childViewController 没有被加到任何父视图控制器时,如果把 childViewController 的 view 加到别的视图上,viewWillAppear 和 viewDidAppear 会正常调用。但是当 childViewController 被加到一个父视图控制器上后,viewWillAppear 和 viewDidAppear 就会与父视图控制器的 viewWillAppear 和 viewDidAppear 事件同步。
也就是说我们的 childViewController 被加载到父 Controller 之后会和父视图同步事件,那么我们怎么调用 viewWillAppearviewWillDisAppear 呢?我们可以看一下苹果的官方文档。
beginAppearanceTransition:animated: - UIViewController | Apple Developer Documentation

使用 beginAppearanceTransition:animated: 方法可以让子视图调用显示和消失的方法。
接着看作者的源码:
手动调用 index 的方法

- (void)setPageContentScrollViewCurrentIndex:(NSInteger)currentIndex {
    // 1、根据标题下标计算 pageContent 偏移量
    CGFloat offsetX = currentIndex * self.SG_width;

    // 2、切换子控制器的时候,执行上个子控制器的 viewWillDisappear 方法
    if (self.previousCVC != nil && _previousCVCIndex != currentIndex) {
        [self.previousCVC beginAppearanceTransition:NO animated:NO];
    }

    // 3、添加子控制器及子控制器的 view 到父控制器以及父控制器 view 中
    if (_previousCVCIndex != currentIndex) {
        UIViewController *childVC = self.childViewControllers[currentIndex];
        [self.parentViewController addChildViewController:childVC];
        [childVC beginAppearanceTransition:YES animated:NO];
        [self.scrollView addSubview:childVC.view];
        // 1.1、切换子控制器的时候,执行上个子控制器的 viewDidDisappear 方法
        if (self.previousCVC != nil && _previousCVCIndex != currentIndex) {
            [self.previousCVC endAppearanceTransition];
        }
        [childVC endAppearanceTransition];
        childVC.view.frame = CGRectMake(offsetX, 0, self.SG_width, self.SG_height);
        [childVC didMoveToParentViewController:self.parentViewController];
        // 3.1、记录上个子控制器
        self.previousCVC = childVC;
        
        // 4、处理内容偏移
        [self.scrollView setContentOffset:CGPointMake(offsetX, 0) animated:_isAnimated];
    }
    // 3.2、记录上个子控制器下标
    _previousCVCIndex = currentIndex;
    // 3.3、重置 _startOffsetX
    _startOffsetX = offsetX;
    
    // 5、pageContentScrollView:index:
    if (self.delegatePageContentScrollView && [self.delegatePageContentScrollView respondsToSelector:@selector(pageContentScrollView:index:)]) {
        [self.delegatePageContentScrollView pageContentScrollView:self index:currentIndex];
    }
}

这里面[self.previousCVC beginAppearanceTransition:NO animated:NO];就是调用上一个页面的ViewWillDisAppear, 然后[childVC beginAppearanceTransition:YES animated:NO];就是调用viewWillAppear然后紧接着[self.previousCVC endAppearanceTransition];调用消失界面的viewDidDisAppear,最后[childVC endAppearanceTransition];调用viewDidAppear

总结

SGPagingView 提供了两种实现方式,我们可以根据项目的需要自由选择,使用 collectionView 的话会操作上更加流畅,会有预加载的效果,但是 viewWillAppearviewWillDisAppear的调用时机我们就需要注意,使用 scrollView 的话会滑动到某一个页面才开始加载,但是可以做到所见即调用,更加方便控制。
同时作为一个挑选三方库的使用者,我们更加想要的是侵入性小,耦合度低,使用方便,并且代码规范的库,由此也可以懂得,以后要是想要自己实现能够让别人使用的库,要从这几个角度去考虑。

Comments
Write a Comment