背景
隨著產品功能不斷的迭代,總會有需求希望在保證不影響其他區域功能的前提下,在某一區域實現根據選擇器切換不同的內容顯示。
蘋果並不推薦巢狀滾動檢視,如果直接新增的話,就會出現下圖這種情況,手勢的衝突造成了體驗上的悲劇。
在實際開發中,我也不斷的在思考解決方案,經歷了幾次重構後,有了些改進的經驗,因此抽空整理了三種方案,他們實現的最終效果都是一樣的。
分而治之
最常見的一種方案就是使用 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。