三种UIScrollView嵌套实现方案

背景

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

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

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


分而治之

最多见的一种方案就是使用 UITableView 做为外部框架,将子视图的内容经过 UITableViewCell 的方式展示。swift

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

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,仍是会有手势冲突的状况。闭包

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

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

内部视图初始是不能滚动的,因此外部视图做为整套事件的接收者。当滚动到预设的位置并开启了内部视图的滚动,事件仍是传递给惟一接收者外部视图,只有松开手结束事件后从新触发,才能使内部视图开始滚动。oop

好在有一个方法能够解决这个问题。spa

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。

相关文章
相关标签/搜索