原文地址 eleme 移動(dòng)組博客
餓了么在5.8版本的時(shí)候?qū)Σ蛷d頁(yè)做了很大的改動(dòng), 在視覺(jué)和交互上都帶來(lái)了很不錯(cuò)的效果. 為了實(shí)現(xiàn)這種效果, 我們內(nèi)部的PhilCai同學(xué)用UIPanGestureRecognizer和UIKit Dynamics模擬了系統(tǒng)的UIScrollView, 包括慣性滾動(dòng), 彈性, 橡皮筋(RubberBanding)效果.

首先說(shuō)明一下視圖的結(jié)構(gòu):
<ParentViewController.View>
| <Container> //UIView
| | <SegmentView>
| | <ScrollView> //僅左右滑動(dòng)(pagingEnabled)
| | | <ChildViewController1.View>
| | | | <CategoryListView>
| | | | <FoodListView>
| | | <ChildViewController2.View>
| | | | <RatingListView>
| | | <ChildViewController3.View>
| | | | <SummaryListView>
ParentViewController就是從首頁(yè)P(yáng)ush進(jìn)入的ViewController, 在它的View上放置了一個(gè)Container(一個(gè)普通的UIView), Container的上方是SegmentView,下方是一個(gè)左右滑動(dòng)的ScrollView; 在ScrollView上, 從左往右放置了三個(gè)ViewController的View; 所有的tableView視圖的bounce都禁用.
和競(jìng)品一樣综慎,我們都是在一個(gè)UIScrollView上面嵌套一個(gè)UITableView作為子視圖绒极,但是他們的方案都有一種問(wèn)題:只在contentOffset變化時(shí)就立即把scrollView移動(dòng)到了頭部腺占,帶來(lái)了非常明顯的“不跟手”的交互體驗(yàn)珊豹。
而我們的交互是:FoodListView 在 Container 向上推動(dòng)的時(shí)候不滑動(dòng),只有 Container 到頂部 Fix住 FoodListView 才開(kāi)始滑動(dòng)获洲。假如基于這種簡(jiǎn)單的 contentOffSet 來(lái)做擒滑,會(huì)有幾個(gè)問(wèn)題:
- FoodListView 在被向上推的時(shí)候不滑動(dòng)情萤,需要禁用手勢(shì)帆精。
- Container 在 Fix 的時(shí)候需要把 FoodListView 手勢(shì)啟用较屿。
- 但是這時(shí)候 FoodListView會(huì)停住,因?yàn)樗鼪](méi)有被滑動(dòng)過(guò)卓练,需要抬手再次去滑動(dòng)
考慮到這個(gè)效果訂制程度很高,我們自己實(shí)現(xiàn)了一套UIScrollView的滑動(dòng)效果隘蝎。
[Container mas_makeConstraints:^(MASConstraintMaker *make) {
make.left.right.bottom.equalTo(ParentViewController.View);
make.top.equalTo(ParentViewController.View).offset(topOffset);
}];
Container的left, right, bottom都對(duì)應(yīng)ParentViewController.View的left,right,bottom, 我們只需要修改topOffset對(duì)應(yīng)的約束, 就可以做出如下的效果:

接下來(lái)關(guān)閉所有scrollView的手勢(shì)(包括tableView),然后在Container上加上自己的PanGestureRecognizer. 這樣之后只要是和上下滾動(dòng)相關(guān)的交互(tableView的滾動(dòng)和Container的top的約束)都由自己實(shí)現(xiàn)的PanGestureRecognizer完成.
這么做有兩點(diǎn)優(yōu)勢(shì):
- 當(dāng)在上下滑動(dòng)的時(shí)候PanGestureRecognizer一定會(huì)觸發(fā), 并且在滑動(dòng)的時(shí)候, 可以精確的控制當(dāng)前手勢(shì)的位移是修改Container的頂部約束還是修改當(dāng)前頁(yè)面的tableView的contentOffset;
- 在手勢(shì)結(jié)束的時(shí)候, 可以獲取最后手勢(shì)的速度
-[UIPanGestureRecognizer velocityInView:]
這方便了之后模擬慣性效果.
在模擬ScrollView的三個(gè)特性里面, 最簡(jiǎn)單的是RubberBanding(橡皮筋效果), 慣性滾動(dòng)和彈性原理是類(lèi)似的.
RubberBanding
因?yàn)橹粏⒂昧俗远x的pan手勢(shì), 在普通情況下, 要修改tableView的contentOffset 或者修改Container的頂部約束, 只需要在pan.state == UIGestureRecognizerStateChanged
, 根據(jù)[pan translationInView: Container].y
獲取垂直方向的手勢(shì)位移, 修改contentOffset或者約束的變化等于手勢(shì)位移. 至于RubberBanding, 在垂直方向上有兩種可能: Container距離頂部超過(guò)某個(gè)預(yù)設(shè)的值, 手勢(shì)繼續(xù)向下拖動(dòng); 或者tableView的拉到底部之后手勢(shì)繼續(xù)向上. 這個(gè)時(shí)候修改contentOffset
或者頂部約束的變化小于手勢(shì)位移(比如乘以一個(gè)小于1的因數(shù)), 就可以模仿出RubberBanding效果.
慣性 & 彈性
這里說(shuō)的慣性效果不僅包括模仿tableView自身的慣性減速修改contentOffset
.
還包括:
- 在手勢(shì)結(jié)束之后, Container根據(jù)慣性的效果動(dòng)態(tài)改變它的頂部約束.
- Container按照慣性效果到頂部后(top約束減小, Container向上移動(dòng)), 慣性效果沒(méi)有消失, 繼續(xù)驅(qū)動(dòng)tableView的
contentOffset
修改. (速度傳遞) - tableView按照慣性減小
contentOffset.y
到0后, 慣性效果繼續(xù)驅(qū)動(dòng)Container修改頂部約束. (速度傳遞)
同樣, 彈性效果也不只是tableView到達(dá)超過(guò)底部之后放手回彈, 也包括Container距離頂部超過(guò)一定距離之后放手回彈效果, 以及可能因?yàn)樗俣葌鬟f后導(dǎo)致的回彈.
先簡(jiǎn)單的考慮只在手勢(shì)結(jié)束后發(fā)生的慣性和彈性, 很幸運(yùn)的是可以獲取手勢(shì)最后一刻的速度[pan velocityInView:Container].y
. 第一反應(yīng)是使用UIView的springAnimation, 因?yàn)樗邮軅魅胨俣? 但是其他參數(shù)比如duration, 其實(shí)沒(méi)有太好的方案去指定, 如果加上速度傳遞的效果, 它就更無(wú)能為力了. 反復(fù)滑動(dòng)系統(tǒng)的ScrollView, 在調(diào)用棧發(fā)現(xiàn)它是由CADisplayLink驅(qū)動(dòng)的, 發(fā)現(xiàn)它的行為和UIKit Dynamics的動(dòng)畫(huà)很符合, 而且UIKit Dynamics背后也是CADisplayLink,加上UIDynamicBehavior有個(gè)action屬性:
When running, the dynamic animator calls the action block on every animation step.
在每一幀動(dòng)畫(huà)的時(shí)候都會(huì)調(diào)用下. 這些組合起來(lái), 足夠去模擬ScrollView的各種行為了.
一般我們使用UIKit Dynamics的時(shí)候, 我們是把各種Behaviour直接添加到UIView上, 然后視圖就會(huì)在它到作用下動(dòng)起來(lái). 但在現(xiàn)在的情況下, 并不能夠直接對(duì)視圖添加Behaviour. 由于Behaviour實(shí)際是對(duì)遵循UIDynamicItem協(xié)議的對(duì)象做物理動(dòng)畫(huà), 所以可以把contentOffset或者頂部約束的值做一層抽象.
@interface DynamicItem : NSObject<UIDynamicItem>
@property (nonatomic, readwrite) CGPoint center;
@property (nonatomic, readonly) CGRect bounds;
@property (nonatomic, readwrite) CGAffineTransform transform;
@end
@implementation DynamicItem
- (instancetype)init {
if (self = [super init]) {
_bounds = CGRectMake(0, 0, 1, 1);
}
return self;
}
@end
DynamicItem的實(shí)例可以看作是一個(gè)質(zhì)點(diǎn), 在垂直方向上, 它的位置(center)可以用來(lái)代表Container的位置(top), 也可以用來(lái)代表tableView的contentOffset.y
, 它的transform
屬性可以不用考慮.
無(wú)論是修改Container的位置還是tableView的contentOffset
, 在慣性或彈性效果的情況下, 只要在action中將約束的值或者contentOffset.y
設(shè)置為DynamicItem的center.y
就可以. UIKit Dynamics自己會(huì)在每一幀去修改.
比如慣性效果下修改Container的頂部約束大概是這樣的:
// when pan.state == UIGestureRecognizerStateEnded
NVMDynamicItem *item = [NVMDynamicItem new];
// topOffset表示當(dāng)前Container距離頂部的距離
item.center = CGPointMake(0, topOffset);
// velocity是在手勢(shì)結(jié)束的時(shí)候獲取的豎直方向的手勢(shì)速度
UIDynamicItemBehavior *inertialBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[ item ]];
[inertialBehavior addLinearVelocity:CGPointMake(0, velocity) forItem:item];
// 通過(guò)嘗試取2.0比較像系統(tǒng)的效果
inertialBehavior.resistance = 2.0;
inertialBehavior.action = ^{
CGFloat itemTop = item.center.y;
[Container mas_updateConstraints:^(MASConstraintMaker *make) {
make.top.equalTo(ParentViewController.View).offset(itemTop);
}];
};
[self.animator addBehavior:inertialBehavior];
類(lèi)似的彈性效果只需使用UIAttachmentBehavior并且設(shè)置合適的值, 嘗試下來(lái)length = 0, damping = 1, frequency = 1.6, 就有不錯(cuò)的回彈效果.
修改contentOffset也是類(lèi)似, 只不過(guò)是將在action中修改約束的部分改為修改contentOffset.
對(duì)于速度傳遞, 完全一樣的原理, 唯一的變化就是從獲取手勢(shì)的速度變?yōu)楂@取-[UIDynamicItemBehavior linearVelocityForItem]
的線速度, 然后UIDynamicAnimator移除不需要的動(dòng)畫(huà), 按照上面的例子傳入速度再次做慣性動(dòng)畫(huà).
甚至還可以把UIAttachmentBehavior和UIDynamicItemBehavior同時(shí)使用, 模仿有初速度的回彈效果.
大致的思路就是這樣, 只需要注意什么時(shí)候調(diào)用-[UIDynamicAnimator removeBehavior]
停止動(dòng)畫(huà)(比如手勢(shì)剛開(kāi)始的時(shí)候), 以及action中注意retain cycle.
有個(gè)2014年的博客已經(jīng)有了類(lèi)似的例子, 只是交互簡(jiǎn)單一些, 原理是一樣的.
而在運(yùn)用自己的手勢(shì)去實(shí)現(xiàn)ScrollView之后, 碰到了一些細(xì)節(jié)問(wèn)題.
- 自己加到Container上的手勢(shì), 很容易誤觸發(fā)tableView的
-tableView:didSelectRowAtIndexPath:indexPath
協(xié)議方法, 導(dǎo)致很容易Push到下一個(gè)頁(yè)面, 很影響使用. 解決的原理比較簡(jiǎn)單, 在合適的時(shí)機(jī)將當(dāng)前的tableView.userInteractionEnabled設(shè)置為NO, 之后在需要的時(shí)候恢復(fù). 正好UIDynamicAnimatorDelegate提供了動(dòng)畫(huà)將要開(kāi)始dynamicAnimatorWillResume:
和暫停(包括移除bahaviour)dynamicAnimatorDidPause:
的回調(diào). 就在這兩個(gè)地方分別設(shè)置, 效果還可以接受. - 當(dāng)tableView在UIKit Dynamics的作用下滾動(dòng)時(shí), 或者是快速上下滑動(dòng)的時(shí)候, 很容易觸發(fā)左右滑動(dòng)的ScrollView切換頁(yè)面. 解決方案比較tricky: 自定義了UIScrollView的子類(lèi), 在子類(lèi)中將gestureRecognizerShouldBegin:重寫(xiě), 對(duì)于panGestureRecognizer的情況, 在它的水平速度和垂直速度的夾角在一定范圍內(nèi)強(qiáng)制返回NO. 這樣就大大減小了誤觸發(fā)左右滾動(dòng)的操作. 但是還是希望有更好的解決方案.
- 還有一個(gè)很常見(jiàn)的問(wèn)題, 點(diǎn)擊狀態(tài)欄, 正常情況下系統(tǒng)能夠?qū)crollView滾動(dòng)到頂部, 而在一個(gè)Window中有多個(gè)ScrollView的時(shí)候, 它是不一定成功的. 正確的解決方案應(yīng)該是將當(dāng)前頁(yè)面需要響應(yīng)系統(tǒng)statusBar點(diǎn)擊的ScrollView的
scrollsToTop
設(shè)置為YES, 其他都設(shè)置為NO, 并且scrollsToTop
為YES的只能有一個(gè), 這種情況下理論上是可以work的. 但是在解決第一個(gè)問(wèn)題的時(shí)候, 導(dǎo)致了這種解決方法有時(shí)候不成功. 因?yàn)榘l(fā)現(xiàn)在一個(gè)UIScrollView的userInteractionEnabled == NO
的時(shí)候, 狀態(tài)欄點(diǎn)擊返回頂部效果是無(wú)效的(比如正在慣性滾動(dòng)的時(shí)候, 狀態(tài)還是NO, 這個(gè)時(shí)候點(diǎn)擊statusBar); 加上在最左邊的頁(yè)面有兩個(gè)tableView需要同時(shí)滾動(dòng)到頂部. 只能換個(gè)解決方案. 子類(lèi)化了全局的UIWindow, 重寫(xiě)它的-pointInside:withEvent:
, 在statusBar區(qū)域被點(diǎn)擊的時(shí)候發(fā)出通知, 監(jiān)聽(tīng)到后手動(dòng)設(shè)置contentOffset到0. - 由于之前很多控件是AutoLayout寫(xiě)的, 比如cell, 因?yàn)楝F(xiàn)在實(shí)現(xiàn)的方案會(huì)頻繁修改約束, 導(dǎo)致滑動(dòng)很卡(之前在iPhone 6就感受到卡頓了). 之后用手動(dòng)布局改了一部分cell, 確實(shí)流暢了很多.
雖然UIKit Dynamics平時(shí)很少用到, 不過(guò)在關(guān)鍵時(shí)刻也發(fā)揮了巨大的作用, 很好奇Apple在實(shí)現(xiàn)UIScrollView會(huì)不會(huì)也用到了它.
- EOF -