iPad分屏框架-SPStackedNav源码解读

828 阅读12分钟
原文链接: www.jianshu.com

SPStackedNav 是全球最大的流音乐服务商 Spotify 开源的一个 iPad 分屏框架,用于 Spotify 的 iPad 版 App 中,网易云音乐 iPad 版 App 也是采用相似的分屏交互方案,该框架的交互表现如下图所示:

SPStackedNav实现的交互方式

使用

根据 GitHub 上面的说明完成项目导入之后,那么就可以开始搭建UI框架了。

  1. 创建 SPSideTabController, SPSideTabController 的用法和UITabController的用法没有什么大的区别。

  2. 分别创建 SPSideTabController 的 RootViewController,设置 UITabBarItem 属性。

  3. 给 SPSideTabController 的 viewControllers 属性赋值对应的 RootViewController 数组。

  4. Demo 的 AppDelegate 代码如下:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
    // Override point for customization after application launch.
    self.window.backgroundColor = [UIColor whiteColor];

    // 步骤 1 创建 SPSideTabController
    self.tabs = [[SPSideTabController alloc] init];
    
    // 步骤 2 分别创建 SPSideTabController 的 RootViewController,设置 UITabBarItem 属性
    RootTestViewController *root1 = [RootTestViewController new];
    root1.title = @"Root 1";
    root1.tabBarItem.image = [UIImage imageNamed:@"114-balloon"];
    
    RootTestViewController *root2 = [RootTestViewController new];
    root2.title = @"Root 2";
    root2.tabBarItem.image = [UIImage imageNamed:@"185-printer"];
    root2.tabBarItem.badgeValue = @"5";
    root2.tabBarItem.badgeColor = [UIColor redColor];

    RootTestViewController *root3 = [RootTestViewController new];
    root3.title = @"Root 3";
    root3.tabBarItem.image = [UIImage imageNamed:@"114-balloon"];
    
    // 步骤 3 给 SPSideTabController 的 viewControllers 属性赋值对应的 RootViewController 数组
    self.tabs.viewControllers = @[
        [[SPStackedNavigationController alloc] initWithRootViewController:root1],
        [[SPStackedNavigationController alloc] initWithRootViewController:root2],
        [[SPStackedNavigationController alloc] initWithRootViewController:root3]
    ];

    self.window.rootViewController = self.tabs;
    [self.window makeKeyAndVisible];
    
    
    return YES;
}```


5.效果图

![效果图1](http://upload-images.jianshu.io/upload_images/656644-868252e5afa69fbd.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)


![效果图2](http://upload-images.jianshu.io/upload_images/656644-c413a01681ef5ed1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)


### 设计

![View的层次结构](http://upload-images.jianshu.io/upload_images/656644-3ca6c07d584f5a61.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

从图中的 View 层次结构图可以看到,左边的侧边栏 View 是一个  SPSideTabBar,该 SPSideTabBar 包含若干个 SPSideTabItemButton 。右边的容器 View 是一个 SPStackedNavigationScrollView ,该 SPStackedNavigationScrollView 里面包含了若干个 SPStackedPageContainer , 一个 SPStackedPageContainer 可以简单的看做一个ViewController。

当我们在 Demo 项目中的 RootTestViewController 里面 push 一个 ViewController 的时候。其实就相当于往 SPStackedNavigationScrollView 添加一个 SPStackedPageContainer 子 view。SPStackedPageContainer的显示内容来自于 ViewController 的 view 属性。

```CPP
    ChildTestViewController *vc = [ChildTestViewController new];
    [self.stackedNavigationController pushViewController:vc animated:YES];

SPSideTabBar 和 SPSideTabItemButton 解析

RootTestViewController *root2 = [RootTestViewController new];
    root2.title = @"Root 2";
    root2.tabBarItem.image = [UIImage imageNamed:@"185-printer"];
    root2.tabBarItem.badgeValue = @"5";
    root2.tabBarItem.badgeColor = [UIColor redColor];

Demo 代码里面的 AppDelegate 设置的明明是 UITabBarItem 的各类属性, 但是为什么在 SPSideTabBar 里面没有看到关于 UITabBarItem 的信息呢?

SPSideTabBar的层级结构

再来看看 SPSideTabBar 这个 View 的层级结构图,可以猜出 SPSideTabBar 将 UITabBarItem 的属性设置映射成 SPSideTabItemButton 的属性设置了。

SPSideTabController 的 viewDidLoad 方法

查看 SPSideTabController.m 文件的 viewDidLoad 方法,我们可以看到 _tabBar.items = validItems 这个属性设置方法将 SPSideTabController 的 tabBarItem 的对象数组传给SPSideTabBar 的 items属性。

来到 SPSideTabBar.m 实现文件查看 - (void)setItems:(NSArray*)items 方法

//将 UITabBarItem 数组转成 SPSideTabItemButton 数组
- (void)setItems:(NSArray*)items
{
    
    if ([items isEqual:_items]) return;
    
    self.selectedItem = nil;
    
    _items = [items copy];
    
    for(UIView *b in _itemButtons) [b removeFromSuperview];
    self.itemButtons = nil;

    if (_items) {
        NSMutableArray *itemButtons = [NSMutableArray array];
        CGRect pen = CGRectMake(0, 10, 80, 70);
        for(UITabBarItem *item in _items) {
            //关键步骤 将 UITabBarItem 转成 SPSideTabItemButton
            UIView *b = [self buttonForItem:item withFrame:pen];
            [itemButtons addObject:b];
            [self addSubview:b];
            pen.origin.y += pen.size.height + 10;
        }
        self.itemButtons = itemButtons;
    }
}

继续跟踪查看方法

UIView *b = [self buttonForItem:item withFrame:pen];
// 设置 SPTabBarItem 的 frame,并返回 SPTabBarItem 的 View
- (UIView*)buttonForItem:(UITabBarItem*)item withFrame:(CGRect)pen
{
    if ([item isKindOfClass:[SPTabBarItem class]] && [(SPTabBarItem*)item view]) {
        UIView *view = [(SPTabBarItem*)item view];
        [view setFrame:pen];
        return view;
    }
    
    SPSideTabItemButton *b = [[SPSideTabItemButton alloc] initWithFrame:pen];
    
     // 省略 UITabBarItem 的属性转成 SPSideTabItemButton 的属性过程,
     // 具体细节可以详看源码
    
    return b;
}

使用 SPSideTabBar 自定义 View 来替代系统的 UITabBar, 使用 SPTabBarItem 自定义 View 来替代系统的 UITabBarItem,SPSideTabBar 将 UITabBarItem 的属性设置映射到 SPTabBarItem。这个就是常见的自定义 TabBar 的思路。

SPStackedNavigationController 解析

SPStackedNavigationController 继承与 UIViewController,并定义和实现了一系列和 NavigationController 相关的方法,简而言之就是自己实现一个 NavigationController,这里做重讲解2个主要的方法.

- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated activate:(BOOL)activate

- (UIViewController *)popViewControllerAnimated:(BOOL)animated;
SPStackedNavigationController 的示意

当 SPStackedNavigationController 做 push 操作的时候,就是往 SPStackedNavigationScrollView 这个仿 ScrollView 的 View 添加一个 SPStackedPageContainer 子View。从上图中的左边的 View 层次结构中可以看到SPStackedNavigationScrollView 里面有2个 SPStackedPageContainer 子 View。而上图中右边的 View 表现正好印证了这个结构。

查看 SPStackedNavigationController.m 文件的 - (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated activate:(BOOL)activate 实现方法

- (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated activate:(BOOL)activate
{
    // 省略代码
    // 添加 viewController 到 viewControllers 的数组
    [self willChangeValueForKey:@"viewControllers"];
    [self addChildViewController:viewController];
    
    //将 viewController 添加到 self,
    if ([self isViewLoaded])
    // 关键步骤 SPStackedNavigationScrollView 添加一个 SPStackedPageContainer 子 View
        [self pushPageContainerWithViewController:viewController];
    
    if (activate)
        [self setActiveViewController:viewController position:activePosition animated:animated];
    // 调用 viewController 生命周期方法
    [viewController didMoveToParentViewController:self];
    [self didChangeValueForKey:@"viewControllers"];
}

接下来看看 SPStackedNavigationController.m 文件 - (void)pushPageContainerWithViewController:(UIViewController*)viewController 的方法

- (void)pushPageContainerWithViewController:(UIViewController*)viewController
{
    CGSize size = self.view.frame.size;
    CGRect frame = CGRectMake(self.view.bounds.size.width, 0, 0, size.height);
    frame.size.width = (viewController.stackedNavigationPageSize == kStackedPageHalfSize ?
                        kSPStackedNavigationHalfPageWidth :
                        size.width);
    
    SPStackedPageContainer *pageC = [[SPStackedPageContainer alloc] initWithFrame:frame VC:viewController];
    //SPStackedNavigationScrollView 添加一个 SPStackedPageContainer 子 View
    [_scroll addSubview:pageC];
}

从代码中可以验证我们上文所述,当 SPStackedNavigationController 做 push 操作的时候,就是往 SPStackedNavigationScrollView 这个 View 添加一个 SPStackedPageContainer 子 View。

我们现在是否可以这样猜测,当 SPStackedNavigationController 做 pop 操作的时候,就是在 SPStackedNavigationScrollView 这个View 移除一个 SPStackedPageContainer View。

接下来查看 SPStackedNavigationController.m 文件的 - (UIViewController *)popViewControllerAnimated:(BOOL)animated 方法来验证一下我们的猜测。

- (UIViewController *)popViewControllerAnimated:(BOOL)animated
{
    UIViewController *viewController = [[self childViewControllers] lastObject];
    if (!viewController)
        return nil;
    
    [self willChangeValueForKey:@"viewControllers"];
    [viewController willMoveToParentViewController:nil];
    
    if ([self isViewLoaded])
    {
        // 关键步骤 ,将 SPStackedPageContainer 标记为移除状态,后续 SPStackedNavigationScrollView 会将它移除
        SPStackedPageContainer *pageC = [_scroll containerForViewController:viewController];
        pageC.markedForSuperviewRemoval = YES;
    }
    
    //关键步骤,移除 viewController
    [viewController removeFromParentViewController];
    [self didChangeValueForKey:@"viewControllers"];
    
    [self setActiveViewController:[self.childViewControllers lastObject]
                         position:SPStackedNavigationPagePositionRight
                         animated:animated];
    
    return viewController;
}

如我们猜测 SPStackedNavigationController 做 pop 操作的时候,就是在 SPStackedNavigationScrollView 这个View 移除一个 SPStackedPageContainer View。并让 SPStackedPageContainer 对应的 ViewController 发一个 removeFromParentViewController 的消息。

SPStackedPageContainer 解析

SPStackedPageContainer 的作用是承载 ViewController 的 View,并对一些手势动作进行处理,在这里 SPStackedPageContainer 这个概念在这里等同于一个分屏 View。 打开 SPStackedPageContainer.m 查看 - (void)setVCVisible:(BOOL)VCVisible 方法。

//将VC的View加到Container里面
- (void)setVCVisible:(BOOL)VCVisible
{
    if (VCVisible == self.VCVisible) return;
    
    if (VCVisible) {
        [self.screenshot removeFromSuperview];
        self.screenshot = nil;
        if (!self.markedForSuperviewRemoval || [_vc isViewLoaded])
        {
            _vcContainer.backgroundColor = _vc.view.backgroundColor;
            _vc.view.frame = CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height);
            if (!_vc.view.superview)
                // 关键步骤 添加 View
                [_vcContainer insertSubview:_vc.view atIndex:0];
        }
    } else {
        if ([_vc isViewLoaded])
            // 关键步骤 移除 View
            [_vc.view removeFromSuperview];
    }
}

SPStackedNavigationScrollView 解析

SPStackedNavigationScrollView 是一个模仿 UIScrollView 实现的 View。关于 UIScrollView 的深入理解,推荐 ObjC 中国的文章 理解 Scroll Views, 这里就不再详述,默认大家都是能理解 UIScrollView 的相关概念。

当使用 SPStackedNavigationController 做3次 Push 操作的时候, SPStackedNavigationScrollView 的 View 层次结构是这样的。

SPStackedNavigationScrollView 的层次结构

SPStackedNavigationController 的 rootView 就是 Container0 这个 View。而 Push 的 View 分别是 Container1,Container2,Container3。左边的半屏 View 的位置从底往上分别是 Container1 --> Container2。右边的半屏 View 则是 Container3。若是 SPStackedNavigationController 再 Push 一个 View 的话,那么 左边的半屏 View 的位置从底往上分别是 Container1 --> Container2 --> Container3 。右边的半屏 View 则是 Container4,Container 这个概念在这里等同于一个分屏 View。 在这个时候 SPStackedNavigationScrollView 的View 的简单示意图如下

SPStackedNavigationController 的 push 操作

从上面的 View 结构示意图中可以看出,SPStackedNavigationScrollView 对 UIScrollView 的模仿主要体现在 UIScrollView 的滑动机制上。
当 SPStackedNavigationController 做 push 操作的时候,SPStackedNavigationScrollView 右边半屏的 View 会从右向左滑动到左边半屏的位置,而右边半屏则从右向左显示一个新的 push 进来的 View。
当 SPStackedNavigationController 做 pop 操作的时候,SPStackedNavigationScrollView 右边半屏的 View 会从左向右滑动出屏幕显示范围,而左边半屏的 View 则会从左向右滑动到右边半屏。

SPStackedNavigationController 的 pop 操作

讲完了 SPStackedNavigationScrollView 的大概表现之后,若是大家还是不怎么了解的话,可以运行 Demo 详细体会SPStackedNavigationScrollView 的UI变化。 我们接下来查看 SPStackedNavigationScrollView.h 文件,寻找和 UIScrollView 相关的代码。

@interface SPStackedNavigationScrollView : UIView
 // ...... 省略代码
@property(nonatomic) CGPoint contentOffset;
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated;
- (NSRange)scrollRange;
// ...... 省略代码
@end

从 SPStackedNavigationScrollView 的头文件中,我们可以看到 SPStackedNavigationScrollView 继承于 UIView。和 UIScrollView 相关的概念有 contentOffset 和 scrollRange。关于 UIScrollView 的深入理解,推荐 查看 ObjC 中国的文章 理解 Scroll Views ,这里就不再详述,默认大家都是能理解 UIScrollView 的相关概念。

接下来开始讲解 SPStackedNavigationScrollView 的具体实现。 看下面的图,当屏幕上只有 rootView 没有分屏的 View 的时候 SPStackedNavigationScrollView 的 frame 的坐标原点是在 rootView 的左上角,这个时候SPStackedNavigationScrollView 的 contentOffset = 0。


contentOffset = 0

接着看图,当屏幕上出现一个分屏的 View 的时候,我们叫这个 View 为 Container1。 SPStackedNavigationScrollView 的 frame 的坐标原点是在 Container1 的左上角,这个时候SPStackedNavigationScrollView 的 contentOffset = rootView.width / 2。


contentOffset = rootView.width / 2

接着看图,当屏幕上出现二个分屏的 View 的时候,我们分别叫这二个 View 为 Container1 和 Container2。 SPStackedNavigationScrollView 的 frame 的坐标原点是在 Container1 的左上角,这个时候SPStackedNavigationScrollView 的 contentOffset = rootView.width。


contentOffset = rootView.width

从上面的示意图中不难看出理解 SPStackedNavigationScrollView 的重点在于理解 SPStackedNavigationScrollView 不断变化的 frame 原点 和 contentOffset。只要 contentOffset 发生了变化,那么 SPStackedNavigationScrollView 就会发生滚动。

查看 SPStackedNavigationScrollView.m 文件,看到了2个和contentOffset相关的变量 _actualOffset 和 _targetOffset,接下来跟踪这2个变量的变化。

@implementation SPStackedNavigationScrollView {
    CGPoint _actualOffset; //模拟 ScrollView 当前的 contentOffset
    CGPoint _targetOffset;// 模拟 ScrollView 将要滚动到的 contentOffset
}

查看 SPStackedNavigationScrollView 的 - (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated 方法,作用是赋值 _targetOffset 和 _actualOffset 。

// 模仿 UIScrollView 滚动到指定位置
- (void)setContentOffset:(CGPoint)contentOffset animated:(BOOL)animated
{
    //  给 _targetOffset 赋值
    _targetOffset = contentOffset;
    if (animated)
        [self animateToTargetScrollOffset];
    else {
    //  给 _actualOffset 赋值
        _actualOffset = _targetOffset;
        if (_onScrollDone)
        {
            self.onScrollDone();
            self.onScrollDone = nil;
        }
       // 关键步骤
        [self setNeedsLayout];
    }                                                                                                                         
}

UIView 在调用 setNeedsLayout 方法之后,会调用 layoutSubviews 方法。接下看查看该方法。

- (void)layoutSubviews
{
    // pen 的作用是stretch scroll at start and end
    // 用于在第一屏从左向右拉扯和最后一屏从右向左拉扯,
    // 让手势拖动的距离2倍于View移动的距离。
    // _actualOffset 改变之后,通过特定的规则计算 pen 的 frame,然后将 frame 赋值给 View ,
    // 总之作用就是调整 View 的 frame 位置
    // 可以说 pen 就是对应的每个分屏的 frame
    CGRect pen = CGRectZero;

    // 为什么需要 -  _actualOffset.x ?
    // 为了得到每个分屏 View 的坐标的 X 值 (坐标原点是 SPStackedNavigationScrollView 的坐标原点,即在屏幕范围内的最左边的分屏 View 的左上角位置)
    // 详见 ContentOffset 的计算方法
    pen.origin.x = -_actualOffset.x;
    
    // stretch scroll at start and end
    if (_actualOffset.x < 0){
        // 第一页从左向右拉扯 _actualOffset.x < 0 才成立,
        // _actualOffset 就是当前模仿的 UIScrollView 的 contentOffset
        // 手势拖动的距离2倍于 View 移动的距离
        pen.origin.x = -_actualOffset.x/2;
    }

    CGFloat maxScroll = [self scrollOffsetForAligningPageWithRightEdge:self.subviews.lastObject];
    if (_actualOffset.x > maxScroll){
            pen.origin.x = -(maxScroll + (_actualOffset.x-maxScroll)/2);
    }

    int i = 0;
    // markedForSuperviewRemovalOffset 标记 pageC 自己的 offset 坐标
    // 用来给 superview 把 pageC 从当前位置移动到 markedForSuperviewRemovalOffset 指定的坐标
    // 可以让自己的 View 对边缘层叠效果做出对应的位置
    // 也可以让 pageC 自己全屏或者半屏,
    CGFloat markedForSuperviewRemovalOffset = pen.origin.x;// View 的坐标位置x
    NSMutableArray *stackedViews = [NSMutableArray array];
    
    for(SPStackedPageContainer *pageC in self.subviews) {
        pen.size = pageC.bounds.size;
        pen.size.height = self.frame.size.height;
        if (pageC.vc.stackedNavigationPageSize == kStackedPageFullSize)
            pen.size.width = self.frame.size.width;
        
        CGRect actualPen = pen;
        if (pageC.markedForSuperviewRemoval)
            actualPen.origin.x = markedForSuperviewRemovalOffset;
        // Stack on the left
        // 小于 (0,1,2,3)*3
        // 左边是一个 stackedViews,最多有3层边缘层叠效果
        if (actualPen.origin.x < (MIN(i, 3))*3){
           // 如果actualPen.origin.x 小于 (MIN(i, 3))*3 那么说明该 pageC 的位置不是在 stackedViews 最顶部的三个以内
           [stackedViews addObject:pageC];
        }else{
           pageC.hidden = NO;
        }

        if (self.scrollAnimationTimer == nil)
            // floorf取整操作
            actualPen.origin.x = floorf(actualPen.origin.x);
        // 改变pageC.frame,那么pageC就会动了
        pageC.frame = actualPen;
   
        markedForSuperviewRemovalOffset += pen.size.width;
        // NavVC 做 POP 操作的时候会将 markedForSuperviewRemoval 置为 YES
        // 前面 pen.origin.x = -_actualOffset.x;
        // 这里计算下一个屏幕的位置 frame 的 x 值
        // 所以需要加上 pen.size.width
        if (!pageC.markedForSuperviewRemoval)
            pen.origin.x += pen.size.width;
        
        // 覆盖不透明度
        if (actualPen.origin.x <= 0 && pageC != [self.subviews lastObject]) {
            // abs()绝对值函数
            pageC.overlayOpacity = 0.3/actualPen.size.width*abs(actualPen.origin.x);
        } else {
            pageC.overlayOpacity = 0.0;
        }

        i++;
    }
    
    i = 0;
    for (NSInteger index = 0; index < [stackedViews count]; index++)
    {
        SPStackedPageContainer *pageC = stackedViews[index];
        // stackedViews 包括 RootVC 的 View;
        // stackedViews 里面的最后3个 View 显示
        if ([stackedViews count] > 3 && index < ([stackedViews count]-3))
            pageC.hidden = YES;
        else
        {
            // 左边是一个 stackedViews,最多有3层边缘层叠效果
            pageC.hidden = NO;
            CGRect frame = pageC.frame;
            // 调整坐标,显示层叠效果
            frame.origin.x = 0 + MIN(i, 3)*3;
            pageC.frame = frame;
            i++;
        }
    }
    // Only make sure we show what we need to, don't unload stuff until we're done animating
    [self updateContainerVisibilityByShowing:YES byHiding:NO];
}

在 layoutSubviews 方法里面 根据 _actualOffset 计算好每个分屏的 frame ,以及哪些分屏是可以显示在屏幕上的,哪些分屏是需要移除的,哪些分屏的位置是在屏幕显示的分屏的左边,哪些分屏的位置是在屏幕显示的分屏的右边。

在layoutSubviews 方法里面调用了一个方法用于控制分屏 View 的显示与隐藏,在这里分屏 View的概念可以等同于SPStackedPageContainer。这个方法是 - (void)updateContainerVisibilityByShowing:(BOOL)doShow byHiding:(BOOL)doHide 。

- (void)updateContainerVisibilityByShowing:(BOOL)doShow byHiding:(BOOL)doHide
{
    // fabsf 浮点数的绝对值
    // 分屏 View 是否需要弹跳效果
    BOOL bouncing = self.scrollAnimationTimer && fabsf(_targetOffset.x - _actualOffset.x) < 30;
    
    // layoutSubViews的 pen 是一个 frame、
    // 这里的 pen 是一个 frame 的 x 坐标
    // 但是用法和 layoutSubViews 的 pen 没什么区别
    CGFloat pen = -_actualOffset.x;
    
    // stretch scroll at start and end
    if (_actualOffset.x < 0)
        pen = -_actualOffset.x/2;
    
    CGFloat maxScroll = [self scrollOffsetForAligningPageWithRightEdge:self.subviews.lastObject];

    if (_actualOffset.x > maxScroll)
        pen = -(maxScroll + (_actualOffset.x-maxScroll)/2);
    // 用来让 SuperView 移动 pageC 的 x 坐标,原点是屏幕显示的最左边的分屏的 X 坐标
    CGFloat markedForSuperviewRemovalOffset = pen;
    
    NSMutableArray *viewsToDelete = [NSMutableArray array];
    for(SPStackedPageContainer *pageC in self.subviews) {
        CGFloat currentPen = pen;
        // 该 pageC 被做了 POP 操作,需要被 SuperView移除
        if (pageC.markedForSuperviewRemoval)
            currentPen = markedForSuperviewRemovalOffset;
        // 该分屏是否是在屏幕可见的分屏的右边同时无法看见该分屏
        BOOL isOffScreenToTheRight = currentPen >= self.bounds.size.width;

        NSRange scrollRange = [self scrollRangeForPageContainer:pageC];
        // View 是否被其他 View 覆盖了
        BOOL isCovered = currentPen + scrollRange.length <= 0;
        
        // View 现在是否可见
        BOOL isVisible = !isOffScreenToTheRight && !isCovered;
        

        // pageC 的可见性发生变化 && ( (isVisible == NO  && doHide == Yes)  ||  isVisible == Yes && doShow ==Yes)
        // 只要 pageC 的可见性发生变化,不管是隐藏还是显示都执行下面的if条件分支
        if (pageC.VCVisible != isVisible && ((!isVisible && doHide) || (isVisible && doShow)))
        {
            
            // pageC分屏将出现
            // pageC分屏将离开屏幕
            //(isVisible == No || bouncing == No || (isVisible ==Yes && needsInitialPresentation == Yes))
            if (!isVisible || !bouncing || (isVisible && pageC.needsInitialPresentation)) {
                pageC.needsInitialPresentation = NO;
                pageC.VCVisible = isVisible;
            }
        }
        // 要隐藏 pageC 并且该 pageC 被标记为销毁的
        //(doHide ==Yes && pageC.markedForSuperviewRemoval ==Yes)
        // 将 pageC 加入销毁数组 viewsToDelete
        if (doHide && pageC.markedForSuperviewRemoval)
            [viewsToDelete addObject:pageC];
        
        //经过 Demo 验证 pen 和 markedForSuperviewRemovalOffset 的值一样
        markedForSuperviewRemovalOffset += pageC.frame.size.width;
        
        // markedForSuperviewRemoval = No
        // 计算 pen 的值,该值为下一个分屏的 X 坐标
        if (!pageC.markedForSuperviewRemoval)
            pen += pageC.frame.size.width;
    }
    // 对viewsToDelete数组里面的View执行销毁操作
    [viewsToDelete makeObjectsPerformSelector:@selector(removeFromSuperview)];
}

限于篇幅关系无法一一介绍SPStackedNavigationScrollView 的各种实现。
未介绍的细节知识点包括但不限于 NSRunLoop,用于 SPStackedNavigationScrollView 追踪触摸滑动,保证界面滑动时不受其他 Mode 影响。SPStackedNavigationScrollView 的 scrollRange 的计算细节,SPStackedNavigationScrollView 的手势处理等等,大家若是有兴趣可以在我的 GitHub 上下载对应注释版本源码,地址 github.com/junbinchenc…

总结

SPStackedNav 项目是一个用于 iPad 分屏的 UI 解决方案。该方案的核心在于 SPStackedNavigationScrollView 这个类。SPStackedNavigationScrollView 模仿了 UIScrollView 的实现。SPStackedNav 的分屏方案的设计非常精巧,实现思路清晰明确,实现过程中的很多细节还是非常具有参考和学习价值的,一些 contentOffset 的计算方法还是非常巧妙的。本人能力有限,文章难免有不足之处,若是您有发现,请在评论中指出,确认之后马上修改,谢谢!

参考

理解 Scroll Views www.objccn.io/issue-3-2/
SPStackedNav github.com/spotify/SPS…
SPStackedNav-Note github.com/junbinchenc…