背景
随着产品功能不断的迭代,总会有需求希望在保证不影响其他区域功能的前提下,在某一区域实现根据选择器切换不同的内容显示。
苹果并不推荐嵌套滚动视图,如果直接添加的话,就会出现下图这种情况,手势的冲突造成了体验上的悲剧。
在实际开发中,我也不断的在思考解决方案,经历了几次重构后,有了些改进的经验,因此抽空整理了三种方案,他们实现的最终效果都是一样的。
分而治之
最常见的一种方案就是使用 UITableView
作为外部框架,将子视图的内容通过 UITableViewCell
的方式展现。
这种做法的好处在于解耦性,框架只要接受不同的数据源就能刷新对应的内容。
1 2 3 4 5 6 7 8 9 10 11 12
| 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
,还是会有手势冲突的情况。
常规做法首先禁止内部视图的滚动,当滚动到网页的位置时,启动网页的滚动并禁止外部滚动,反之亦然。
不幸的是,这种方案最大的问题是顿挫感。
内部视图初始是不能滚动的,所以外部视图作为整套事件的接收者。当滚动到预设的位置并开启了内部视图的滚动,事件还是传递给唯一接收者外部视图,只有松开手结束事件后重新触发,才能使内部视图开始滚动。
好在有一个方法可以解决这个问题。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| 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
为头部高度,从而实现效果。
来看下代码实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 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层级就只存在一个滚动视图,所以巧妙的避开了冲突。
相对的,插入的头部视图必须要轻量,如果需要和我例子中一样实现浮动栏效果,就要观察偏移量的变化手动定位。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| func reloadScrollView() { if currentScrollView != nil { currentScrollView!.removeFromSuperview() 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
来解决。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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 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
,内部视图永远不可滚动,外部边滚动边调整内部的位置,保证了双方的独立性。
与第二种方法相比,切换不同功能就比较简单,只需要替换内部视图,并实现外部视图的代理,滚动时设置内部视图的偏移量就可以了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| 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) { 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) 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) } }
|
当外部视图开始滚动时,其实一直在根据偏移量调整内部视图的位置。
外部视图的内容高度不是固定的,而是内部视图内容高度加上头部高度,所以需要观察其变化并刷新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| func reloadScrollView() { if currentScrollView != nil { observer?.invalidate() observer = nil }
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
等滚动回调,如果涉及翻页之类的需求就会遇到困难。
解决办法是获取内部视图原本的代理,当外部视图代理收到回调时,转发给该代理实现功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 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。