三种UIScrollView嵌套实现方案

7,976 阅读8分钟

背景

随着产品功能不断的迭代,总会有需求希望在保证不影响其他区域功能的前提下,在某一区域实现根据选择器切换不同的内容显示。

苹果并不推荐嵌套滚动视图,如果直接添加的话,就会出现下图这种情况,手势的冲突造成了体验上的悲剧。

在实际开发中,我也不断的在思考解决方案,经历了几次重构后,有了些改进的经验,因此抽空整理了三种方案,他们实现的最终效果都是一样的。


分而治之

最常见的一种方案就是使用 UITableView 作为外部框架,将子视图的内容通过 UITableViewCell 的方式展现。

这种做法的好处在于解耦性,框架只要接受不同的数据源就能刷新对应的内容。

func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) 
    -> CGFloat {
    if indexPath.section == 0 {
        return NSTHeaderHeight
    }
    
    if segmentView.selectedIndex == 0 {
        return tableSource.tableView(_:tableView, heightForRowAt:indexPath)
    }
    
    return webSource.tableView(_:tableView, heightForRowAt:indexPath)
}

但是相对的也有一个问题,如果内部是一个独立的滚动视图,比如 UIWebView 的子视图 UIWebScrollView,还是会有手势冲突的情况。

常规做法首先禁止内部视图的滚动,当滚动到网页的位置时,启动网页的滚动并禁止外部滚动,反之亦然。

不幸的是,这种方案最大的问题是顿挫感

内部视图初始是不能滚动的,所以外部视图作为整套事件的接收者。当滚动到预设的位置并开启了内部视图的滚动,事件还是传递给唯一接收者外部视图,只有松开手结束事件后重新触发,才能使内部视图开始滚动。

好在有一个方法可以解决这个问题。

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    if scrollView == tableView {
        //外部在滚动
        if offset > anchor {
            //滚到过了锚点,还原外部视图位置,添加偏移到内部
            tableView.setContentOffset(CGPoint(x: 0, y: anchor), animated: false)
            let webOffset = webScrollView.contentOffset.y + offset - anchor
            webScrollView.setContentOffset(CGPoint(x: 0, y: webOffset), animated: false)
        } else if offset < anchor {
            //没滚到锚点,还原位置
            webScrollView.setContentOffset(CGPoint.zero, animated: false)
        }
    } else {
        //内部在滚动
        if offset > 0 {
            //内部滚动还原外部位置
            tableView.setContentOffset(CGPoint(x: 0, y: anchor), animated: false)
        } else if offset < 0 {
            //内部往上滚,添加偏移量到外部视图
            let tableOffset = tableView.contentOffset.y + offset
            tableView.setContentOffset(CGPoint(x: 0, y: tableOffset), animated: false)
            webScrollView.setContentOffset(CGPoint.zero, animated: false)
        }
    }
}

func scrollViewDidEndScroll(_ scrollView: UIScrollView) {
    //根据滚动停止后的偏移量,计算谁可以滚动
    var outsideScrollEnable = true
    if scrollView == tableView {
        if offset == anchor &&
            webScrollView.contentOffset.y > 0 {
            outsideScrollEnable = false
        } else {
            outsideScrollEnable = true
        }
    } else {
        if offset == 0 &&
            tableView.contentOffset.y < anchor {
            outsideScrollEnable = true
        } else {
            outsideScrollEnable = false
        }
    }
    //设置滚动,显示对应的滚动条
    tableView.isScrollEnabled = outsideScrollEnable
    tableView.showsHorizontalScrollIndicator = outsideScrollEnable
    webScrollView.isScrollEnabled = !outsideScrollEnable
    webScrollView.showsHorizontalScrollIndicator = !outsideScrollEnable
}

通过接受滚动回调,我们就可以人为控制滚动行为。当滚动距离超过了我们的预设值,就可以设置另一个视图的偏移量模拟出滚动的效果。滚动状态结束后,再根据判断来定位哪个视图可以滚动。

当然要使用这个方法,我们就必须把两个滚动视图的代理都设置为控制器,可能会对代码逻辑有影响 (UIWebView 是 UIWebScrollView 的代理,后文有解决方案)。

UITableView 嵌套的方式,能够很好的解决嵌套简单视图,遇到 UIWebView 这种复杂情况,也能人为控制解决。但是作为 UITableView 的一环,有很多限制(比如不同数据源需要不同的设定,有的希望动态高度,有的需要插入额外的视图),这些都不能很好的解决。


各自为政

另一种解决方案比较反客为主,灵感来源于下拉刷新的实现方式,也就是将需要显示的内容塞入负一屏。

首先保证子视图撑满全屏,把主视图内容插入子视图,并设置 ContentInset 为头部高度,从而实现效果。

来看下代码实现。

func reloadScrollView() {
    //选择当前显示的视图
    let scrollView = segmentView.selectedIndex == 0 ? 
        tableSource.tableView : webSource.webView.scrollView
    //相同视图就不操作了
    if currentScrollView == scrollView {
        return
    }
    //从上次的视图中移除外部内容
    headLabel.removeFromSuperview()
    segmentView.removeFromSuperview()
    if currentScrollView != nil {
        currentScrollView!.removeFromSuperview()
    }
    //设置新滚动视图的内嵌偏移量为外部内容的高度
    scrollView.contentInset = UIEdgeInsets(top: 
        NSTSegmentHeight + NSTHeaderHeight, left: 0, bottom: 0, right: 0)
    //添加外部内容到新视图上
    scrollView.addSubview(headLabel)
    scrollView.addSubview(segmentView)
    view.addSubview(scrollView)
    
    currentScrollView = scrollView
}

由于在UI层级就只存在一个滚动视图,所以巧妙的避开了冲突。

相对的,插入的头部视图必须要轻量,如果需要和我例子中一样实现浮动栏效果,就要观察偏移量的变化手动定位。

func reloadScrollView() {
    if currentScrollView != nil {
        currentScrollView!.removeFromSuperview()
        //移除之前的 KVO
        observer?.invalidate()
        observer = nil
    }

    //新视图添加滚动观察
    observer = scrollView.observe(\.contentOffset, options: [.new, .initial]) 
    {[weak self] object, change in
        guard let strongSelf = self else {
            return
        }
        let closureScrollView = object as UIScrollView
        var segmentFrame = strongSelf.segmentView.frame
        //计算偏移位置
        let safeOffsetY = closureScrollView.contentOffset.y + 
            closureScrollView.safeAreaInsets.top
        //计算浮动栏位置
        if safeOffsetY < -NSTSegmentHeight {
            segmentFrame.origin.y = -NSTSegmentHeight
        } else {
            segmentFrame.origin.y = safeOffsetY
        }
        strongSelf.segmentView.frame = segmentFrame
    }
}

这方法有一个坑,如果加载的 UITableView 需要显示自己的 SectionHeader ,那么由于设置了 ContentInset ,就会导致浮动位置偏移。

我想到的解决办法就是在回调中不断调整 ContentInset 来解决。

observer = scrollView.observe(\.contentOffset, options: [.new, .initial]) 
{[weak self] object, change in
    guard let strongSelf = self else {
        return
    }
    let closureScrollView = object as UIScrollView
    //计算偏移位置
    let safeOffsetY = closureScrollView.contentOffset.y + 
        closureScrollView.safeAreaInsets.top
    //ContentInset 根据当前滚动定制
    var contentInsetTop = NSTSegmentHeight + NSTHeaderHeight
    if safeOffsetY < 0 {
        contentInsetTop = min(contentInsetTop, fabs(safeOffsetY))
    } else {
        contentInsetTop = 0
    }
    closureScrollView.contentInset = UIEdgeInsets(top: 
    contentInsetTop, left: 0, bottom: 0, right: 0)
}

这个方法好在保证了有且仅有一个滚动视图,所有的手势操作都是原生实现,减少了可能存在的联动问题。

但也有一个小缺陷,那就是头部内容的偏移量都是负数,这不利于三方调用和系统原始调用的实现,需要维护。


中央集权

最后介绍一种比较完善的方案。外部视图采用 UIScrollView ,内部视图永远不可滚动,外部边滚动边调整内部的位置,保证了双方的独立性。

与第二种方法相比,切换不同功能就比较简单,只需要替换内部视图,并实现外部视图的代理,滚动时设置内部视图的偏移量就可以了。

func reloadScrollView() {
    //获取当前数据源
    let contentScrollView = segmentView.selectedIndex == 0 ? 
    tableSource.tableView : webSource.webView.scrollView
    //移除之前的视图
    if currentScrollView != nil {
        currentScrollView!.removeFromSuperview()
    }
    //禁止滚动后添加新视图
    contentScrollView.isScrollEnabled = false
    scrollView.addSubview(contentScrollView)
    //保存当前视图
    currentScrollView = contentScrollView
}

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    //根据偏移量刷新 Segment 和内部视图的位置
    self.view.setNeedsLayout()
    self.view.layoutIfNeeded()
    //根据外部视图数据计算内部视图的偏移量
    var floatOffset = scrollView.contentOffset
    floatOffset.y -= (NSTHeaderHeight + NSTSegmentHeight)
    floatOffset.y = max(floatOffset.y, 0)
    //同步内部视图的偏移
    if currentScrollView?.contentOffset.equalTo(floatOffset) == false {
        currentScrollView?.setContentOffset(floatOffset, animated: false)
    }
}

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    //撑满全部
    scrollView.frame = view.bounds
    //头部固定
    headLabel.frame = CGRect(x: 15, y: 0, 
        width: scrollView.frame.size.width - 30, height: NSTHeaderHeight)
    //Segment的位置是偏移和头部高度的最大值
    //保证滚动到头部位置时不浮动
    segmentView.frame = CGRect(x: 0, 
        y: max(NSTHeaderHeight, scrollView.contentOffset.y), 
        width: scrollView.frame.size.width, height: NSTSegmentHeight)
    //调整内部视图的位置
    if currentScrollView != nil {
        currentScrollView?.frame = CGRect(x: 0, y: segmentView.frame.maxY, 
            width: scrollView.frame.size.width, 
            height: view.bounds.size.height - NSTSegmentHeight)
    }
}

当外部视图开始滚动时,其实一直在根据偏移量调整内部视图的位置。

外部视图的内容高度不是固定的,而是内部视图内容高度加上头部高度,所以需要观察其变化并刷新。

func reloadScrollView() {
    if currentScrollView != nil {
        //移除KVO
        observer?.invalidate()
        observer = nil
    }

    //添加内容尺寸的 KVO
    observer = contentScrollView.observe(\.contentSize, options: [.new, .initial]) 
    {[weak self] object, change in
        guard let strongSelf = self else {
            return
        }
        let closureScrollView = object as UIScrollView
        let contentSizeHeight = NSTHeaderHeight + NSTSegmentHeight + 
            closureScrollView.contentSize.height
        //当内容尺寸改变时,刷新外部视图的总尺寸,保证滚动距离
        strongSelf.scrollView.contentSize = CGSize(width: 0, height: contentSizeHeight)
    }
}

这个方法也有一个问题,由于内部滚动都是由外部来实现,没有手势的参与,因此得不到 scrollViewDidEndDragging 等滚动回调,如果涉及翻页之类的需求就会遇到困难。

解决办法是获取内部视图原本的代理,当外部视图代理收到回调时,转发给该代理实现功能。

func reloadScrollView() {
    typealias ClosureType = @convention(c) (AnyObject, Selector) -> AnyObject
    //定义获取代理方法
    let sel = #selector(getter: UIScrollView.delegate)
    //获取滚动视图代理的实现
    let imp = class_getMethodImplementation(UIScrollView.self, sel)
    //包装成闭包的形式
    let delegateFunc : ClosureType = unsafeBitCast(imp, to: ClosureType.self)
    //获得实际的代理对象
    currentScrollDelegate = delegateFunc(contentScrollView, sel) as? UIScrollViewDelegate
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if currentScrollDelegate != nil {
        currentScrollDelegate!.scrollViewDidEndDragging?
            (currentScrollView!, willDecelerate: decelerate)
    }
}

注意这里我并没有使用 contentScrollView.delegate,这是因为 UIWebScrollView 重载了这个方法并返回了 UIWebView 的代理。但实际真正的代理是一个 NSProxy 对象,他负责把回调传给 UIWebView 和外部代理。要保证 UIWebView 能正常处理的话,就要让它也收到回调,所以使用 Runtime 执行 UIScrollView 原始获取代理的实现来获取。


总结

目前在生产环境中我使用的是最后一种方法,但其实这些方法互有优缺点。

方案 分而治之 各自为政 中央集权
方式 嵌套 内嵌 嵌套
联动 手动 自动 手动
切换 数据源 整体更改 局部更改
优势 便于理解 滚动效果好 独立性
劣势 联动复杂 复杂场景苦手 模拟滚动隐患
评分 🌟🌟🌟 🌟🌟🌟🌟 🌟🌟🌟🌟

技术没有对错,只有适不适合当前的需求。

分而治之适合 UITableView 互相嵌套的情况,通过数据源的变化能够很好实现切换功能。

各自为政适合相对简单的页面需求,如果能够避免浮动框,那使用这个方法能够实现最好的滚动效果。

中央集权适合复杂的场景,通过独立不同类型的滚动视图,使得互相最少影响,但是由于其模拟滚动的特性,需要小心处理。

希望本文能给大家带来启发,项目开源代码在此,欢迎指教与Star。