三种UIScrollView嵌套实现方案

背景

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

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

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


分而治之

最常见的一种方案就是使用 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。

原文地址:https://www.cnblogs.com/vanch/p/10175541.html

时间: 2024-11-10 02:29:30

三种UIScrollView嵌套实现方案的相关文章

HAProxy的三种不同类型配置方案

haproxy是一款功能强大.灵活好用反向代理软件,提供了高可用.负载均衡.后端服务器代理的功能,它在7层负载均衡方面的功能很强大(支持cookie track, header rewrite等等),支持双机热备,支持虚拟主机,拥有非常不错的服务器健康检查功能,当其代理的后端服务器出现故障, HAProxy会自动将该服务器摘除,故障恢复后再自动将该服务器加入;同时还提供直观的监控页面,可以清晰实时的监控服务集群的运行状况. 在四层(tcp)实现负载均衡的软件: lvs------>重量级 ngi

三种CSS 全局Reset 方案

方案一 html, body, div, span,applet, object, iframe, table, caption, tbody, tfoot, thead, tr,th, td, del, dfn, em, font, img, ins, kbd, q, s, samp, small,strike, strong, sub, sup, tt, var, h1, h2, h3, h4, h5, h6,p, blockquote, pre, a, abbr, acronym, add

java将doc文件转换为pdf文件的三种方法

http://feifei.im/archives/93 —————————————————————————————————————————————— 项目要用到doc转pdf的功能,一番google之后总结出了三种方法(免费方案),于是一一试了一下,做个总结记录,下次要用直接查,省的忘了…… 方法1.poi读取doc + itext生成pdf (实现最方便,效果最差,跨平台) 方法2.jodconverter + openOffice (一般格式实现效果还行,复杂格式容易有错位,跨平台) 方法

三种观察者模式的C#实现

说起观察者模式,估计在园子里能搜出一堆来.所以写这篇博客的目的有两点: 观察者模式是写松耦合代码的必备模式,重要性不言而喻,抛开代码层面,许多组件都采用了Publish-Subscribe模式,所以我想按照自己的理解重新设计一个使用场景并把观察者模式灵活使用在其中 我想把C#中实现观察者模式的三个方案做一个总结,目前还没看到这样的总结 现在我们来假设这样的一个场景,并利用观察者模式实现需求: 未来智能家居进入了每家每户,每个家居都留有API供客户进行自定义整合,所以第一个智能闹钟(smartCl

Oracle的三种高可用集群方案

Oracle的三种高可用集群方案 主要有三种: 1. RAC RAC,  Real Application Clusters 多个Oracle服务器组成一个共享的Cache,而这些Oracle服务器共享一个基于网络的存储.这个系统可以容忍单机/或是多机失败. 不过系统内部的多个节点需要高速网络互连,基本上也就是要全部东西放在在一个机房内,或者说一个数据中心内.如果机房出故障,比如网络不通,那就坏了.所以仅仅用RAC还是满足不了一般互联网公司的重要业务的需要,重要业务需要多机房来容忍单个机房的事故

关于JAVA中HashMap集合的的三种超不好记的便利方案

HashMap 和 HashSet 是 Java Collection Framework 的两个重要成员,其中 HashMap 是 Map 接口的常用实现类 1:先创建一个类 1 package Day; 2 3 import java.util.ArrayList; import java.util.HashMap; import java.util.Iterator; import java.util.Map; import java.util.Map.Entry; import java

三种方案实现日志记录功能

三种方案实现日志记录功能 方案一.使用拦截器实现日志记录功能 步骤一.首先需要我们自定义一个注解类 package cn.hmy.Util; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 自定义操作日志注解接口类

充电桩的三种网络接入方案介绍

1.充电桩网络化实现功能 1)IC刷卡系统联网.安全加密: 2)对充电桩工作环境(温度.湿度等).工作状态检测.警报,远程维护,监控: 3)接充电站打印机,打印消费凭据: 4)动端查询当下可用充电桩位置: 5)LED远程广告投放功能: 6)作为WiFi基站,有助于提高无线覆盖面积,方便用户等待无聊时上网. 2.三种实现方案 方案一:每个充电桩通过RJ45或者光纤分别接入以太网,连接充电站管理中心,再接入互联网管理中心和数据库. 优点:有线以太网主要优点是数据传输可靠.网络容量大: 缺点:布线复杂

【Win 10 应用开发】文件读写的三种方案

本文老周就跟伙伴们探讨一下关于文件读写的方法.总得来说嘛,有三种方案可以用,而且每种方案都各有特色,也说不上哪种较好.反正你得记住老祖宗留给我们的大智慧——事无定法,灵活运用者为上. OK,咱们开始吧. 先说第一个方案:使用 FileIO类. 这个类属于RT库API,它公开了一堆静态方法,可以直接调用,快捷方便,就像.net里面的File类一样.在使用FileIo类的时候,需要一个引用已知文件的StorageFile实例,而且FileIo只能操作已经存在的文件,它不会自动创建文件,这一点要注意.