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

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

结尾

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350