A pinned-header pager container for iOS.
用更少的胶水代码,搭起
Header + Sticky Tab + Horizontal Pages + Nested Scroll这类复杂页面。
CLPinnedPage 是一个面向 iOS 的吸顶分页容器,专门解决复杂页面里的两个核心问题:
- 外层头部区域与内层列表之间的嵌套滚动协调
- 横向分页、吸顶悬停区域和页面生命周期管理
它适合这类页面:个人主页、商品详情、频道页、内容聚合页、资讯/视频/社区首页,以及所有 header + tab + list pages 的经典结构。
- 吸顶结构开箱即用:天然支持
headerView + hoverView + page组合 - 嵌套滚动顺滑:外层收起 header,内层接管列表;回拉到顶后外层重新接管
- 双模式接入:同时支持
UIView页面和UIViewController页面 - 懒加载友好:页数多时按需创建,减少首屏初始化成本
- 代理无侵入:不会破坏页面内部原有的
scrollView.delegate - API 克制:对外接口少,接入路径清晰,不要求你改现有页面架构
| 方案 | 组件 | 适合场景 |
|---|---|---|
| View 模式 | CLPinnedPageView |
页面本身就是 UIView,例如 table/collection/custom scroll page |
| Controller 模式 | CLPinnedControllerPageView |
每一页都需要独立控制器生命周期、状态管理或更复杂业务 |
仓库中的 demo 对应:
- View 模式:
CLPinnedPage/Demo/ViewMode/CLViewModeController.swift - Controller 模式:
CLPinnedPage/Demo/ControllerMode/CLControllerModeController.swift
CLPinnedPageView / CLPinnedControllerPageView
└── mainScrollView
├── headerView // 顶部内容区域,可滚出
└── body
├── hoverView // 悬停区域,吸顶后保持可见
└── contentScrollView // 横向分页容器
├── page 0
├── page 1
└── ...
滚动行为:
- 先由外层容器滚动
headerView - 当
headerView完全收起后,当前页面的scrollView接管纵向滚动 - 当页面滚回顶部,外层容器重新接管
这正是多数复杂首页、个人主页、详情页想要的交互模型。
- 吸顶分页容器
- 横向分页滚动
- 懒加载 / 非懒加载
UIView/UIViewController双模式- 自动桥接页面
scrollView.delegate - 可配置滚动条显示
- 支持代码切页
- 接入成本低,适合直接嵌入现有业务页面
- iOS 13.0+
- Swift 5.0+
SnapKit
let package = Package(
dependencies: [
.package(url: "https://github.com/JmoVxia/CLPinnedPage.git", from: "1.0.0")
],
targets: [
.target(
name: "YourTarget",
dependencies: ["CLPinnedPage"]
)
]
)当前仓库内已通过
Package.swift声明SnapKit依赖,SPM 会自动解析。
当你的每一页本身就是一个 UIView 时,使用 CLPinnedPageView。
import UIKit
import SnapKit
import CLPinnedPage
final class DemoViewController: UIViewController {
private lazy var pagerView: CLPinnedPageView = {
let view = CLPinnedPageView(isLazyLoading: false)
view.dataSource = self
view.delegate = self
return view
}()
private let titles = ["首页", "发现", "我的", "设置"]
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(pagerView)
pagerView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
let headerView = UIView()
headerView.backgroundColor = .systemOrange
headerView.snp.makeConstraints { make in
make.height.equalTo(240)
}
let hoverView = UIView()
hoverView.backgroundColor = .white
hoverView.snp.makeConstraints { make in
make.height.equalTo(60)
}
pagerView.headerView = headerView
pagerView.hoverView = hoverView
pagerView.reload()
}
}
extension DemoViewController: CLPinnedPageViewDataSource {
func numberOfPages(in pageView: CLPinnedPageView) -> Int {
titles.count
}
func pageView(_ pageView: CLPinnedPageView, pageAt index: Int) -> CLPinnedViewPage {
DemoPageView()
}
}
extension DemoViewController: CLPinnedPageViewDelegate {
func pageView(_ pageView: CLPinnedPageView, didScrollToPageAt index: Int) {
print("scroll to page", index)
}
}页面对象只需要实现 CLPinnedViewPage:
final class DemoPageView: UIView, CLPinnedViewPage {
private let tableView = UITableView(frame: .zero, style: .plain)
var scrollView: UIScrollView { tableView }
override init(frame: CGRect) {
super.init(frame: frame)
tableView.contentInsetAdjustmentBehavior = .never
addSubview(tableView)
tableView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}对应 demo:CLPinnedPage/Demo/ViewMode/CLPageView.swift
当你的每一页都需要独立控制器生命周期时,使用 CLPinnedControllerPageView。
import UIKit
import SnapKit
import CLPinnedPage
final class DemoViewController: UIViewController {
private lazy var pagerView: CLPinnedControllerPageView = {
let view = CLPinnedControllerPageView(isLazyLoading: true)
view.dataSource = self
view.delegate = self
return view
}()
private var controllers: [Int: DemoPageController] = [:]
private let titles = ["推荐", "热点", "收藏", "设置"]
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(pagerView)
pagerView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
let headerView = UIView()
headerView.backgroundColor = .systemTeal
headerView.snp.makeConstraints { make in
make.height.equalTo(240)
}
let hoverView = UIView()
hoverView.backgroundColor = .white
hoverView.snp.makeConstraints { make in
make.height.equalTo(60)
}
pagerView.headerView = headerView
pagerView.hoverView = hoverView
pagerView.reload()
}
}
extension DemoViewController: CLPinnedControllerPageViewDataSource {
func numberOfPages(in controllerPageView: CLPinnedControllerPageView) -> Int {
titles.count
}
func controllerPageView(_ controllerPageView: CLPinnedControllerPageView, pageControllerAt index: Int) -> CLPinnedControllerPage {
if let controller = controllers[index] {
return controller
}
let controller = DemoPageController()
controllers[index] = controller
return controller
}
func parentViewController(for controllerPageView: CLPinnedControllerPageView) -> UIViewController {
self
}
}子控制器只需要实现 CLPinnedControllerPage:
final class DemoPageController: UIViewController, CLPinnedControllerPage {
private let tableView = UITableView(frame: .zero, style: .plain)
var scrollView: UIScrollView { tableView }
}对应 demo:CLPinnedPage/Demo/ControllerMode/CLDemoTablePageController.swift
public protocol CLPinnedViewPage: AnyObject where Self: UIView {
var scrollView: UIScrollView { get }
}适用于 View 模式。你的页面只需要暴露一个主滚动视图,剩下的联动交给容器处理。
public protocol CLPinnedControllerPage: AnyObject where Self: UIViewController {
var scrollView: UIScrollView { get }
}适用于 Controller 模式。容器会托管子控制器的安装与可见生命周期切换。
public init(frame: CGRect = .zero, isLazyLoading: Bool = true)
func reload(toPageAt index: Int? = nil)
func scrollToPage(at index: Int, animated: Bool)
func page(at index: Int) -> CLPinnedViewPage?常用属性:
headerViewhoverViewdataSourcedelegateshowIndicatorisHorizontalScrollEnabledcurrentPageIndexcurrentPagenumberOfPagesisLazyLoadingEnabled
public init(frame: CGRect = .zero, isLazyLoading: Bool = true)
func reload(toPageAt index: Int? = nil)
func scrollToPage(at index: Int, animated: Bool)常用属性:
headerViewhoverViewdataSourcedelegateshowIndicatorisHorizontalScrollEnabledcurrentPageIndexcurrentControllernumberOfPagesisLazyLoadingEnabled
func pageView(_ pageView: CLPinnedPageView, contentScrollViewDidScroll scrollView: UIScrollView, progress: CGFloat)
func pageView(_ pageView: CLPinnedPageView, didScrollToPageAt index: Int)func controllerPageView(_ controllerPageView: CLPinnedControllerPageView, contentScrollViewDidScroll scrollView: UIScrollView, progress: CGFloat)
func controllerPageView(_ controllerPageView: CLPinnedControllerPageView, didScrollToPageAt index: Int)progress 是横向分页滚动进度。你可以直接拿它驱动顶部分类栏、指示器位移、标题颜色过渡等联动效果。demo 里的 CLSegmentedBar 就是这个用法。
headerView和hoverView必须有确定高度- 赋值完
dataSource、headerView、hoverView之后,再调用reload() - 页面内部依然可以正常设置自己的
tableView.delegate/collectionView.delegate - 如果不需要横向分页,可以设置
isHorizontalScrollEnabled = false - 页面很多时优先使用懒加载;页面很少且追求切换稳定性时,可以使用非懒加载
- 如果没有
headerView,页面会直接接管纵向滚动
仓库内置了两个可直接运行的示例:
CLPinnedPage/Demo/ViewMode/CLViewModeController.swift说明:纯UIView页面容器方案CLPinnedPage/Demo/ControllerMode/CLControllerModeController.swift说明:子控制器托管方案
如果你要评估是否适合业务接入,建议先从这两个 demo 开始。
- 个人主页 / 作者主页
- 商品详情页
- 频道页 / 社区页 / 资讯首页
- 视频、直播、图文等内容聚合页
- 所有需要吸顶 tab 和多页列表联动的容器页
CLPinnedPage is available under the MIT license. See the LICENSE file for more info.