CocoaPods trunk is moving to be read-only. Read more on the blog, there are 7 months to go.

CLPinnedPage 1.0.4

CLPinnedPage 1.0.4

Maintained by ‘JmoVxia’.



  • By
  • JmoVxia

CLPinnedPage

A pinned-header pager container for iOS.

用更少的胶水代码,搭起 Header + Sticky Tab + Horizontal Pages + Nested Scroll 这类复杂页面。

CLPinnedPage 是一个面向 iOS 的吸顶分页容器,专门解决复杂页面里的两个核心问题:

  • 外层头部区域与内层列表之间的嵌套滚动协调
  • 横向分页、吸顶悬停区域和页面生命周期管理

它适合这类页面:个人主页、商品详情、频道页、内容聚合页、资讯/视频/社区首页,以及所有 header + tab + list pages 的经典结构。

Why CLPinnedPage

  • 吸顶结构开箱即用:天然支持 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
            └── ...

滚动行为:

  1. 先由外层容器滚动 headerView
  2. headerView 完全收起后,当前页面的 scrollView 接管纵向滚动
  3. 当页面滚回顶部,外层容器重新接管

这正是多数复杂首页、个人主页、详情页想要的交互模型。

特性

  • 吸顶分页容器
  • 横向分页滚动
  • 懒加载 / 非懒加载
  • UIView / UIViewController 双模式
  • 自动桥接页面 scrollView.delegate
  • 可配置滚动条显示
  • 支持代码切页
  • 接入成本低,适合直接嵌入现有业务页面

环境要求

  • iOS 13.0+
  • Swift 5.0+
  • SnapKit

安装

Swift Package Manager

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 会自动解析。

快速开始

View 模式

当你的每一页本身就是一个 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

Controller 模式

当你的每一页都需要独立控制器生命周期时,使用 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

核心协议

CLPinnedViewPage

public protocol CLPinnedViewPage: AnyObject where Self: UIView {
    var scrollView: UIScrollView { get }
}

适用于 View 模式。你的页面只需要暴露一个主滚动视图,剩下的联动交给容器处理。

CLPinnedControllerPage

public protocol CLPinnedControllerPage: AnyObject where Self: UIViewController {
    var scrollView: UIScrollView { get }
}

适用于 Controller 模式。容器会托管子控制器的安装与可见生命周期切换。

常用 API

CLPinnedPageView

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?

常用属性:

  • headerView
  • hoverView
  • dataSource
  • delegate
  • showIndicator
  • isHorizontalScrollEnabled
  • currentPageIndex
  • currentPage
  • numberOfPages
  • isLazyLoadingEnabled

CLPinnedControllerPageView

public init(frame: CGRect = .zero, isLazyLoading: Bool = true)
func reload(toPageAt index: Int? = nil)
func scrollToPage(at index: Int, animated: Bool)

常用属性:

  • headerView
  • hoverView
  • dataSource
  • delegate
  • showIndicator
  • isHorizontalScrollEnabled
  • currentPageIndex
  • currentController
  • numberOfPages
  • isLazyLoadingEnabled

代理回调

CLPinnedPageViewDelegate

func pageView(_ pageView: CLPinnedPageView, contentScrollViewDidScroll scrollView: UIScrollView, progress: CGFloat)
func pageView(_ pageView: CLPinnedPageView, didScrollToPageAt index: Int)

CLPinnedControllerPageViewDelegate

func controllerPageView(_ controllerPageView: CLPinnedControllerPageView, contentScrollViewDidScroll scrollView: UIScrollView, progress: CGFloat)
func controllerPageView(_ controllerPageView: CLPinnedControllerPageView, didScrollToPageAt index: Int)

progress 是横向分页滚动进度。你可以直接拿它驱动顶部分类栏、指示器位移、标题颜色过渡等联动效果。demo 里的 CLSegmentedBar 就是这个用法。

接入建议

  • headerViewhoverView 必须有确定高度
  • 赋值完 dataSourceheaderViewhoverView 之后,再调用 reload()
  • 页面内部依然可以正常设置自己的 tableView.delegate / collectionView.delegate
  • 如果不需要横向分页,可以设置 isHorizontalScrollEnabled = false
  • 页面很多时优先使用懒加载;页面很少且追求切换稳定性时,可以使用非懒加载
  • 如果没有 headerView,页面会直接接管纵向滚动

Demo

仓库内置了两个可直接运行的示例:

  • CLPinnedPage/Demo/ViewMode/CLViewModeController.swift 说明:纯 UIView 页面容器方案
  • CLPinnedPage/Demo/ControllerMode/CLControllerModeController.swift 说明:子控制器托管方案

如果你要评估是否适合业务接入,建议先从这两个 demo 开始。

适用场景

  • 个人主页 / 作者主页
  • 商品详情页
  • 频道页 / 社区页 / 资讯首页
  • 视频、直播、图文等内容聚合页
  • 所有需要吸顶 tab 和多页列表联动的容器页

License

CLPinnedPage is available under the MIT license. See the LICENSE file for more info.