基本場景
(最終效果和鏈接在文末,支持Swift與OC)
UIScrollView
嵌套多個UITableView
的場景在APP里很常見,復雜點還有各種UITableView、UICollectionView
各種嵌套的場景,目前通用的解決辦法基本是在UIScrollView
的代理方法
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
里比較偏移量和需要懸停的坐標位置再做相應處理,定義主要父視圖scrollView
為mainScrollView
,嵌套的多個聯動scrollView
為contentScrollView
,先總結下大致思路。
- 手勢響應,
shouldRecognizeSimultaneouslyWithGestureRecognizer
必須同時作用于mainScrollView
和所有contentScrollView
,而contentScrollView
是需要橫向滑動的,因此要允許同時垂直滑動,而不支持水平和垂直同時滑動。 -
mainScrollView 、contentScrollView
均需要實現scrollViewDidScroll
并分別處理,兩者的實際滑動是互斥的,同一時刻只有一方需要響應滑動,另一方做懸停處理,相互通知也很是麻煩。 - 下拉刷新,
mainScrollView 、contentScrollView
各自有著需要下拉刷新的場景,一般contentScrollView
需要下拉刷新時也正好處于自身臨界固定點的位置,這里也需要單獨處理下。 -
scrollsToTop
這其實是一個很容易被忽略的點,iOS系統有個小的隱藏功能,點擊系統狀態欄會查找到當前顯示的UIScrollView
并響應回到頂部,而在這種嵌套的場景里,主次需要響應的時機就依賴于需求了,或許需求就要求先回到contentScrollView
后回到mainScrollView
的頂部呢??。
要把這些都處理好,寫代碼的時候必須梳理清楚,即便如此,當項目不同模塊都有著類似的需求的時候,又得好好捋一遍了,可能相似而不相同,一不小心就容易一團麻,令人抓狂。
之前在網上搜索這類需求的方案,大部分都是上述的大概思路,其他有些是對整個相關UI層的封裝,一來學習使用成本略高,二則在已經成型的項目里使用的話,改動略大,耦合性比較高。于是打算自己重新整理一個低耦合的方案出來。
結果方案
依然是比較偏移量處理懸停,為了減少耦合,因此不走代理,采用KVO
的方式監測偏移量,初始化需要設置mainScrollView
和各contentScrollView
,考慮到不少子頁面可能存在懶加載的情況,因此contentScrollView
可以不必在初始化時全給到,可延后等待時機添加。index
參數用于標記contentScrollView
在其橫向父scrollView
的位置,避免受到其他兄弟視圖的滑動影響。
+ (instancetype)managerWithMainScrollView:(UIScrollView *)mainScrollView contentScrollViews:(NSArray<UIScrollView *> *_Nullable)contentScrollViews;
- (void)addContentScrollView:(UIScrollView *)contentScrollView withIndex:(NSInteger)index;
初始化完了,接下來就是本方案中唯一的必設屬性了:
@property (nonatomic) CGFloat contentScrollDistance;
mainScrollView
懸停相關的值,contentScrollView
可以在mainScrollView
移動的距離,一般是需要顯示的內容區域在mainScrollView
的相對坐標Y值,如圖所示,箭頭是終點,圖中上面高為300,只要設置contentScrollDistance
為300,就可以基本實現完整的嵌套聯動了。當頁面刷新高度變化的時候,只需要重新調整contentScrollDistance
的值即可。
必設屬性之后就是擴展需求的可選屬性了。
///各contentScrollView的共同橫向superScrollView
///內部是尋找第一個contentScrollView的父視圖里的第一個UIScrollView
///與實際不符時可 以此修正
///主要用于scrollsToTop及散裝屬性
@property (nonatomic, weak) UIScrollView *fixHorizontalSuperScrollView;
///滑動條顯示 默認切換顯示
@property (nonatomic) XShowIndicatorType showIndicatorType;
///默認main可下拉
@property (nonatomic) XMixScrollPullType mixScrollPullType;
///點擊狀態欄回頂部時 是否直接回到mainScrollView頂部 默認Yes
@property (nonatomic) BOOL scrollsToMainTop;
///是否開啟動態模擬 默認 NO 在main范圍內content范圍外 上拉沒有過度滑動效果 YES則添加模擬效果
@property (nonatomic) BOOL enableDynamicSimulate;
///動態模擬過度滑動效果 阻力參數 默認 2
@property (nonatomic) CGFloat dynamicResistance;
- 如注釋所示,該屬性的出現主要是為了
scrollsToTop
的切換以及接下來要介紹的散裝屬性。 -
mainScrollView
和contentScrollView
各有各的滑動條,簡單暴力的話就是全隱藏,但是畢竟contentScrollView
可能上拉加載更多無限長,還是需要看情況顯示的。 - 下拉刷新,可以自由設置
mainScrollView
和contentScrollView
是否支持下拉刷新。 - 當
scrollsToMainTop
為NO
時,點擊狀態欄會優先使當前contentScrollView
回到頂部,其次回到mainScrollView
頂部。 - 關于動態模擬,在滑動
contentScrollView
區域外的mainScrollView
時,contentScrollView
不會響應手勢,自然也不會滑動,在慣性滑動過渡到contentScrollView
的時候mainScrollView
由于懸停設置會導致瞬停,沒法好好平滑過渡,最終參考網上動態模擬的方案針對上滑觸摸點在contentScrollView
區域外mainScrollView
區域內的單個場景增加了慣性模擬。因為需要額外的計算且不是必須的,所以默認關閉了。
以上關于contentScrollView
的設置都是針對所有內容視圖的,考慮到不同contentScrollView
可能有著不同需求,比如有的子頁面內容較少不需要顯示滑動進度條,不需要回到子頁面頂部,有的子頁面內容可以無限上拉加載更多,需要進度條也需要回到子頁面頂部之類的。因此增加了部分可選屬性單獨設置的方法。
///開啟散裝屬性 默認NO
@property (nonatomic) BOOL enableCustomConfig;
- (void)setShowIndicatorType:(XShowIndicatorType)showIndicatorType forScrollView:(UIScrollView *)contentScrollView;
- (void)setMixScrollPullType:(XMixScrollPullType)mixScrollPullType forScrollView:(UIScrollView *)contentScrollView;
- (void)setScrollsToMainTop:(BOOL)scrollsToMainTop forScrollView:(UIScrollView *)contentScrollView;
- (void)setEnableDynamicSimulate:(BOOL)enableDynamicSimulate forScrollView:(UIScrollView *)contentScrollView;
沒有單獨設置屬性的contentScrollView
依然以主要設置為準。
大致實現
KVO
那里判斷代碼比較長,大致說一下,KVO
里在
對mainScrollView 、contentScrollView
的常規嵌套聯動處理的基礎上,加上了回到頂部、是否顯示下拉狀態的處理、以及慣性模擬的判斷調用,此外對內容視圖橫向父scrollView
的偏移量也添加了觀察(如下),內容視圖切換時需要校準scrollsToTop
狀態以及對散裝進度條的顯示狀況進行修正。.p
的寫法只是為了少寫幾個associatedObject
。
//橫向父scrollView滑動處理
NSInteger index = scrollView.contentOffset.x / scrollView.frame.size.width;
if (scrollView.p.index != index) {
scrollView.p.index = index;
self.currentIndex = index;
[self checkScrollsToTop];
[self checkCustomConfig];
}
聯動的滑動過渡如下
- (void)changeMainScrollStatus:(BOOL)mainCanScroll
{
if (self.mainScrollView.p.canScroll == mainCanScroll) {
return;
}
self.mainScrollView.scrollsToTop = YES;
self.mainScrollView.p.canScroll = mainCanScroll;
for (UIScrollView *contentScrollView in self.contentScrollViews) {
contentScrollView.p.canScroll = !mainCanScroll;
if (mainCanScroll) {
contentScrollView.contentOffset = CGPointZero;
}
if (!self.scrollsToMainTop) {
contentScrollView.scrollsToTop = !mainCanScroll;
}
}
}
這里是到臨界點過渡時的處理,canScroll = YES
代表著主動滑動,反之則是懸停,被動跟滑,當mainScrollView
可以滑動的時候重置下contentScrollView
的偏移量。mainScrollView.scrollsToTop = YES
則是因為在正好臨界點時如果為NO
則無法回到頂部,mainScrollView
的實際scrollsToTop
值會在KVO contentScrollView
的偏移量大于0時重新賦值。
關于散裝屬性的處理比較簡單,用字典存值,重寫了屬性的get
方法。
最后是關于UIScrollView
分類實現的這兩個方法
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
if (self.p.markScroll) {
//阻止橫豎聯動
UIScrollView *scrollView = (UIScrollView *)otherGestureRecognizer.view;
if ([scrollView isKindOfClass:[UIScrollView class]] && scrollView.p.markScroll) {
return YES;
}
}
//阻止其他意外聯動
return NO;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
if (self.p.scrollManager.enableDynamicSimulate) {
[self.property.scrollManager.dynamicSimulate stop];
if (self.p.isMain) {
XMixScrollManager *scrollManager = self.p.scrollManager;
scrollManager.isTouchMain = point.y < scrollManager.contentScrollDistance;
}
}
return [super pointInside:point withEvent:event];
}
pointInside
的處理,一是記錄是否在需要模擬的坐標區間內滑動,二是停止之前的模擬。動態模擬本身就不多說了,想要了解的可以看文末的鏈接。
部分效果
鏈接
動態模擬部分參考->https://www.tuicool.com/articles/QVJnAbB
完整代碼地址->XMixScrollManager
Swift版代碼地址->XMixScrollManager_swift