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