• 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 位置展示.