多層 UIScrollView 嵌套滾動解決方案

原文地址:jiar.me/article/Mul…git

本文旨在對於SegementSlide庫實現原理的講解,有興趣的同窗,歡迎前往Github地址瀏覽。github

SegementSlide


背景

現在的app中,愈來愈多地採用以下圖所示的設計,通常用在諸如『用戶主頁』、『話題詳情頁』、『專題詳情頁』等這些場景。一般,這些場景會帶有頭部視圖(頭部視圖可能要求支持滾動漸變),下面緊接着的是分頁控件,最下面是滾動列表。編程

以下圖所示:swift

各類方案以及優缺點

爲了方便下面的說明,在開始以前,先約定幾個說法,下面的各類方案,大都離不開在最底層放上一個UIScrollView(豎直方向滾動),咱們稱之爲rootScrollView。不管分頁控件下方有多少個子界面,總有一個當前界面,咱們稱當前界面下的UIScrollView(豎直方向滾動)爲childScrollView緩存

I 控制isScrollEnabled屬性

這是咱們第一時間能想到的方案,經過給rootScrollViewchildScrollView實現UIScrollViewDelegate,並在func scrollViewDidScroll(_ scrollView: UIScrollView)方法中實時將scrollView.contentOffset.y與臨界值進行對比從而修改二者scrollViewisScrollEnabled屬性值來達到目的。bash

大體代碼以下微信

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    if scrollView == rootScrollView {
        if scrollView.contentOffset.y >= headerStickyHeight {
            scrollView.contentOffset.y = headerStickyHeight
            rootScrollView.isScrollEnabled = false
            childScrollView.isScrollEnabled = true
        }
    } else {
        if scrollView.contentOffset.y <= 0 {
            scrollView.contentOffset.y = 0
            childScrollView.isScrollEnabled = false
            rootScrollView.isScrollEnabled = true
        }
    }
}
複製代碼

方法簡單,可是有個不太能接受的交互問題,但凡將isScrollEnabled設置爲false,此次的滑動手勢就會被打斷,從表現上來看,就是滑動到臨界值時滑動會被中斷。app

II 自定義滑動手勢

在這篇文章這篇文章中,做者提供了一種利用自定義手勢的方式來實現。 可是,只是添加普通的滑動手勢是不夠的,UIScrollView是自帶阻尼效果的,所以引入了UIDynamicAnimator來實現阻尼效果。 這是一種不錯的思路。不過徹底自定義手勢來實現UIScrollView的效果,須要考慮的細節過多,挺難處理得跟系統的效果一致(寫這篇文章的時候,下載了做者提供的源碼commitIDff7b76f8468bc87fea8ea6975d8b9fe1173ab031,在真機iPhone X上運行,感受仍是有交互上的問題)。此外,由於是自定義手勢,手勢不是直接做用在UIScrollView上的,UIScrollViewScrollIndicator是沒法顯示的,經過改變UIScrollViewcontentOffset,其ScrollIndicator也是沒法顯示的,必需要手勢做用在UIScrollView上才行。使用UIScrollViewflashScrollIndicators()來強迫ScrollIndicator顯示出來?...可能還真行,不過我沒試過,感受太粗暴了。ide

III 手勢穿透

這應該是目前相對主流的一種實現方式,好比在這篇文章中,即是介紹了這種方式。據我觀察Twitter和微博的用戶主頁多是使用這種方式實現的(寫這篇文章的時候,Twitter版本爲:7.41.2,微博版本爲:9.2.0,推測錯了的話還望見諒)工具

該方案的核心爲有兩點:

  • 讓滑動手勢穿透使得rootScrollViewchildScrollView都能接收到滑動手勢(由於手勢是做用到UIScrollview上的,天然是能顯示ScrollIndicator的)。作法是讓rootScrollView實現UIGestureRecognizerDelegate的代理方法func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool,並在適當的時機返回true

這部分的代碼大體以下:

class SegementSlideScrollView: UIScrollView, UIGestureRecognizerDelegate {
    
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        return true
    }
    
}
複製代碼

固然只是如此的話,是不夠的,這樣的結果是滑動的時候,致使rootScrollViewchildScrollView一塊兒滾動。

  • 增長兩個標誌位來控制什麼時候容許rootScrollView滾動,以及什麼時候容許childScrollView

這部分代碼大體以下:

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    if scrollView == rootScrollView {
        if !canParentViewScroll {
            rootScrollView.contentOffset.y = headerStickyHeight // point A
            canChildViewScroll = true
        } else if scrollView.contentOffset.y >= headerStickyHeight {
            rootScrollView.contentOffset.y = headerStickyHeight
            canParentViewScroll = false
            canChildViewScroll = true
        }
    } else {
        if !canChildViewScroll {
            childScrollView.contentOffset.y = 0 // point B
        } else if scrollView.contentOffset.y <= 0 {
            canChildViewScroll = false
            canParentViewScroll = true
        }
    }
}
複製代碼

如上代碼所示,控制rootScrollView或者是childScrollView不可滾動的方式是將二者的contentOffset.y設置爲一個固定值(見註釋point Apoint B),並非簡單地將isScrollEnabled設置false而已。

沒問題了?不,也是有不足之處的: 在第一個界面使用手指向上滑動,讓頭部視圖徹底被隱藏後再向上滑動一些,讓childScrollViewcontentOffset.y處於大於0的狀態,隨後,左右切換到第二個界面,使用手指向下滑動,徹底拉出頭部視圖,而後再切換回第一個界面,這個時候,使用手指在屏幕上稍微滑動一下,rootScrollView或是childScrollViewcontentOffset.y會突變,從表現上看,就是發生『位置突變現象』

問題產生的緣由是什麼? canParentViewScrollchildScrollView始終爲一對相反的值,瀏覽上訴代碼,會發如今point Apoint B處,將rootScrollView或者是childScrollViewcontentOffset.y設置爲了一個固定值。這樣的處理,當始終在同一個界面滑動的時候,不會有問題,可是,在切換界面後,因爲rootScrollView是共用的,在新界面改動了rootScrollViewcontentOffset.y,切換回原界面後,稍作滑動,定會執行point A或是point B其中的一處代碼,從而致使『位置突變現象』。

在微博和Twitter中對此問題作了簡單的處理。微博上,在切換至新界面以前,將原界面的childScrollViewcontentOffset.y值重置爲了0。Twitter上,則是在合適的時機作了重置。這也是推測二者多是使用了該方案的緣由。

以下圖所示:

SegementSlide的需求

SegementSlide是使用 方案III 來實現的。

此外我但願它還能支持一些別的特性:

  1. 簡單易用的接口
  2. 通常使用 方案III 實現的例子,大都只是支持在rootScrollView上實現阻尼效果,我但願也能在childScrollView上實現,能夠選擇任意一個阻尼來使用。(有阻尼,就能夠配套下拉刷新工具來使用了)
  3. 通常使用 方案III 實現的例子,大都是須要手指在子視圖部分滑動才能實現聯動,但願也能在頭部滑動實現聯動
  4. 既能夠支持使用頭部視圖,也能夠不須要頭部視圖
  5. 頭部視圖可使用簡單的接口實現滾動漸變效果(navigation上隨着滾動改變背景色、標題、leftItem顏色、rightItem顏色,或是背景色透明之類的),也能夠自定義漸變效果
  6. 子控件既可結合一塊兒使用,也能夠單獨使用
  7. 分頁標題旁能夠顯示紅點 ...

對此,大都已經實現:

  1. 看下以下示例代碼,是否還算簡單易用:
import SegementSlide

class HomeViewController: SegementSlideViewController {

    ......

    override var headerHeight: CGFloat? {
        return view.bounds.height/4
    }
    
    override var headerView: UIView? {
        return UIView()
    }

    override var titlesInSwitcher: [String] {
        return ["Swift", "Ruby", "Kotlin"]
    }

    override func segementSlideContentViewController(at index: Int) -> SegementSlideContentScrollViewDelegate? {
        return ContentViewController()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        canCacheScrollState = true
        reloadData()
        scrollToSlide(at: 0, animated: false)
    }

}
複製代碼
import SegementSlide

class ContentViewController: UITableViewController, SegementSlideContentScrollViewDelegate {

    ......

    @objc var scrollView: UIScrollView {
        return tableView
    }

}
複製代碼
  1. 已經可否支持「父阻尼」和「子阻尼」效果了

重寫SegementSlideViewController的屬性bouncesType,它是一個枚舉類型:

enum BouncesType {
    case parent
    case child
}
複製代碼

默認值爲.parent,以下重寫,便可實現『子阻尼』效果:

class HomeViewController: SegementSlideViewController {

    ......

    override var bouncesType: BouncesType {
        return .child
    }
}
複製代碼
  1. 如何使得在頭部滑動也能實現滾動聯動效果? 我在SegementSlideHeaderView中重寫了方法func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?,在合適的狀況下返回了childScrollView。目前這不是一個最優的方法,由於我沒可以在這個方法中判斷出這個事件是滑動仍是點擊事件,這裏還能夠優化。

  2. 既能夠支持使用頭部視圖,也能夠不須要頭部視圖 SegementSlideViewController是實現這套方案的基類,其中有一個headerView屬性,該屬性爲可選值,返回nil則表示不須要頭部視圖。我在項目配套的Example工程中,其中的首頁即是沒有頭部視圖的示例,不過增長了下拉顯示navigation、上滑隱藏navigation的效果。通常使用 方案III 的例子,在rootScrollView上使用了UITableView,爲了使用UITableViewtableHeaderView屬性,以及吸頂效果。SegementSlidev1版本的時候,使用了UICollectionView,也是處於一樣的目的,現v2已經改爲了UIScrollView,吸頂效果的話,能夠經過增長一條到view.safeAreaLayoutGuide.topAnchor的約束來實現。

  3. 快速應用頭部漸變效果? TransparentSlideViewController是繼承於SegementSlideViewController的子類,其中的headerView屬性已被改爲非可選值。其中另外定義了一些屬性,用於頭部視圖處於『顯示狀態』或是『嵌入狀態』時,titleViewnavigationBar對應屬性的改動。

以下所示:

typealias DisplayEmbed<T> = (display: T, embed: T)

override var isTranslucents: DisplayEmbed<Bool> {
    return (true, false)
}

override var attributedTexts: DisplayEmbed<NSAttributedString?> {
    return (nil, nil)
}

override var barStyles: DisplayEmbed<UIBarStyle> {
    return (.black, .default)
}

override var barTintColors: DisplayEmbed<UIColor?> {
    return (nil, .white)
}

override var tintColors: DisplayEmbed<UIColor> {
    return (.white, .black)
}
複製代碼

其中DisplayEmbed爲一個typealias表示『顯示狀態』或是『嵌入狀態』時的值。

須要注意的是:

  • TransparentSlideViewController中的titleView是使用自定義的方式並賦值給navigationItem.titleView來實現的,最早考慮的是修改navigationBartitleTextAttributes屬性,實踐下來,發現會出現titleTextAttributes已經修改完畢,可是效果沒有改變的狀況。
  • TransparentSlideViewController會在viewWillAppear時保存navigation上對應樣式的狀態,並在viewWillDisappear時進行還原,來保證從一個TransparentSlideViewController(A)進入到另外一個TransparentSlideViewController(B)時,navigation上樣式的狀態不會有錯誤,因此也不應在viewDidLoad時修改navigation上的樣式,由於BviewDidLoad先於AviewWillDisappear執行。

若是須要自定義漸變效果,能夠模仿TransparentSlideViewController繼承SegementSlideViewController來實現須要的效果。Example中使用的是原生的UINavigationController,和TransparentSlideViewController配合起來,能夠作到還算滿意的效果。可是,實際狀況下每一個項目中可能會去改動默認的navigation,若是TransparentSlideViewController不適用,則須要使用自定義的方式來支持已有項目。

  1. 子控件既可結合一塊兒使用,也能夠單獨使用 目前SegementSlideSwitcherViewSegementSlideContentView既能夠做爲SegementSlideViewController的子控件來使用,也能夠單獨拿出來使用,Example工程中的NoticeViewController即是單獨使用的例子,實現了將switcher放在navigation上的效果。

  2. 紅點顯示? SegementSlideSwitcherView支持了紅點顯示

enum BadgeType {
    case none
    case point
    case count(Int)
}
複製代碼

紅點類型爲枚舉值,從上述代碼能夠看出紅點是支持『普通紅點顯示』還有『帶數字紅點顯示』。

還須要優化的點

  1. 上面在第3點已經提到,『頭部滑動也能實現滾動聯動效果』目前對此的解決方法不是最優。

  2. 方案III 所提到的『位置突變現象』,我在SegementSlideViewController中提供了canCacheScrollState屬性,值爲true時,在切換界面的時候會緩存當前的canParentViewScrollcanChildViewScroll以及rootScrollViewcontentOffset.y值,並在切換回該界面的時候恢復;值爲false時,即爲相似微博的處理,在切換到新界面前將當前界面的childScrollViewcontentOffset.y值置爲0。設置爲true時會有一個效果,擔憂這個效果難以被接受,故將該值的默認值設置爲了false

效果以下:

但這仍不是一個很好的處理方式。

  1. 聯動滾動切換的時候,尚未達到完美的流暢效果。因爲point Apoint B處將contentOffset.y強制設值來阻止滾動,同時也致使了滾動切換時『動能』不足的結果,也就是還不夠流暢。

接下去要作的事

天然是要解決上面提到的三點不足的地方,要想讓聯動完美般流暢,仍是須要使用一個滾動,而不是兩個。我在本地開了個v3分支作了個嘗試,在視圖頂層覆蓋一層透明的UIScrollView,借用它的手勢、它的contentOffset來控制rootScrollViewchildScrollViewcontentOffset,能夠解決上述提到的三個須要優化的點,可是同時也帶來了其餘好多問題,這裏就不細說了,哪天問題都解決了,更新了v3版本,再來補充說明吧。

參考

結束語

編寫本文時,SegementSlide的版本號爲2.0-beta-13。另外,本站還未開通評論功能,如對本文中的內容存在疑問,或者發現文中的不正確之處,歡迎在本文的掘金地址評論區中友善提出。如對本項目有任何疑問,歡迎前往issues提出,同時也歡迎來Pull requests,爲本項目作貢獻。






『歡迎關注個人我的微信訂閱號,我將不按期分享編程相關內容』

相關文章
相關標籤/搜索