一个强大的 iOS 嵌套滑动框架,提供头部视图、悬停视图和分页内容之间的无缝滚动协调。
- 🔄 嵌套滚动协调: 主滚动视图与子滚动视图之间的丝滑协调
- 📱 头部和悬停视图: 支持可滚动消失的头部视图和始终可见的悬停视图
- 📄 水平分页: 水平分页滚动,支持懒加载和非懒加载模式
- 🎯 自动代理拦截: 零配置的滚动视图代理转发
- ⚡ 高性能: 支持懒加载模式,内存友好
- 🔧 灵活布局: 完全自定义的布局控制和边距设置
- 📐 旋转适配: 自动处理横竖屏切换时的内容偏移修正
- 🎨 现代架构: 基于协议的设计,易于集成和扩展
- 🔄 横竖屏支持: 内置横竖屏旋转支持,自动适配布局
- 🎛 多手势协调: 支持多个手势识别器同时工作,无冲突
- 📦 多种滚动视图: 支持 UITableView、UICollectionView、UIScrollView 等
- 🎯 零侵入: 不影响原有滚动视图的代理方法实现
- iOS 13.0+
- Swift 5.0+
pod 'CLNestedSlide'
dependencies: [
.package(url: "https://github.com/your-username/CLNestedSlide.git", from: "1.0.0")
]
import CLNestedSlide
class ViewController: UIViewController {
// 推荐:懒加载模式(默认)
private lazy var nestedSlideView: CLNestedSlideView = {
let view = CLNestedSlideView() // 默认启用懒加载
view.dataSource = self
view.delegate = self
return view
}()
override func viewDidLoad() {
super.viewDidLoad()
setupNestedSlideView()
}
private func setupNestedSlideView() {
view.addSubview(nestedSlideView)
nestedSlideView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
// 配置头部和悬停视图(可选)
nestedSlideView.headerView = createHeaderView()
nestedSlideView.hoverView = createHoverView()
// 🔑 关键步骤:调用 reload() 创建页面
nestedSlideView.reload()
}
}
// 🔑 关键:必须实现 CLNestedSlideViewDataSource 协议
extension ViewController: CLNestedSlideViewDataSource {
// 🔑 关键:返回页面总数
func numberOfPages(in nestedSlideView: CLNestedSlideView) -> Int {
return 3 // 你的页面数量
}
// 🔑 关键:为每个索引创建页面视图
// 返回的视图必须遵循 CLNestedSlideViewPage 协议
func nestedSlideView(_ nestedSlideView: CLNestedSlideView, pageFor index: Int) -> CLNestedSlideViewPage {
// 可以根据 index 返回不同类型的页面
switch index {
case 0:
return TablePageView(title: "列表页")
case 1:
return CollectionPageView(title: "集合页")
case 2:
return ScrollPageView(title: "滚动页")
default:
return TablePageView(title: "默认页")
}
}
}
// 🔑 关键:页面视图必须遵循 CLNestedSlideViewPage 协议
class TablePageView: UIView, CLNestedSlideViewPage {
private let tableView = UITableView()
// 🔑 必需协议属性:提供滚动视图用于嵌套滚动
var scrollView: UIScrollView { tableView }
// 以下属性由框架自动管理,无需手动实现
// var isSwipeEnabled: Bool { get set }
// var superScrollEnabledHandler: ((Bool) -> Bool)? { get set }
// func setupScrollViewDelegateIfNeeded()
init(title: String) {
super.init(frame: .zero)
setupUI(title: title)
setupTableView()
}
private func setupUI(title: String) {
backgroundColor = .systemBackground
addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
private func setupTableView() {
// 🔑 重要:可以正常设置代理,框架会自动处理嵌套滚动
tableView.delegate = self
tableView.dataSource = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell")
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// 🔑 重要:你的代理方法正常实现,嵌套滚动会自动协调
extension TablePageView: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return 50
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
cell.textLabel?.text = "第 \(indexPath.row + 1) 行"
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 🔑 重要:你的自定义滚动逻辑正常写,嵌套滚动协调自动处理
print("页面内部滚动: \(scrollView.contentOffset.y)")
}
}
extension ViewController: CLNestedSlideViewDelegate {
func contentScrollViewDidScroll(_ nestedSlideView: CLNestedSlideView, scrollView: UIScrollView, progress: CGFloat) {
// 水平分页滚动时调用,progress 为滚动进度(0.0 到 页面数-1)
print("页面切换进度: \(progress)")
}
func contentScrollViewDidScrollToPage(at index: Int) {
// 滚动到指定页面时调用
print("切换到第 \(index + 1) 页")
}
}
private func createHeaderView() -> UIView {
let header = UIView()
header.backgroundColor = .systemBlue
// ⚠️ 重要:必须设置高度,否则不显示
header.snp.makeConstraints { make in
make.height.equalTo(200)
}
// 添加内容...
let label = UILabel()
label.text = "这是头部视图"
label.textAlignment = .center
label.textColor = .white
header.addSubview(label)
label.snp.makeConstraints { make in
make.center.equalToSuperview()
}
return header
}
private func createHoverView() -> UIView {
let hover = UIView()
hover.backgroundColor = .systemGray6
// ⚠️ 重要:必须设置高度,否则不显示
hover.snp.makeConstraints { make in
make.height.equalTo(50)
}
// 添加内容...
let segmentedControl = UISegmentedControl(items: ["页面1", "页面2", "页面3"])
segmentedControl.selectedSegmentIndex = 0
hover.addSubview(segmentedControl)
segmentedControl.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(16)
}
return hover
}
推荐做法:
- 强烈建议将 headerView/hoverView 封装为自定义 UIView 子类,并在自定义 view 内部通过 AutoLayout 约束撑开高度。
- 这样可以让视图高度自适应内容,便于后续扩展和维护。
- 不推荐直接在外部用 frame 设置高度。
class CustomHeaderView: UIView {
private let titleLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupUI()
}
private func setupUI() {
backgroundColor = .systemBlue
titleLabel.text = "这是头部视图"
titleLabel.textColor = .white
titleLabel.textAlignment = .center
addSubview(titleLabel)
titleLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
// 关键:用约束撑开高度
self.snp.makeConstraints { make in
make.height.equalTo(200)
}
}
}
// 使用
nestedSlideView.headerView = CustomHeaderView()
同理,悬浮视图也建议自定义:
class CustomHoverView: UIView {
private let segmentedControl = UISegmentedControl(items: ["页面1", "页面2", "页面3"])
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupUI()
}
private func setupUI() {
backgroundColor = .systemGray6
addSubview(segmentedControl)
segmentedControl.selectedSegmentIndex = 0
segmentedControl.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(16)
}
// 关键:用约束撑开高度
self.snp.makeConstraints { make in
make.height.equalTo(50)
}
}
}
// 使用
nestedSlideView.hoverView = CustomHoverView()
protocol CLNestedSlideViewPage: AnyObject where Self: UIView {
// 🔑 必需实现:提供内部的滚动视图
var scrollView: UIScrollView { get }
// 以下属性和方法由框架自动管理,无需手动实现:
// var isSwipeEnabled: Bool { get set }
// var superScrollEnabledHandler: ((Bool) -> Bool)? { get set }
// func setupScrollViewDelegateIfNeeded()
}
- ✅ UITableView:最常用,支持各种 delegate 方法
- ✅ UICollectionView:支持网格布局和自定义布局
- ✅ UIScrollView:支持自定义滚动内容
- ✅ WKWebView:web 内容滚动(需要返回 scrollView 属性)
// UICollectionView 页面
class CollectionPageView: UIView, CLNestedSlideViewPage {
private let collectionView: UICollectionView
var scrollView: UIScrollView { collectionView }
init() {
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: 100, height: 100)
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
super.init(frame: .zero)
// 正常设置代理
collectionView.delegate = self
collectionView.dataSource = self
}
}
// 自定义 UIScrollView 页面
class ScrollPageView: UIView, CLNestedSlideViewPage {
private let customScrollView = UIScrollView()
var scrollView: UIScrollView { customScrollView }
init() {
super.init(frame: .zero)
setupScrollView()
}
private func setupScrollView() {
// 正常配置 scrollView
customScrollView.delegate = self
// 添加内容...
}
}
-
Q: headerView/hoverView 设置了但是看不到?
A: 检查是否设置了高度约束或 intrinsicContentSize,没有高度的视图不会显示。 -
Q: 页面不能滚动或者滚动有问题?
A: 确保页面视图遵循了CLNestedSlideViewPage
协议,并正确实现了scrollView
属性。 -
Q: 可以动态改变 headerView/hoverView 的高度吗?
A: 可以,直接修改自定义 view 内部的高度约束即可,布局会自动更新。 -
Q: 如何禁用横向滑动?
A: 设置nestedSlideView.isHorizontalScrollEnabled = false
-
Q: 页面数量变化后怎么办?
A: 更新数据源后调用nestedSlideView.reload()
重新加载页面。
extension ViewController: CLNestedSlideViewDelegate {
func contentScrollViewDidScroll(_ nestedSlideView: CLNestedSlideView, scrollView: UIScrollView, progress: CGFloat) {
// 水平分页滚动时调用,progress 为滚动进度(0.0 到 页面数-1)
print("页面切换进度: \(progress)")
}
func contentScrollViewDidScrollToPage(at index: Int) {
// 滚动到指定页面时调用
print("切换到第 \(index + 1) 页")
}
}
// 导航到指定页面
nestedSlideView.scrollToPage(at: 1, animated: true)
// 获取当前页面索引
let currentIndex = nestedSlideView.currentPageIndex
// 设置当前页面索引
nestedSlideView.currentPageIndex = 2
// 获取当前可见页面
if let currentPage = nestedSlideView.currentPage {
// 操作当前页面
}
// 获取指定索引的页面
if let page = nestedSlideView.page(at: 1) {
// 操作指定页面
}
// 重新加载所有页面
nestedSlideView.reload()
框架支持两种加载策略:
let nestedSlideView = CLNestedSlideView() // 默认懒加载
// 或者显式指定
let nestedSlideView = CLNestedSlideView(isLazyLoading: true)
- 优点: 页面按需创建,内存友好,适合大量页面
- 缺点: 首次访问页面时有轻微延迟
- 适用场景: 页面数量较多(> 5 页)或页面内容复杂
let nestedSlideView = CLNestedSlideView(isLazyLoading: false)
- 优点: 所有页面在
reload()
时创建,访问速度快 - 缺点: 内存占用较高
- 适用场景: 页面数量较少(< 5 页)且需要快速切换
注意: 加载模式必须在初始化时设置,之后无法更改。
// 主堆栈视图(整体布局)
nestedSlideView.mainStackSpacing = 10.0
nestedSlideView.mainStackMargins = UIEdgeInsets(top: 20, left: 16, bottom: 20, right: 16)
// 顶部堆栈视图(headerView 和 hoverView)
nestedSlideView.topStackSpacing = 5.0
nestedSlideView.topStackMargins = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8)
// 内容堆栈视图(页面之间)
nestedSlideView.pageSpacing = 0.0
nestedSlideView.contentStackMargins = UIEdgeInsets.zero
// 控制是否允许横向滑动
nestedSlideView.isHorizontalScrollEnabled = true
// 当前页面索引(支持 get/set)
var currentPageIndex: Int
// 总页数(只读)
var numberOfPages: Int
// 当前可见页面(只读)
var currentPage: CLNestedSlideViewPage?
// 是否启用懒加载模式(只读)
var isLazyLoadingEnabled: Bool
// 获取指定索引的页面(懒加载模式下可能返回 nil)
func page(at index: Int) -> CLNestedSlideViewPage?
// 滚动到指定页面
func scrollToPage(at index: Int, animated: Bool)
// 重新加载所有页面
func reload()
// 顶部头部视图
var headerView: UIView?
// 滚动时保持可见的悬停视图
var hoverView: UIView?
// 是否允许横向滑动
var isHorizontalScrollEnabled: Bool
// 主堆栈视图布局控制
var mainStackSpacing: CGFloat
var mainStackMargins: UIEdgeInsets
// 顶部堆栈视图布局控制
var topStackSpacing: CGFloat
var topStackMargins: UIEdgeInsets
// 内容页面之间的间距
var pageSpacing: CGFloat
var contentStackMargins: UIEdgeInsets
主容器视图,管理整体滚动行为和页面协调。
支持多手势同时识别的专用滚动视图,确保手势不冲突。
支持 contentSize 变化监听的滚动视图,自动处理横竖屏切换时的内容偏移修正。
定义参与嵌套滚动的页面视图要求。
protocol CLNestedSlideViewDataSource: AnyObject {
func numberOfPages(in nestedSlideView: CLNestedSlideView) -> Int
func nestedSlideView(_ nestedSlideView: CLNestedSlideView, pageFor index: Int) -> CLNestedSlideViewPage
}
protocol CLNestedSlideViewDelegate: AnyObject {
func contentScrollViewDidScroll(_ nestedSlideView: CLNestedSlideView, scrollView: UIScrollView, progress: CGFloat)
func contentScrollViewDidScrollToPage(at index: Int)
}
protocol CLNestedSlideViewPage: AnyObject where Self: UIView {
var scrollView: UIScrollView { get }
// 框架会自动设置以下属性和方法,你无需实现
var isSwipeEnabled: Bool { get set }
var superScrollEnabledHandler: ((Bool) -> Bool)? { get set }
func setupScrollViewDelegateIfNeeded()
}
就这么简单!框架会在内部处理所有复杂的滚动协调。
框架自动拦截滚动视图代理方法并转发到你的实现。你可以正常使用 scrollView.delegate = self
,无需额外设置。
框架协调主滚动视图和子滚动视图之间的滚动,提供流畅的过渡效果,防止滚动冲突。
页面只需实现 CLNestedSlideViewPage
协议的单个 scrollView
属性。所有滚动协调都自动处理。
- 懒加载(默认): 按需创建页面,节省内存,适合大量页面
- 非懒加载: 预先创建所有页面,适合少量页面,性能更好
- 支持任何类型的滚动视图(UITableView、UICollectionView、UIScrollView)
- 可自定义头部和悬停视图
- 基于页面的水平滚动
- 可配置的布局间距和边距
- 可选择懒加载或非懒加载策略
内置横竖屏切换支持,自动修正内容偏移,确保页面对齐无偏移。
-
内存管理: 框架自动缓存页面以提高性能。如需要,请在页面视图中实现适当的清理逻辑。
-
滚动视图设置: 确保你的滚动视图具有适当的内容大小和约束,以获得最佳滚动行为。
-
代理实现: 你可以正常实现滚动视图代理方法 - 框架确保你的代码和协调逻辑都能正确执行。
-
布局更新: 在重要的数据更改后调用
nestedSlideView.reload()
以刷新页面数量和布局。 -
加载模式选择:
- 页面数量 ≤ 5:建议使用非懒加载模式
- 页面数量 > 5:建议使用懒加载模式
所有内部堆栈视图都设置了 insetsLayoutMarginsFromSafeArea = false
和 isLayoutMarginsRelativeArrangement = true
,让你完全控制布局,无需自动安全区域调整。如果需要,你可以通过布局边距属性手动处理安全区域。
查看包含的演示项目,了解完整的实现示例:
- 自定义头部和悬停视图
- 多种页面类型(基于 UIScrollView)
- 代理方法实现
- 编程式导航
- 布局控制示例
本项目基于 MIT 许可证开源。详见 LICENSE 文件。
Chen JmoVxia - GitHub
欢迎提交 Issue 和 Pull Request!
如果这个库对你有帮助,请给个 ⭐️ 支持一下!