三种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。