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