一直以來,在實際項目中,無論是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來處理,解決了上述問題,當然由於每個人的業務邏輯會存在很多的不同,無暫時無法寫出一個框架來適應所有業務邏輯的處理,但是這個解決方案在很大程度上可以根據自己的業務邏輯,自行修改代碼即可完成使用,在完成這個功能期間,我解決了如下問題,並且這些也許是你在實現時需要解決的問題:
1、全屏可滑動
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,體驗一番,此功能在首頁 - 小組 - 小組推薦 - 點擊其中任意一個小組即可查看。