三種UIScrollView嵌套實現方案

背景

隨着產品功能不斷的迭代,總會有需求但願在保證不影響其餘區域功能的前提下,在某一區域實現根據選擇器切換不一樣的內容顯示。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。

相關文章
相關標籤/搜索