多层 UIScrollView 嵌套滚动解决方案
原文地址:jiar.me/article/Mul…
本文旨在對于SegementSlide庫實現原理的講解,有興趣的同學,歡迎前往Github地址瀏覽。
背景
如今的app中,越來越多地采用如下圖所示的設計,一般用在諸如『用戶主頁』、『話題詳情頁』、『專題詳情頁』等這些場景。通常,這些場景會帶有頭部視圖(頭部視圖可能要求支持滾動漸變),下面緊接著的是分頁控件,最下面是滾動列表。
如下圖所示:
各種方案以及優缺點
為了方便下面的說明,在開始之前,先約定幾個說法,下面的各種方案,大都離不開在最底層放上一個UIScrollView(豎直方向滾動),我們稱之為rootScrollView。無論分頁控件下方有多少個子界面,總有一個當前界面,我們稱當前界面下的UIScrollView(豎直方向滾動)為childScrollView。
I 控制isScrollEnabled屬性
這是我們第一時間能想到的方案,通過給rootScrollView和childScrollView實現UIScrollViewDelegate,并在func scrollViewDidScroll(_ scrollView: UIScrollView)方法中實時將scrollView.contentOffset.y與臨界值進行對比從而修改兩者scrollView的isScrollEnabled屬性值來達到目的。
大致代碼如下
func scrollViewDidScroll(_ scrollView: UIScrollView) {if scrollView == rootScrollView {if scrollView.contentOffset.y >= headerStickyHeight {scrollView.contentOffset.y = headerStickyHeightrootScrollView.isScrollEnabled = falsechildScrollView.isScrollEnabled = true}} else {if scrollView.contentOffset.y <= 0 {scrollView.contentOffset.y = 0childScrollView.isScrollEnabled = falserootScrollView.isScrollEnabled = true}} } 復制代碼方法簡單,但是有個不太能接受的交互問題,但凡將isScrollEnabled設置為false,這次的滑動手勢就會被打斷,從表現上來看,就是滑動到臨界值時滑動會被中斷。
II 自定義滑動手勢
在這篇文章這篇文章中,作者提供了一種利用自定義手勢的方式來實現。 但是,只是添加普通的滑動手勢是不夠的,UIScrollView是自帶阻尼效果的,因此引入了UIDynamicAnimator來實現阻尼效果。 這是一種不錯的思路。不過完全自定義手勢來實現UIScrollView的效果,需要考慮的細節過多,挺難處理得跟系統的效果一致(寫這篇文章的時候,下載了作者提供的源碼,commitID為ff7b76f8468bc87fea8ea6975d8b9fe1173ab031,在真機iPhone X上運行,感覺還是有交互上的問題)。此外,因為是自定義手勢,手勢不是直接作用在UIScrollView上的,UIScrollView的ScrollIndicator是無法顯示的,通過改變UIScrollView的contentOffset,其ScrollIndicator也是無法顯示的,必須要手勢作用在UIScrollView上才行。使用UIScrollView的flashScrollIndicators()來強迫ScrollIndicator顯示出來?...可能還真行,不過我沒試過,感覺太粗暴了。
III 手勢穿透
這應該是目前相對主流的一種實現方式,比如在這篇文章中,便是介紹了這種方式。據我觀察Twitter和微博的用戶主頁可能是使用這種方式實現的(寫這篇文章的時候,Twitter版本為:7.41.2,微博版本為:9.2.0,推測錯了的話還望見諒)
該方案的核心為有兩點:
- 讓滑動手勢穿透使得rootScrollView和childScrollView都能接收到滑動手勢(因為手勢是作用到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}} 復制代碼當然只是如此的話,是不夠的,這樣的結果是滑動的時候,導致rootScrollView和childScrollView一起滾動。
- 增加兩個標志位來控制何時允許rootScrollView滾動,以及何時允許childScrollView。
這部分代碼大致如下:
func scrollViewDidScroll(_ scrollView: UIScrollView) {if scrollView == rootScrollView {if !canParentViewScroll {rootScrollView.contentOffset.y = headerStickyHeight // point AcanChildViewScroll = true} else if scrollView.contentOffset.y >= headerStickyHeight {rootScrollView.contentOffset.y = headerStickyHeightcanParentViewScroll = falsecanChildViewScroll = true}} else {if !canChildViewScroll {childScrollView.contentOffset.y = 0 // point B} else if scrollView.contentOffset.y <= 0 {canChildViewScroll = falsecanParentViewScroll = true}} } 復制代碼如上代碼所示,控制rootScrollView或者是childScrollView不可滾動的方式是將兩者的contentOffset.y設置為一個固定值(見注釋point A和point B),并不是簡單地將isScrollEnabled設置false而已。
沒問題了?不,也是有不足之處的: 在第一個界面使用手指向上滑動,讓頭部視圖完全被隱藏后再向上滑動一些,讓childScrollView的contentOffset.y處于大于0的狀態,隨后,左右切換到第二個界面,使用手指向下滑動,完全拉出頭部視圖,然后再切換回第一個界面,這個時候,使用手指在屏幕上稍微滑動一下,rootScrollView或是childScrollView的contentOffset.y會突變,從表現上看,就是發生『位置突變現象』
問題產生的原因是什么? canParentViewScroll和childScrollView始終為一對相反的值,瀏覽上訴代碼,會發現在point A和point B處,將rootScrollView或者是childScrollView的contentOffset.y設置為了一個固定值。這樣的處理,當始終在同一個界面滑動的時候,不會有問題,但是,在切換界面后,由于rootScrollView是共用的,在新界面改動了rootScrollView的contentOffset.y,切換回原界面后,稍做滑動,定會執行point A或是point B其中的一處代碼,從而導致『位置突變現象』。
在微博和Twitter中對此問題做了簡單的處理。微博上,在切換至新界面之前,將原界面的childScrollView的contentOffset.y值重置為了0。Twitter上,則是在合適的時機做了重置。這也是推測兩者可能是使用了該方案的原因。
如下圖所示:
SegementSlide的需求
SegementSlide是使用 方案III 來實現的。
此外我希望它還能支持一些別的特性:
對此,大都已經實現:
重寫SegementSlideViewController的屬性bouncesType,它是一個枚舉類型:
enum BouncesType {case parentcase child } 復制代碼默認值為.parent,如下重寫,即可實現『子阻尼』效果:
class HomeViewController: SegementSlideViewController {......override var bouncesType: BouncesType {return .child} } 復制代碼如何使得在頭部滑動也能實現滾動聯動效果? 我在SegementSlideHeaderView中重寫了方法func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView?,在合適的情況下返回了childScrollView。目前這不是一個最優的方法,因為我沒能夠在這個方法中判斷出這個事件是滑動還是點擊事件,這里還可以優化。
既可以支持使用頭部視圖,也可以不需要頭部視圖 SegementSlideViewController是實現這套方案的基類,其中有一個headerView屬性,該屬性為可選值,返回nil則表示不需要頭部視圖。我在項目配套的Example工程中,其中的首頁便是沒有頭部視圖的示例,不過增加了下拉顯示navigation、上滑隱藏navigation的效果。一般使用 方案III 的例子,在rootScrollView上使用了UITableView,為了使用UITableView的tableHeaderView屬性,以及吸頂效果。SegementSlide在v1版本的時候,使用了UICollectionView,也是處于同樣的目的,現v2已經改成了UIScrollView,吸頂效果的話,可以通過增加一條到view.safeAreaLayoutGuide.topAnchor的約束來實現。
快速應用頭部漸變效果? TransparentSlideViewController是繼承于SegementSlideViewController的子類,其中的headerView屬性已被改成非可選值。其中另外定義了一些屬性,用于頭部視圖處于『顯示狀態』或是『嵌入狀態』時,titleView和navigationBar對應屬性的改動。
如下所示:
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來實現的,最先考慮的是修改navigationBar的titleTextAttributes屬性,實踐下來,發現會出現titleTextAttributes已經修改完畢,但是效果沒有改變的情況。
- TransparentSlideViewController會在viewWillAppear時保存navigation上對應樣式的狀態,并在viewWillDisappear時進行還原,來保證從一個TransparentSlideViewController(A)進入到另一個TransparentSlideViewController(B)時,navigation上樣式的狀態不會有錯誤,所以也不該在viewDidLoad時修改navigation上的樣式,因為B的viewDidLoad先于A的viewWillDisappear執行。
如果需要自定義漸變效果,可以模仿TransparentSlideViewController繼承SegementSlideViewController來實現需要的效果。Example中使用的是原生的UINavigationController,和TransparentSlideViewController配合起來,可以做到還算滿意的效果。但是,實際情況下每個項目中可能會去改動默認的navigation,如果TransparentSlideViewController不適用,則需要使用自定義的方式來支持已有項目。
子控件既可結合一起使用,也可以單獨使用 目前SegementSlideSwitcherView和SegementSlideContentView既可以作為SegementSlideViewController的子控件來使用,也可以單獨拿出來使用,Example工程中的NoticeViewController便是單獨使用的例子,實現了將switcher放在navigation上的效果。
紅點顯示? SegementSlideSwitcherView支持了紅點顯示
紅點類型為枚舉值,從上述代碼可以看出紅點是支持『普通紅點顯示』還有『帶數字紅點顯示』。
還需要優化的點
上面在第3點已經提到,『頭部滑動也能實現滾動聯動效果』目前對此的解決方法不是最優。
方案III 所提到的『位置突變現象』,我在SegementSlideViewController中提供了canCacheScrollState屬性,值為true時,在切換界面的時候會緩存當前的canParentViewScroll、canChildViewScroll以及rootScrollView的contentOffset.y值,并在切換回該界面的時候恢復;值為false時,即為類似微博的處理,在切換到新界面前將當前界面的childScrollView的contentOffset.y值置為0。設置為true時會有一個效果,擔心這個效果難以被接受,故將該值的默認值設置為了false。
效果如下:
但這仍不是一個很好的處理方式。
接下去要做的事
自然是要解決上面提到的三點不足的地方,要想讓聯動完美般流暢,還是需要使用一個滾動,而不是兩個。我在本地開了個v3分支做了個嘗試,在視圖頂層覆蓋一層透明的UIScrollView,借用它的手勢、它的contentOffset來控制rootScrollView和childScrollView的contentOffset,可以解決上述提到的三個需要優化的點,但是同時也帶來了其他好多問題,這里就不細說了,哪天問題都解決了,更新了v3版本,再來補充說明吧。
參考
- iOS 嵌套UIScrollview的滑動沖突另一種解決方案
- iOS scrollView嵌套tableView的手勢沖突解決方案
結束語
編寫本文時,SegementSlide的版本號為2.0-beta-13。另外,本站還未開通評論功能,如對本文中的內容存在疑問,或者發現文中的不正確之處,歡迎在本文的掘金地址評論區中友善提出。如對本項目有任何疑問,歡迎前往issues提出,同時也歡迎來Pull requests,為本項目做貢獻。
『歡迎關注我的個人微信訂閱號,我將不定期分享編程相關內容』
轉載于:https://juejin.im/post/5c63ee7d51882562654aaf37
總結
以上是生活随笔為你收集整理的多层 UIScrollView 嵌套滚动解决方案的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 02基于python玩转人工智能最火框架
- 下一篇: BZOJ1823:[JSOI2010]满