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