隨着產品功能不斷的迭代,總會有需求但願在保證不影響其餘區域功能的前提下,在某一區域實現根據選擇器切換不一樣的內容顯示。git
蘋果並不推薦嵌套滾動視圖,若是直接添加的話,就會出現下圖這種狀況,手勢的衝突形成了體驗上的悲劇。github
在實際開發中,我也不斷的在思考解決方案,經歷了幾回重構後,有了些改進的經驗,所以抽空整理了三種方案,他們實現的最終效果都是同樣的。web
最多見的一種方案就是使用 UITableView
做爲外部框架,將子視圖的內容經過 UITableViewCell
的方式展示。swift
這種作法的好處在於解耦性,框架只要接受不一樣的數據源就能刷新對應的內容。閉包
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
不幸的是,這種方案最大的問題是頓挫感。ui
內部視圖初始是不能滾動的,因此外部視圖做爲整套事件的接收者。當滾動到預設的位置並開啓了內部視圖的滾動,事件仍是傳遞給惟一接收者外部視圖,只有鬆開手結束事件後從新觸發,才能使內部視圖開始滾動。spa
好在有一個方法能夠解決這個問題。3d
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。