Skip to content

Repository files navigation

一个用于iOS的嵌套页面视图控制器,提供平滑的滚动协调体验。

功能特点

  • 支持头部视图、标签栏和多个子视图控制器
  • 支持内容滚动位置记录(该功能是设计本框架的最大动力来源)
  • 支持局部刷新和全局刷新
  • 支持子页面预加载(默认是滑动到指定页才加载)
  • 支持头部视图手指拖拽滚动并带动整体,可配置控制内容scrollView是否惯性滚动
  • 支持自定义标签栏
  • 支持旋转
  • 更多细节和功能请下载demo

功能演示

记录滚动位置 局部刷新 全局刷新
记录滚动位置 局部刷新 全局刷新
头部始终固定不动 头部缩放+导航栏隐藏 显示底部tabBar
头部始终固定不动 头部缩放+隐藏导航栏 显示底部tabBar
子VC的sectionHeader吸顶 运行时修改头部高度 没有头部
子VC的sectionHeader吸顶 运行时修改头部高度 没有头部
滚到顶部 自定义标签栏1 自定义标签栏2
滚到顶部 自定义标签栏1 自定义标签栏2

系统要求

  • iOS 13.0+
  • Swift 5.0+

安装

Swift Package Manager

在Xcode中,选择 File > Swift Packages > Add Package Dependency,然后输入以下URL:

https://github.com/SPStore/NestedPageViewController.git

CocoaPods

在你的Podfile中添加:

pod 'NestedPageViewController'

然后运行:

pod install

注意:如果CocoaPods的方式安装,编译报错:Xcode error when building app: line 7: /resources-to-copy-Project.txt: Permission denied,或者其他类似权限问题,请在你的主工程中的Targets -> Build Settings -> User Script Sandboxing 改为No

使用方法

NestedPageViewController提供两种使用方式:添加子控制器方式和继承方式。

方式一:添加子控制器方式

import UIKit
import NestedPageViewController

class YourViewController: UIViewController {
    
    // MARK: - Properties
    
    private var nestedPageViewController = NestedPageViewController()
    private var coverView = YourHeaderView()
    private var customTabStrip = YourCustomTabStrip()
    
    // MARK: - View Controllers
    
    private let childControllerTitles = ["标签一", "标签二", "标签三", "标签四"]
    
    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
                
        setupNestedPageViewController()
    }
    
    // MARK: - Setup
    
    private func setupNestedPageViewController() {
        nestedPageViewController.dataSource = self
        nestedPageViewController.delegate = self
        
        // 添加为子控制器
        addChild(nestedPageViewController)
        view.addSubview(nestedPageViewController.view)
        nestedPageViewController.didMove(toParent: self)
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        // 更新NestedPageViewController的frame
        let safeAreaTop = view.safeAreaInsets.top
        nestedPageViewController.view.frame = CGRect(
            x: 0,
            y: safeAreaTop,
            width: view.bounds.width,
            height: view.bounds.height - safeAreaTop
        )
    }
}

// MARK: - NestedPageViewControllerDataSource

extension YourViewController: NestedPageViewControllerDataSource {
    
    func numberOfViewControllers(in pageViewController: NestedPageViewController) -> Int {
        return childControllerTitles.count
    }
    
    func pageViewController(_ pageViewController: NestedPageViewController, viewControllerAt index: Int) -> NestedPageScrollable? {
        guard index >= 0 && index < childControllerTitles.count else { return nil }
        
        switch index {
        case 0:
            return YourChildViewController1()  // 必须遵守NestedPageScrollable协议
        case 1:
            return YourChildViewController2()  // 必须遵守NestedPageScrollable协议
        case 2:
            return YourChildViewController3()  // 必须遵守NestedPageScrollable协议
        case 3:
            return YourChildViewController4()  // 必须遵守NestedPageScrollable协议
        default:
            return nil
        }
    }
    
    func coverView(in pageViewController: NestedPageViewController) -> UIView? {
        return coverView
    }
    
    func heightForCoverView(in pageViewController: NestedPageViewController) -> CGFloat {
        return 200.0
    }
    
    func tabStrip(in pageViewController: NestedPageViewController) -> UIView? {
        return customTabStrip  // 使用自定义标签栏
    }
    
    func heightForTabStrip(in pageViewController: NestedPageViewController) -> CGFloat {
        return 50.0
    }
    
    func titlesForTabStrip(in pageViewController: NestedPageViewController) -> [String]? {
        return nil  // 使用自定义标签栏时返回nil
    }
}

// MARK: - NestedPageViewControllerDelegate

extension YourViewController: NestedPageViewControllerDelegate {
    
    // 页面横向滚动到指定索引位置的回调方法
    func pageViewController(_ pageViewController: NestedPageViewController, didScrollToPageAt index: Int) {
        // 页面切换回调
        print("当前页面索引: \(index)")
    }
    
    // 内容垂直滚动视图的滚动状态变化回调方法
    func pageViewController(_ pageViewController: NestedPageViewController, contentScrollViewDidScroll scrollView: UIScrollView, headerOffset: CGFloat, isSticked: Bool) {
        // headerOffset: 头部相对contentScrollView顶部的偏移量
        // isSticked: 是否处于完全吸顶状态
        
        // 例如:根据滚动状态控制导航栏的显示/隐藏
        if isSticked {
            // 头部完全吸顶,可以显示导航栏标题
        } else {
            // 头部未完全吸顶,可以隐藏导航栏标题
        }
    }
}

方式二:继承方式

import UIKit
import NestedPageViewController

class YourNestedPageViewController: NestedPageViewController {
    
    // MARK: - Properties
    
    private var coverView = YourHeaderView()
    private var customTabStrip = YourCustomTabStrip()
    
    // MARK: - View Controllers
    
    private let childControllerTitles = ["标签一", "标签二", "标签三", "标签四"]

    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupNestedPageViewController()
    }
    
    override func viewDidLayoutSubviews() {
        let safeTop = view.safeAreaInsets.top
        containerInsets = UIEdgeInsets(top: safeTop, left: 0, bottom: 0, right: 0)
        
        // 采用继承方式时,需要在super之前设置containerInsets
        super.viewDidLayoutSubviews()
    }
    
    // MARK: - Setup
    
    private func setupNestedPageViewController() {
        // 设置数据源
        dataSource = self
        
        // 设置代理(继承方式下,可以直接重写代理方法)
        delegate = self
    }
    
    // MARK: - NestedPageViewControllerDelegate
    
    // 页面横向滚动到指定索引位置的回调方法
    override func pageViewController(_ pageViewController: NestedPageViewController, didScrollToPageAt index: Int) {
        super.pageViewController(pageViewController, didScrollToPageAt: index)
        
        // 页面切换回调
        print("当前页面索引: \(index)")
    }
    
    // 内容垂直滚动视图的滚动状态变化回调方法
    override func pageViewController(_ pageViewController: NestedPageViewController, contentScrollViewDidScroll scrollView: UIScrollView, headerOffset: CGFloat, isSticked: Bool) {
        super.pageViewController(pageViewController, contentScrollViewDidScroll: scrollView, headerOffset: headerOffset, isSticked: isSticked)
        
        // headerOffset: 头部相对contentScrollView顶部的偏移量
        // isSticked: 是否处于完全吸顶状态
        
        // 例如:根据滚动状态控制导航栏的显示/隐藏
        if isSticked {
            // 头部完全吸顶,可以显示导航栏标题
        } else {
            // 头部未完全吸顶,可以隐藏导航栏标题
        }
    }
}

// MARK: - NestedPageViewControllerDataSource

extension YourNestedPageViewController: NestedPageViewControllerDataSource {
    
    func numberOfViewControllers(in pageViewController: NestedPageViewController) -> Int {
        return childControllerTitles.count
    }
    
    func pageViewController(_ pageViewController: NestedPageViewController, viewControllerAt index: Int) -> NestedPageScrollable? {
        guard index >= 0 && index < childControllerTitles.count else { return nil }
        
        switch index {
        case 0:
            return YourChildViewController1()  // 必须遵守NestedPageScrollable协议
        case 1:
            return YourChildViewController2()  // 必须遵守NestedPageScrollable协议
        case 2:
            return YourChildViewController3()  // 必须遵守NestedPageScrollable协议
        case 3:
            return YourChildViewController4()  // 必须遵守NestedPageScrollable协议
        default:
            return nil
        }
    }
    
    func coverView(in pageViewController: NestedPageViewController) -> UIView? {
        return coverView
    }
    
    func heightForCoverView(in pageViewController: NestedPageViewController) -> CGFloat {
        return 200.0
    }
    
    func tabStrip(in pageViewController: NestedPageViewController) -> UIView? {
        return customTabStrip  // 使用自定义标签栏
    }
    
    func heightForTabStrip(in pageViewController: NestedPageViewController) -> CGFloat {
        return 50.0
    }
    
    func titlesForTabStrip(in pageViewController: NestedPageViewController) -> [String]? {
        return nil  // 使用自定义标签栏时返回nil
    }
}

Objective-C 使用方式

NestedPageViewController原本是用OC编写,考虑到swift是主流,于是改成了swift版本,OC工程要使用需要做一个桥接。

示例工程中提供了完整的 Objective-C 桥接示例,可以参考 Example/NestedPageExample/Examples-OC 目录下的实现。

切页与布局更新的位置保持

keepsContentScrollPosition 默认是 false,统一控制非吸顶状态下切页和 updateLayouts() 时的位置保持:

nestedPageViewController.keepsContentScrollPosition = true
// 修改头部高度的数据源后更新布局。
nestedPageViewController.updateLayouts()

设为 true 时,更新布局会保留已加载列表的内容相对 tabStrip 底边的位置:顶部保持展开,吸顶保持吸顶,半展开时保留已折叠高度;无法承接的位置收敛到有效滚动范围。此行为适用于头部尺寸变化且子列表内容布局不变的场景,不负责数据增删或 cell 高度变化后的内容锚定。首次加载和 rebuild() 仍从初始位置开始。

兼容性说明:以前 updateLayouts() 不受此属性控制、总是重置位置;现在设为 true 会保留位置。默认 false 的重置行为不变。Demo 可在设置中开启“保持内容滚动位置”,再进入“运行时修改头部高度”示例验证。

短内容的自动滚动范围

autoAdjustsContentSizeMinimumHeight 默认是 true。短列表、空列表也会获得足够的滚动范围,使标签栏可以吸顶并在切页时保持位置。适用于原生 UITableView、Flow Layout、Compositional Layout,不要求自定义列表或 layout。

属性名称为兼容旧版保留。实现使用组件管理的 contentInset.bottom 补足空间,不再通过 KVO 回写 contentSize;列表的真实 contentSize 仍由其布局决定。数据、视口尺寸、头部高度变化时重新计算,长列表不额外补足;关闭该属性或移除子页面时移除自动补足量。滚动条不包含这部分空白补足量。

设为 false 时不会补足短内容。切到短列表或内容缩短后,如果原来的折叠位置超出该页的实际滚动范围,组件会同步回退列表位置并展开 header,避免头部留白;因此不保证短列表仍能保持吸顶。能够承接原位置的长列表不受影响。

如果业务需要动态设置底部工具栏、键盘等 inset,推荐通过以下接口设置业务值,避免读到包含自动补足量的合成值:

nestedPageViewController.setContentBottomInset(90, for: collectionView)
let businessInset = nestedPageViewController.contentBottomInset(for: collectionView)
nestedPageViewController.setContentBottomInset(businessInset + 20, for: collectionView)

直接赋予不同的 contentInset.bottom 仍会被识别为新的业务值;但不要基于 contentInset.bottom 的合成值进行增减或保存后恢复。第三方底部刷新控件如自行增减该值,也需要协调其 inset 所有权(或关闭自动补足,接受短列表不能保持吸顶)。仅调整 top 的下拉刷新不影响已保存的业务 bottom inset。

短内容、反复切页和头部高度变化的回归覆盖位于 Tests/NestedPageViewControllerTests,包含原生 UITableView、Flow Layout 和 Compositional Layout。

性能报告

NestedPageViewController在性能方面进行了多项优化,确保在复杂的嵌套滚动场景下仍能保持流畅的用户体验。以下是demo中4个子控制下的性能评测:

内存占用

内存占用

CPU使用率

CPU使用率

实现原理

参见实现原理

项目起源

本仓库的前身是我在8年前开发的一个名为HVScrollView的演示项目。当时由于经验有限,未能将其封装成一个通用组件。项目的思想萌芽实际上源自腾讯bugly发布的一篇关于特斯拉组件的文章,该文章介绍了iOS高性能PageController的实现原理。

时光荏苒,8年过去了,我积累了更多的开发经验和技术沉淀,现在将这个想法重新实现并开源,希望能为iOS开发社区提供一个更加完善、易用的嵌套滚动解决方案。NestedPageViewController在保留原有思想精髓的基础上,进一步优化了性能和用户体验,为现代iOS应用提供了更加流畅的页面嵌套滚动效果。

参与贡献

由于本人工作繁忙,可能无法投入大量时间进行持续的更新迭代。我们非常欢迎有兴趣的开发者加入到项目中来,通过提交Pull Request的方式参与贡献。无论是功能改进、bug修复、文档完善还是性能优化,您的每一份贡献都将帮助这个项目变得更好。

如果您有任何问题或建议,也欢迎通过Issues进行讨论,或直接联系作者邮箱:lesp163@163.com。让我们一起打造更好的NestedPageViewController!

许可证

NestedPageViewController 使用 MIT 许可证。详情请查看 LICENSE 文件。