iOS 多个UIScrollView嵌套解决方案
一直以来,在实际项目中,无论是iOS、RN还是Android都会遇到一个场景,那就是UI界面会涉及到多个UIScrollView、ListView在垂直、水平方向上嵌套滑动,而在一些嵌套滑动中还会涉及更多复杂的业务逻辑。以iOS为例,比较常见的处理方式是UIScrollView嵌套UIScrollView或者UITableView,但这样其实存在很多问题,也会有很多难以解决的问题
比如:
1、UIScrollView互相嵌套,当需要滑动的时候去根据滑动位置设置是否isScrollEnabled,这样做对于简单的滑动没有问题,亦或者对于仅有一个简单的headerView是不会存在太大问题
2、当我们的headerView会有跟多复杂逻辑、更多滑动就会难以判定,甚至会出现滑动过程中界面卡顿的现象,这就是因为isScrollEnabled判定不好掌握
3、可能很多会采用,通过锤子滑动的时候headerView下方UIScrollView的contentOffset的改变来推动headerView重新设置origin,但是如果headerView的高度过高,设置超过了屏幕(就好比如headerView包含动态的置顶数据呢),如果不能够全屏滑动,那么这样子的嵌套就失去了意义
在这里,我使用了一种UIKit Dynamic + Gesture来处理,解决了上述问题,当然由于每个人的业务逻辑会存在很多的不同,无暂时无法写出一个框架来适应所有业务逻辑的处理,但是这个解决方案在很大程度上可以根据自己的业务逻辑,自行修改代码即可完成使用,在完成这个功能期间,我解决了如下问题,并且这些也许是你在实现时需要解决的问题:
image1、全屏可滑动
2、通过MJRefresh实现的下拉刷新、加载更多
3、单个tab,但数据未填充满屏幕
4、单个tab,数据填充满屏幕,但未填充满外层UIScrollView的contentSize
5、单个tab填充满屏幕
6、多个tab部分数据填充满屏幕,部分未填充
7、上述情况的其他多个tab情况
8、其他包含顶部horizontal滑动的情况
9、headerView包含动态的置顶、其他高度过高的UI等情况
10、其他更多的坑,我已在代码中注释
主要代码实现如下,每一个地方都有较为详细的注释:
enum NestedSlidingType: Int {
case singleTabNotFillScreen = 0 // 单个tab数据未填充满屏幕
case singleTabFillScreenNotFillContentSize // 单个tab数据填充满屏幕,未填充满外层ScrollView contentSize
case single // 上述两种情况外的单个tab情况
case multiTabPartFill // 多个tab部分数据填充屏幕,部分未填充
case multiTab // 上述情况外的其他多个tab情况
case multiTabOtherHeaderView // 包含其他更多情况
}
通过手势来处理整屏的滑动
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if let gesture = gestureRecognizer as? UIPanGestureRecognizer {
let translationX = gesture.translation(in: view).x
let translationY = gesture.translation(in: view).y
if translationY == 0 {
return true
} else {
/// 这里说一说手势处理,这个值可以设置得更大一些,保证在滑动垂直的时候触发了pageScrollView的滚动
/// return fabsf(Float(translationX))/Float(translationY) >= 6.0
/// 为了处理得更加严谨一点,应该这样(因为我们的headerView还可能存在更多的水平滑动,需要根具自己的需要判定在多大的偏移量的情况下处理horizontal滑动
let point = gesture.location(in: view)
let otherConvertPoint = view.convert(point, to: otherView)
let pageConvertPoint = view.convert(point, to: pageScrollView)
if otherView.point(inside: otherConvertPoint, with: nil) { // 手势在otherView
return fabs(Float(translationX)) > fabs(Float(translationY))
} else if pageScrollView.point(inside: pageConvertPoint, with: nil) { // 手势在pageScrollView
return fabsf(Float(translationX))/Float(translationY) >= 6.0
}
}
}
return false
}
@objc func panGestureRecognizerAction(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .began:
let translationX = gesture.translation(in: view).x
let translationY = gesture.translation(in: view).y
let velocityX = gesture.velocity(in: view).x
let velocityY = gesture.velocity(in: view).y
/// 这里有个坑,本可以直接使用translation即可的,但是在iphoneX、plus上的translation.y 在屏幕的左侧会存在translationY 始终 == 0 的情况,也就是当用左手指滑动的时候,你会发现根本不会执行后面的逻辑了
isVertical = fabsf(Float(translationY)) > fabsf(Float(translationX)) || fabsf(Float(velocityY)) > fabsf(Float(velocityX))
animator.removeAllBehaviors()
decelerationBehavior = nil
springBehavior = nil
break
case .changed:
if isVertical {
print("------------ 手势改变 --------")
_decelerateScrollView(gesture.translation(in: view).y)
}
break
case .cancelled:
break
case .ended:
print("------------ 手势结束 --------")
if isVertical {
/// MARK: 模拟减速滑动
dynamicItem.center = view.bounds.origin
let velocity = gesture.velocity(in: view)
let inertialBehavior = UIDynamicItemBehavior(items: [dynamicItem])
inertialBehavior.addLinearVelocity(CGPoint(x: 0, y: velocity.y), for: dynamicItem)
inertialBehavior.resistance = 2.0
var lastCenter = CGPoint.zero
inertialBehavior.action = { [weak self] () in
guard let weakSelf = self else { return }
if weakSelf.isVertical {
let currentY = weakSelf.dynamicItem.center.y - lastCenter.y
weakSelf._decelerateScrollView(currentY)
}
lastCenter = weakSelf.dynamicItem.center
}
animator.addBehavior(inertialBehavior)
decelerationBehavior = inertialBehavior
}
break
default:
break
}
/// 这里需要每次重新设置translation
gesture.setTranslation(CGPoint.zero, in: view)
}
通过UIKit Dynamic来模拟滑动及回弹的效果
private func _decelerateScrollView(_ detal: CGFloat) {
guard let curSegmentScrollView = curSegmentChildVC?.tableView else { return }
let maxOffsetY: CGFloat = HeaderView.defaultHeight + otherView.height - UIScreen.naviBarHeight
/// MARK: 仅有一个tab,并且tab不能够将mainScrollView推到顶部
if curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height < curSegmentScrollView.height && type == .singleTabNotFillScreen || type == .singleTabFillScreenNotFillContentSize {
var mainOffsetY = outterScrollView.contentOffset.y - detal
let offset1 = outterScrollView.contentOffset.y + outterScrollView.height
let offset2 = pageScrollView.y + curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height
if mainOffsetY > 0 {
if offset2 < outterScrollView.height { // 可以往上多滑动40,有一个弹回效果
mainOffsetY = offset2 + 40 < offset1 ? 40 : mainOffsetY
} else {
if mainOffsetY + outterScrollView.height > offset2 + 60 {
mainOffsetY = offset2 + 60 - outterScrollView.height
}
}
} else {
if mainOffsetY < -200 {
mainOffsetY = -200
}
}
outterScrollView.contentOffset = CGPoint(x: 0, y: mainOffsetY)
} else { /// MARK: 其他情况
if outterScrollView.contentOffset.y >= maxOffsetY {
var offsetY = curSegmentScrollView.contentOffset.y - detal
if offsetY < 0 || curSegmentScrollView.contentSize.height < curSegmentScrollView.height {
offsetY = 0
var mainOffsetY = outterScrollView.contentOffset.y - detal
mainOffsetY = mainOffsetY < 0 ? outterScrollView.contentOffset.y - _rubberBandDistance(detal, UIScreen.height) : mainOffsetY
outterScrollView.contentOffset = CGPoint(x: 0, y: min(mainOffsetY, maxOffsetY))
print("-------- 处理其他情况 ---------- if ------------- ")
} else if curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height < curSegmentScrollView.height {
offsetY = 0
print("---------- 处理其他情况 -------- else if 1 ------------- ")
} else if offsetY >= curSegmentScrollView.contentSize.height - curSegmentScrollView.height + curSegmentScrollView.mj_footer.height {
offsetY = curSegmentScrollView.contentOffset.y - _rubberBandDistance(detal, UIScreen.height)
print("--------- 处理其他情况 --------- else if 2 ------------- ")
}
curSegmentScrollView.contentOffset = CGPoint(x: 0, y: offsetY)
} else { /// 处理mainScrollView
var mainOffsetY = outterScrollView.contentOffset.y - detal
if mainOffsetY >= maxOffsetY {
mainOffsetY = maxOffsetY
} else if mainOffsetY < 0 {
mainOffsetY = outterScrollView.contentOffset.y - _rubberBandDistance(detal, UIScreen.height)
if mainOffsetY < -200 { // 下拉刷新最多下拉到200位置
mainOffsetY = -200
}
}
print("--------------- 处理outterScrollView -------- \(mainOffsetY)")
outterScrollView.contentOffset = CGPoint(x: 0, y: mainOffsetY)
if mainOffsetY == 0 {
_updateSegmentScrollViewContentOffset(CGPoint.zero)
}
}
}
/// MARK: 模拟回弹效果
let bounce0 = curSegmentScrollView.contentSize.height < curSegmentScrollView.height && (type == .singleTabNotFillScreen || type == .singleTabFillScreenNotFillContentSize) && pageScrollView.y + curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height < outterScrollView.contentOffset.y + outterScrollView.height // 单个到底的回弹
let bounce1 = outterScrollView.contentOffset.y < 0 // main到顶的回弹
let bounce2 = detal < 0 && curSegmentScrollView.contentSize.height > curSegmentScrollView.height && curSegmentScrollView.contentOffset.y > curSegmentScrollView.contentSize.height - curSegmentScrollView.height - curSegmentScrollView.mj_footer.height // curSegment 到底的回弹
let bounce = bounce0 || bounce1 || bounce2
if bounce && decelerationBehavior != nil && springBehavior == nil {
var target = CGPoint.zero
if bounce0 {
dynamicItem.center = outterScrollView.contentOffset
let offset = pageScrollView.y + curSegmentScrollView.contentSize.height + curSegmentScrollView.mj_footer.height
if offset < outterScrollView.height {
target = CGPoint.zero
} else {
target = CGPoint(x: 0, y: offset - outterScrollView.height + 10)
}
_springScrollViewContentOffset(outterScrollView, target)
} else if outterScrollView.contentOffset.y < 0 {
dynamicItem.center = outterScrollView.contentOffset
if outterScrollView.contentOffset.y < -outterScrollView.mj_header.height - UIScreen.statusBarMoreHeight - 20 {
target = CGPoint(x: 0, y: -outterScrollView.mj_header.height - UIScreen.statusBarMoreHeight)
} else {
target = CGPoint.zero
}
_springScrollViewContentOffset(outterScrollView, target)
print(" spring ------------------ if ------------- \(NSStringFromCGPoint(target))")
} else if curSegmentScrollView.contentOffset.y > curSegmentScrollView.contentSize.height - curSegmentScrollView.height + curSegmentScrollView.mj_footer.height {
dynamicItem.center = curSegmentScrollView.contentOffset
/// MARK: 需要将footer 显示出来
let offsetY = curSegmentScrollView.contentSize.height - curSegmentScrollView.height + curSegmentScrollView.mj_footer.height
target = CGPoint(x: 0, y: offsetY < 0 ? 0 : offsetY)
_springScrollViewContentOffset(curSegmentScrollView, target)
print(" spring ------------------ else ------------- \(NSStringFromCGPoint(target))")
}
}
}
/// 处理回弹
private func _springScrollViewContentOffset(_ scrollView: UIScrollView, _ point: CGPoint) {
dynamicItem.center = scrollView.contentOffset
animator.removeAllBehaviors()
decelerationBehavior = nil
springBehavior = nil
let tmpSprintBehavior = UIAttachmentBehavior(item: dynamicItem, attachedToAnchor: point)
tmpSprintBehavior.length = 0
tmpSprintBehavior.damping = 1
tmpSprintBehavior.frequency = 2
tmpSprintBehavior.action = { [weak self] () in
guard let weakSelf = self else { return }
scrollView.contentOffset = weakSelf.dynamicItem.center
if scrollView == weakSelf.outterScrollView && scrollView.contentOffset.y == 0 {
weakSelf._updateSegmentScrollViewContentOffset(CGPoint.zero)
}
}
animator.addBehavior(tmpSprintBehavior)
springBehavior = tmpSprintBehavior
}
private func _rubberBandDistance(_ offset: CGFloat, _ dimission: CGFloat) -> CGFloat {
let constant: CGFloat = 0.55
let result = (constant * CGFloat(fabsf(Float(offset))) * dimission) / (dimission + constant * CGFloat(fabs(Float(offset))))
return offset < 0.0 ? -result : result
}
最后这种解决方案虽然能够解决上述的很多问题,并且也比较方便进行后期的UI扩展改变,但也不是没有存在问题,其中最主要也是最难的一个就是:在业务功能复杂的时候,需要涉及到很多计算,就是这个计算会花费比较多的时间。
秉承 Talk is cheap, Show me the Code附上Demo,如果觉得此种方案能够解决你在项目中也到的问题,也可star一下,亦或者下载我们的医联App,体验一番,此功能在首页 - 小组 - 小组推荐 - 点击其中任意一个小组即可查看。