三種UIScrollView巢狀實現方案

VanchChen發表於2018-12-29

背景

隨著產品功能不斷的迭代,總會有需求希望在保證不影響其他區域功能的前提下,在某一區域實現根據選擇器切換不同的內容顯示。

三種UIScrollView巢狀實現方案

蘋果並不推薦巢狀滾動檢視,如果直接新增的話,就會出現下圖這種情況,手勢的衝突造成了體驗上的悲劇。

三種UIScrollView巢狀實現方案

在實際開發中,我也不斷的在思考解決方案,經歷了幾次重構後,有了些改進的經驗,因此抽空整理了三種方案,他們實現的最終效果都是一樣的。


分而治之

最常見的一種方案就是使用 UITableView 作為外部框架,將子檢視的內容通過 UITableViewCell 的方式展現。

三種UIScrollView巢狀實現方案

這種做法的好處在於解耦性,框架只要接受不同的資料來源就能重新整理對應的內容。

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 的一環,有很多限制(比如不同資料來源需要不同的設定,有的希望動態高度,有的需要插入額外的檢視),這些都不能很好的解決。


各自為政

另一種解決方案比較反客為主,靈感來源於下拉重新整理的實現方式,也就是將需要顯示的內容塞入負一屏。

三種UIScrollView巢狀實現方案

首先保證子檢視撐滿全屏,把主檢視內容插入子檢視,並設定 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 ,內部檢視永遠不可滾動,外部邊滾動邊調整內部的位置,保證了雙方的獨立性。

三種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。

相關文章