segmenter 和 pager 的高度需要刚好满足 view.height(scrollview.frame.height),这样在外层 scrollview 滑动到底部时,segmenter+pager 刚好满足scrollview.frame.height,刚好满屏展示
scrollview 的 contentsize 刚好等于 header+segmenter+pager 的高度,这样刚好满足,外层 scrollview 滑动 header.height 的距离时,刚好展示 segmenter+pager 的 height
这里展示下最普通的代码
我们抽出一个
MultiScrollViewController
层父类
class MultiScrollViewController: UIViewController {
var shouldHideShadow: Bool = false
var scrollView = UIScrollView()
var pager = YTPageController()
var scrollState: ScrollState = .pending
var lastContentOffset: CGPoint = .zero
var currentViewController: ScrollStateful? {
pager.currentViewController as? ScrollStateful
var resetAfterLayout = true
var snapbackEnabled = true
enum ScrollDirection: Int {
case pending, up, down
private var lastDirection: ScrollDirection = .pending
override func viewDidLoad() {
super.viewDidLoad()
self.automaticallyAdjustsScrollViewInsets = false
if #available(iOS 11.0, *) {
scrollView.contentInsetAdjustmentBehavior = .never
scrollView.clipsToBounds = false
scrollView.scrollsToTop = false
scrollView.bounces = false
scrollView.delegate = self
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.add(to: view)
scrollView.snp.makeConstraints { (make) in
make.top.equalTo(view.pin.safeArea.top)
make.left.right.bottom.equalToSuperview()
然后用一个子控制器取继承它
class HomeViewController: MultiScrollViewController {
//MARK:- --------------------------------------infoProperty
//MARK:- --------------------------------------UIProperty
let header = UIView(.oldPink)
let segmenter = UIView(#colorLiteral(red: 0.9529411793, green: 0.6862745285, blue: 0.1333333403, alpha: 1))
let sameCity = SameCityViewController()
let online = OnlineViewController(style: .plain)
//MARK:- --------------------------------------system
override func viewDidLoad() {
super.viewDidLoad()
isNavHidden = true
header.add(to: scrollView)
segmenter.add(to: scrollView)
pager.move(to: self, viewFrame: view.bounds)
pager.view.add(to: scrollView)
pager.viewControllers = [sameCity, online]
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
header.pin.left().top().right().height(150)
segmenter.pin.top(to: header.edge.bottom).left().right().height(44)
pager.view.pin.left().right().top(to: segmenter.edge.bottom).height(view.height - segmenter.height)
scrollView.contentSize = MakeSize(view.size.width, pager.view.bottom)
//MARK:- --------------------------------------actions
//MARK:- --------------------------------------net
deinit {
log("💀💀💀------------ \(Self.self)")
内层 table
子控制器的布局我使用的是 PinLayout,效果等同与 frame 设置,大家理解一下即可,需要注意的是,布局viewDidLayoutSubviews
,这里让子视图 layout 之后,再设置 scroll 的contentSize
子控制器没啥好讲的,就是个很普通的 tablviewController
class OnlineViewController: UITableViewController, UIGestureRecognizerDelegate, ScrollStateful {
var scrollView: UIScrollView {
self.tableView
var scrollState: ScrollState = .pending
var lastContentOffset: CGPoint = .zero
var list:[Int] = []
weak var p: MultiScrollViewController?
required init?(coder: NSCoder) {
super.init(coder: coder)
configTable()
override init(style: UITableView.Style) {
super.init(style: style)
configTable()
func configTable() {
let t = TableView(frame: .screenBounds, style: tableView.style)
tableView = t
// t.panDelegate = self
tableView.separatorInset = .init(left: Const.hMargin)
tableView.separatorColor = .hex("#D8D8D8")
tableView.tableFooterView = UIView()
tableView.backgroundColor = .white
tableView.tableHeaderView = UIView(height: .min)
tableView.tableFooterView = UIView(height: .min)
tableView.rowHeight = 0
tableView.estimatedRowHeight = 0
tableView.estimatedSectionHeaderHeight = 0
tableView.estimatedSectionFooterHeight = 0
tableView.registReusable(OnlienCell.self)
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.registReusable(OnlienCell.self)
list = (0..<9).compactMap { $0 }
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
list.count
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.reuseCell(for: indexPath, cellType: OnlienCell.self)
cell.textLabel?.text = "第\(list[indexPath.row])个"
return cell
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
我们对子控制器做了一点预先的配置,但是无伤大雅,我们并没有应用这些配置.这时候的页面状况是这样的.
要想让外层的 scrollview 同时也能响应到当前触发的手势,我们需要用到 iOS 的一个手势代理.
optional func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
This method is called when recognition of a gesture by either gestureRecognizer or otherGestureRecognizer would block the other gesture recognizer from recognizing its gesture. Note that returning true is guaranteed to allow simultaneous recognition; returning false, on the other hand, is not guaranteed to prevent simultaneous recognition because the other gesture recognizer's delegate may return true.
当手势识别器或其他手势识别器对某个手势的识别会阻止其他手势识别器识别其手势时,就会调用此方法。注意,返回true保证允许同时识别;另一方面,返回false不能保证防止同时识别,因为其他手势识别器的委托可能返回true。
并且,我们需要在 tableview 层去允许识别.这里做了一个判断,及当otherGestureRecognizer == MultiScrollViewController?.scrollView.panGestureRecognizer
时,才允许识别
但是要注意,这个代理会走你的响应链视图,所以我们需要让它只支持响应 MultiScrollViewController.Scrollview
,并且我们需要自定义 tableView去实现代理
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
log("~~~otherGestureRecognizer:" + (otherGestureRecognizer.view?.className ?? ""))
return true
// 打印
[29/06/2021 19:54:20.148] ~~~otherGestureRecognizer:UIScrollView
[29/06/2021 19:54:20.151] ~~~otherGestureRecognizer:UIScrollView
[29/06/2021 19:54:20.152] ~~~otherGestureRecognizer:UICollectionView
[29/06/2021 19:54:20.152] ~~~otherGestureRecognizer:UICollectionView
[29/06/2021 19:54:20.152] ~~~otherGestureRecognizer:UICollectionView
[29/06/2021 19:54:20.152] ~~~otherGestureRecognizer:UIView
所以我自定义了一个 table,并且抛出手势的响应,让controller 去处理
// TableView
class TableView: UITableView, UIGestureRecognizerDelegate {
weak var panDelegate: UIGestureRecognizerDelegate?
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if let delegate = panDelegate {
if let result = delegate.gestureRecognizer?(gestureRecognizer, shouldRecognizeSimultaneouslyWith: otherGestureRecognizer) {
return result
return false
// tableViewController
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if otherGestureRecognizer == p?.scrollView.panGestureRecognizer {
return true
} else {
return false
这个p
是tableController通过 while 取到的
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
lastContentOffset = tableView.contentOffset
guard isMultiScrollChecked == false else { return }
isMultiScrollChecked = true
var p = self.parent
while p != nil, !(p is MultiScrollViewController) {
p = p?.parent
if let p = p as? MultiScrollViewController {
self.p = p
enableMultiScroll(self, in: p)
添加响应后,我们的代码ui 响应目前会变成这样
这时候已经可以看到能够联动scroll 和内部的 table 了,但是我们希望等外层Scroll滚动到底部时,table 才开始滚动,所以我们需要在外层滚动时,不断设置内部的 contentOffset
这里我们开始给 scroll 添加状态,以状态驱动 scrollview 的滚动
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView == self.scrollView else { return }
let offSetY = scrollView.contentOffset.y
if offSetY >= scrollView.contentSize.height - scrollView.frame.height {// 只要到达顶部就属于 end 状态
scrollState = .ended
scrollView.contentOffset.y = scrollView.contentSize.height - scrollView.frame.height
} else if offSetY > 0 {// 中间的任意状态都属于 scrolling 状态
scrollState = .scrolling
} else if offSetY <= 0 {// 只要小于等于0就属于 pending 状态
scrollState = .pending
scrollView.contentOffset.y = 0
if scrollView.contentOffset.y > lastContentOffset.y {
lastDirection = .up
} else {
lastDirection = .down
lastContentOffset = scrollView.contentOffset
我们设定3种状态,pending,scrolling,ended,当且仅当 offSetY > 0 && offSet < scrollView.contentSize.height - scrollView.frame.height
时,外层 scroll 属于 scrolling 状态,这时候,我们一开始对 外层scroll的 contentSize 内容高度设置的用处就提现出来了,我们会发现,外层的 scroll 总共也就只能在这个范围内滑动,我们需要的只是一种状态的设置,用来告诉里层,外层正在滑动.
而在里层 scroll,即 page 里 tableviewController,我们只需监听一种状态
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let p = p, p.scrollState == .scrolling {//只要外层 scroll 属于 scrolling 状态 我们就一直固定里层的
scrollView.contentOffset = self.lastContentOffset
注意这段代码只能放在 里层的 scrollDidScroll 代理中执行,放在外层 scroll 代理中手动对 page.current.scrollview赋值,是起不了作用的.lastContentOffset是用在 viewWillAppear时进行赋值.
这里写完我们基本的逻辑已经完成了.看看效果
但是这里还有个小瑕疵,就是有时候在非常快速滚动时,由于 scrollDidScroll 来不及响应,pager 和外层会出现断连的空挡,这时候,我们可以扯上一块遮羞布
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
guard snapbackEnabled == true else { return }
if velocity.y <= .min, scrollState == .scrolling {
if lastDirection == .up {
targetContentOffset.assign(repeating: CGPoint(x: 0, y: scrollView.contentSize.height - scrollView.frame.height), count: 1)
} else {
targetContentOffset.assign(repeating: .zero, count: 1)
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>)
// called on finger up if the user dragged. velocity is in points/millisecond. targetContentOffset may be changed to adjust where the scroll view comes to rest
//如果用户拖动,则调用finger up。速度单位为点/毫秒。targetContentOffset可以更改以调整滚动视图的静止位置
当停止拖住的时候,判断滚动方向,去 assign 我们最终的停留位置,这样就可以保护到我们的 UI 位置展示.