[iOS 交互]淺析餓了么餐廳頁(yè)交互的實(shí)現(xiàn)

原文地址 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)題.

  1. 自己加到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è)置, 效果還可以接受.
  2. 當(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)的操作. 但是還是希望有更好的解決方案.
  3. 還有一個(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.
  4. 由于之前很多控件是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 -

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末昆庇,一起剝皮案震驚了整個(gè)濱河市末贾,隨后出現(xiàn)的幾起案子闸溃,更是在濱河造成了極大的恐慌整吆,老刑警劉巖拱撵,帶你破解...
    沈念sama閱讀 219,539評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異表蝙,居然都是意外死亡拴测,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,594評(píng)論 3 396
  • 文/潘曉璐 我一進(jìn)店門(mén)府蛇,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)集索,“玉大人,你說(shuō)我怎么就攤上這事汇跨∥窬#” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,871評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵穷遂,是天一觀的道長(zhǎng)函匕。 經(jīng)常有香客問(wèn)我,道長(zhǎng)蚪黑,這世上最難降的妖魔是什么盅惜? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,963評(píng)論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮忌穿,結(jié)果婚禮上抒寂,老公的妹妹穿的比我還像新娘。我一直安慰自己掠剑,他們只是感情好屈芜,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,984評(píng)論 6 393
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著澡腾,像睡著了一般沸伏。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上动分,一...
    開(kāi)封第一講書(shū)人閱讀 51,763評(píng)論 1 307
  • 那天毅糟,我揣著相機(jī)與錄音,去河邊找鬼澜公。 笑死姆另,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的坟乾。 我是一名探鬼主播迹辐,決...
    沈念sama閱讀 40,468評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼甚侣!你這毒婦竟也來(lái)了明吩?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 39,357評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤殷费,失蹤者是張志新(化名)和其女友劉穎印荔,沒(méi)想到半個(gè)月后低葫,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,850評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡仍律,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,002評(píng)論 3 338
  • 正文 我和宋清朗相戀三年嘿悬,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片水泉。...
    茶點(diǎn)故事閱讀 40,144評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡善涨,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出草则,到底是詐尸還是另有隱情钢拧,我是刑警寧澤,帶...
    沈念sama閱讀 35,823評(píng)論 5 346
  • 正文 年R本政府宣布炕横,位于F島的核電站娶靡,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏看锉。R本人自食惡果不足惜姿锭,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,483評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望伯铣。 院中可真熱鬧呻此,春花似錦、人聲如沸腔寡。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 32,026評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)放前。三九已至忿磅,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間凭语,已是汗流浹背葱她。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,150評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留似扔,地道東北人吨些。 一個(gè)月前我還...
    沈念sama閱讀 48,415評(píng)論 3 373
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像炒辉,于是被迫代替她去往敵國(guó)和親豪墅。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,092評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容