SPStackedNav 是全球最大的流音乐服务商 Spotify 开源的一个 iPad 分屏框架,用于 Spotify 的 iPad 版 App 中,网易云音乐 iPad 版 App 也是采用相似的分屏交互方案,该框架的交互表现如下图所示:
SPStackedNav实现的交互方式
使用
根据 GitHub 上面的说明完成项目导入之后,那么就可以开始搭建UI框架了。
创建 SPSideTabController, SPSideTabController 的用法和UITabController的用法没有什么大的区别。
分别创建 SPSideTabController 的 RootViewController,设置 UITabBarItem 属性。
给 SPSideTabController 的 viewControllers 属性赋值对应的 RootViewController 数组。
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
效果图2
设计
View的层次结构
从图中的 View 层次结构图可以看到,左边的侧边栏 View 是一个 SPSideTabBar,该 SPSideTabBar 包含若干个 SPSideTabItemButton 。右边的容器 View 是一个 SPStackedNavigationScrollView ,该 SPStackedNavigationScrollView 里面包含了若干个 SPStackedPageContainer , 一个 SPStackedPageContainer 可以简单的看做一个ViewController。
当我们在 Demo 项目中的 RootTestViewController 里面 push 一个 ViewController 的时候。其实就相当于往 SPStackedNavigationScrollView 添加一个 SPStackedPageContainer 子 view。SPStackedPageContainer的显示内容来自于 ViewController 的 view 属性。
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 上下载对应注释版本源码,地址https://github.com/junbinchencn/SPStackedNav-Note 。
总结
SPStackedNav 项目是一个用于 iPad 分屏的 UI 解决方案。该方案的核心在于 SPStackedNavigationScrollView 这个类。SPStackedNavigationScrollView 模仿了 UIScrollView 的实现。SPStackedNav 的分屏方案的设计非常精巧,实现思路清晰明确,实现过程中的很多细节还是非常具有参考和学习价值的,一些 contentOffset 的计算方法还是非常巧妙的。本人能力有限,文章难免有不足之处,若是您有发现,请在评论中指出,确认之后马上修改,谢谢!
参考
理解 Scroll Views https://www.objccn.io/issue-3-2/
SPStackedNav https://github.com/spotify/SPStackedNav
SPStackedNav-Note https://github.com/junbinchencn/SPStackedNav-Note