iOS(Swift) 嵌套滚动策略

日常业务中,总是会有一个业务,基本是每个iOS 开发都会做到的需求,就是顶部一个 header,底部一个 segmenter + pagecontroller, 又因为这么布局,UI 显得太过长,通常要求 header 能划走,然后底部占据一屏,这种业务一般也是十分的麻烦的,这里为大家介绍下我司的嵌套滚动策略.
先给大家看下最终在业务中的效果.


接下来我会一点点地讲讲如何去封装这个嵌套滚动的控件

外层框架

首先,我们需要设计设计的这个外层框架,即 scrollview 嵌套的 header+segmenter+pager



这里要注意两点

    1. segmenter 和 pager 的高度需要刚好满足 view.height(scrollview.frame.height),这样在外层 scrollview 滑动到底部时,segmenter+pager 刚好满足scrollview.frame.height,刚好满屏展示
    1. 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 {
        60
    }
}

我们对子控制器做了一点预先的配置,但是无伤大雅,我们并没有应用这些配置.这时候的页面状况是这样的.



仅仅只有pager 里的 tableviewController 能响应pan 事件,外层的 scrollview 无法响应.

设置联动

要想让外层的 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) {
        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)
// 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 位置展示.

这里我都只是手指拖动了一小块位置,最终位置都是由代码调整的.

结尾

联动的效果我是以最少的代码去实现,公司项目内描述的逻辑较多,我怕有人想绕了,大家知道这种设计的目的和效果即可,不过这里还有一个问题,我需要对 每个子 table 进行设置监听外层的 scroll 滚动状态,不符合封装的思想,而且,segmenter 其实也可以和 pager 抽到一起,毕竟99%的 UI 中,pager 总是会跟着 segmenter.
如果有时间的话(flag 狂立),我会介绍一个更加庞大的巨制TableProvider&CollectProvider封装策略
他是整合了 DataSource,完全以数据驱动 UI, TableController内部所有不想手动重复书写的配置(Skeleton, emptyView, cell复用等等, 高度计算等等),完全无公害无污染.
当然,东西记在脑子里,写有时候是真懒得写囧,还有,不好意思,Toast 组件封装我又鸽了~,那东西写起来也麻烦,后续有时间的话我只去介绍展示的策略好了.

你可能感兴趣的:(iOS(Swift) 嵌套滚动策略)