原文地址: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 = 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
,這次的滑動手勢就會被打斷,從表現上來看,就是滑動到臨界值時滑動會被中斷。
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 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 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 來實現的。
此外我希望它還能支援一些別的特性:
- 簡單易用的介面
- 一般使用 方案III 實現的例子,大都只是支援在
rootScrollView
上實現阻尼效果,我希望也能在childScrollView
上實現,可以選擇任意一個阻尼來使用。(有阻尼,就可以配套下拉重新整理工具來使用了) - 一般使用 方案III 實現的例子,大都是需要手指在子檢視部分滑動才能實現聯動,希望也能在頭部滑動實現聯動
- 既可以支援使用頭部檢視,也可以不需要頭部檢視
- 頭部檢視可以使用簡單的介面實現滾動漸變效果(
navigation
上隨著滾動改變背景色、標題、leftItem顏色、rightItem顏色,或是背景色透明之類的),也可以自定義漸變效果 - 子控制元件既可結合一起使用,也可以單獨使用
- 分頁標題旁可以顯示紅點
…
對此,大都已經實現:
- 看下如下示例程式碼,是否還算簡單易用:
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
}
}
複製程式碼
- 已經能否支援“父阻尼”和“子阻尼”效果了
重寫SegementSlideViewController
的屬性bouncesType
,它是一個列舉型別:
enum BouncesType {
case parent
case 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
支援了紅點顯示
enum BadgeType {
case none
case point
case count(Int)
}
複製程式碼
紅點型別為列舉值,從上述程式碼可以看出紅點是支援『普通紅點顯示』還有『帶數字紅點顯示』。
還需要優化的點
-
上面在第3點已經提到,『頭部滑動也能實現滾動聯動效果』目前對此的解決方法不是最優。
-
方案III 所提到的『位置突變現象』,我在
SegementSlideViewController
中提供了canCacheScrollState
屬性,值為true
時,在切換介面的時候會快取當前的canParentViewScroll
、canChildViewScroll
以及rootScrollView
的contentOffset.y
值,並在切換回該介面的時候恢復;值為false
時,即為類似微博的處理,在切換到新介面前將當前介面的childScrollView
的contentOffset.y
值置為0
。設定為true
時會有一個效果,擔心這個效果難以被接受,故將該值的預設值設定為了false
。
效果如下:
但這仍不是一個很好的處理方式。
- 聯動滾動切換的時候,還沒有達到完美的流暢效果。由於
point A
和point B
處將contentOffset.y
強制設值來阻止滾動,同時也導致了滾動切換時『動能』不足的結果,也就是還不夠流暢。
接下去要做的事
自然是要解決上面提到的三點不足的地方,要想讓聯動完美般流暢,還是需要使用一個滾動,而不是兩個。我在本地開了個v3
分支做了個嘗試,在檢視頂層覆蓋一層透明的UIScrollView
,借用它的手勢、它的contentOffset
來控制rootScrollView
和childScrollView
的contentOffset
,可以解決上述提到的三個需要優化的點,但是同時也帶來了其他好多問題,這裡就不細說了,哪天問題都解決了,更新了v3
版本,再來補充說明吧。
參考
結束語
編寫本文時,SegementSlide的版本號為2.0-beta-13
。另外,本站還未開通評論功能,如對本文中的內容存在疑問,或者發現文中的不正確之處,歡迎在本文的掘金地址評論區中友善提出。如對本專案有任何疑問,歡迎前往issues提出,同時也歡迎來Pull requests,為本專案做貢獻。
『歡迎關注我的個人微信訂閱號,我將不定期分享程式設計相關內容』