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