[iOS 交互]浅析饿了么餐厅页交互的实现

原文地址 eleme 移动组博客

饿了么在5.8版本的时候对餐厅页做了很大的改动, 在视觉和交互上都带来了很不错的效果. 为了实现这种效果, 我们内部的PhilCai同学用UIPanGestureRecognizer和UIKit Dynamics模拟了系统的UIScrollView, 包括惯性滚动, 弹性, 橡皮筋(RubberBanding)效果.

首先说明一下视图的结构:

<ParentViewController.View>
   | <Container> //UIView
   |    | <SegmentView>
   |    | <ScrollView> //仅左右滑动(pagingEnabled)
   |    |    | <ChildViewController1.View>
   |    |    |    | <CategoryListView>
   |    |    |    | <FoodListView>
   |    |    | <ChildViewController2.View>
   |    |    |    | <RatingListView>
   |    |    | <ChildViewController3.View>
   |    |    |    | <SummaryListView>

ParentViewController就是从首页Push进入的ViewController, 在它的View上放置了一个Container(一个普通的UIView), Container的上方是SegmentView,下方是一个左右滑动的ScrollView; 在ScrollView上, 从左往右放置了三个ViewController的View; 所有的tableView视图的bounce都禁用.

和竞品一样,我们都是在一个UIScrollView上面嵌套一个UITableView作为子视图,但是他们的方案都有一种问题:只在contentOffset变化时就立即把scrollView移动到了头部,带来了非常明显的“不跟手”的交互体验。
而我们的交互是:FoodListView 在 Container 向上推动的时候不滑动,只有 Container 到顶部 Fix住 FoodListView 才开始滑动。假如基于这种简单的 contentOffSet 来做,会有几个问题:

  • FoodListView 在被向上推的时候不滑动,需要禁用手势。
  • Container 在 Fix 的时候需要把 FoodListView 手势启用。
  • 但是这时候 FoodListView会停住,因为它没有被滑动过,需要抬手再次去滑动

考虑到这个效果订制程度很高,我们自己实现了一套UIScrollView的滑动效果。

  [Container mas_makeConstraints:^(MASConstraintMaker *make) {
        make.left.right.bottom.equalTo(ParentViewController.View);     
        make.top.equalTo(ParentViewController.View).offset(topOffset);
  }];

Container的left, right, bottom都对应ParentViewController.View的left,right,bottom, 我们只需要修改topOffset对应的约束, 就可以做出如下的效果:

接下来关闭所有scrollView的手势(包括tableView),然后在Container上加上自己的PanGestureRecognizer. 这样之后只要是和上下滚动相关的交互(tableView的滚动和Container的top的约束)都由自己实现的PanGestureRecognizer完成.
这么做有两点优势:

  • 当在上下滑动的时候PanGestureRecognizer一定会触发, 并且在滑动的时候, 可以精确的控制当前手势的位移是修改Container的顶部约束还是修改当前页面的tableView的contentOffset;
  • 在手势结束的时候, 可以获取最后手势的速度-[UIPanGestureRecognizer velocityInView:] 这方便了之后模拟惯性效果.

在模拟ScrollView的三个特性里面, 最简单的是RubberBanding(橡皮筋效果), 惯性滚动和弹性原理是类似的.

RubberBanding

因为只启用了自定义的pan手势, 在普通情况下, 要修改tableView的contentOffset 或者修改Container的顶部约束, 只需要在pan.state == UIGestureRecognizerStateChanged, 根据[pan translationInView: Container].y获取垂直方向的手势位移, 修改contentOffset或者约束的变化等于手势位移. 至于RubberBanding, 在垂直方向上有两种可能: Container距离顶部超过某个预设的值, 手势继续向下拖动; 或者tableView的拉到底部之后手势继续向上. 这个时候修改contentOffset或者顶部约束的变化小于手势位移(比如乘以一个小于1的因数), 就可以模仿出RubberBanding效果.

惯性 & 弹性

这里说的惯性效果不仅包括模仿tableView自身的惯性减速修改contentOffset.
还包括:

  • 在手势结束之后, Container根据惯性的效果动态改变它的顶部约束.
  • Container按照惯性效果到顶部后(top约束减小, Container向上移动), 惯性效果没有消失, 继续驱动tableView的contentOffset修改. (速度传递)
  • tableView按照惯性减小contentOffset.y到0后, 惯性效果继续驱动Container修改顶部约束. (速度传递)

同样, 弹性效果也不只是tableView到达超过底部之后放手回弹, 也包括Container距离顶部超过一定距离之后放手回弹效果, 以及可能因为速度传递后导致的回弹.

先简单的考虑只在手势结束后发生的惯性和弹性, 很幸运的是可以获取手势最后一刻的速度[pan velocityInView:Container].y. 第一反应是使用UIView的springAnimation, 因为它接受传入速度. 但是其他参数比如duration, 其实没有太好的方案去指定, 如果加上速度传递的效果, 它就更无能为力了. 反复滑动系统的ScrollView, 在调用栈发现它是由CADisplayLink驱动的, 发现它的行为和UIKit Dynamics的动画很符合, 而且UIKit Dynamics背后也是CADisplayLink,加上UIDynamicBehavior有个action属性:

When running, the dynamic animator calls the action block on every animation step.

在每一帧动画的时候都会调用下. 这些组合起来, 足够去模拟ScrollView的各种行为了.

一般我们使用UIKit Dynamics的时候, 我们是把各种Behaviour直接添加到UIView上, 然后视图就会在它到作用下动起来. 但在现在的情况下, 并不能够直接对视图添加Behaviour. 由于Behaviour实际是对遵循UIDynamicItem协议的对象做物理动画, 所以可以把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的实例可以看作是一个质点, 在垂直方向上, 它的位置(center)可以用来代表Container的位置(top), 也可以用来代表tableView的contentOffset.y, 它的transform属性可以不用考虑.

无论是修改Container的位置还是tableView的contentOffset, 在惯性或弹性效果的情况下, 只要在action中将约束的值或者contentOffset.y设置为DynamicItem的center.y就可以. UIKit Dynamics自己会在每一帧去修改.

比如惯性效果下修改Container的顶部约束大概是这样的:

// when pan.state == UIGestureRecognizerStateEnded
NVMDynamicItem *item = [NVMDynamicItem new];
// topOffset表示当前Container距离顶部的距离
item.center = CGPointMake(0, topOffset);
// velocity是在手势结束的时候获取的竖直方向的手势速度
UIDynamicItemBehavior *inertialBehavior = [[UIDynamicItemBehavior alloc] initWithItems:@[ item ]];
  [inertialBehavior addLinearVelocity:CGPointMake(0, velocity) forItem:item];
  // 通过尝试取2.0比较像系统的效果
  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];

类似的弹性效果只需使用UIAttachmentBehavior并且设置合适的值, 尝试下来length = 0, damping = 1, frequency = 1.6, 就有不错的回弹效果.
修改contentOffset也是类似, 只不过是将在action中修改约束的部分改为修改contentOffset.

对于速度传递, 完全一样的原理, 唯一的变化就是从获取手势的速度变为获取-[UIDynamicItemBehavior linearVelocityForItem]的线速度, 然后UIDynamicAnimator移除不需要的动画, 按照上面的例子传入速度再次做惯性动画.

甚至还可以把UIAttachmentBehavior和UIDynamicItemBehavior同时使用, 模仿有初速度的回弹效果.

大致的思路就是这样, 只需要注意什么时候调用-[UIDynamicAnimator removeBehavior]停止动画(比如手势刚开始的时候), 以及action中注意retain cycle.

有个2014年的博客已经有了类似的例子, 只是交互简单一些, 原理是一样的.

而在运用自己的手势去实现ScrollView之后, 碰到了一些细节问题.

  1. 自己加到Container上的手势, 很容易误触发tableView的-tableView:didSelectRowAtIndexPath:indexPath协议方法, 导致很容易Push到下一个页面, 很影响使用. 解决的原理比较简单, 在合适的时机将当前的tableView.userInteractionEnabled设置为NO, 之后在需要的时候恢复. 正好UIDynamicAnimatorDelegate提供了动画将要开始dynamicAnimatorWillResume:和暂停(包括移除bahaviour)dynamicAnimatorDidPause:的回调. 就在这两个地方分别设置, 效果还可以接受.
  2. 当tableView在UIKit Dynamics的作用下滚动时, 或者是快速上下滑动的时候, 很容易触发左右滑动的ScrollView切换页面. 解决方案比较tricky: 自定义了UIScrollView的子类, 在子类中将gestureRecognizerShouldBegin:重写, 对于panGestureRecognizer的情况, 在它的水平速度和垂直速度的夹角在一定范围内强制返回NO. 这样就大大减小了误触发左右滚动的操作. 但是还是希望有更好的解决方案.
  3. 还有一个很常见的问题, 点击状态栏, 正常情况下系统能够将ScrollView滚动到顶部, 而在一个Window中有多个ScrollView的时候, 它是不一定成功的. 正确的解决方案应该是将当前页面需要响应系统statusBar点击的ScrollView的scrollsToTop设置为YES, 其他都设置为NO, 并且scrollsToTop为YES的只能有一个, 这种情况下理论上是可以work的. 但是在解决第一个问题的时候, 导致了这种解决方法有时候不成功. 因为发现在一个UIScrollView的userInteractionEnabled == NO的时候, 状态栏点击返回顶部效果是无效的(比如正在惯性滚动的时候, 状态还是NO, 这个时候点击statusBar); 加上在最左边的页面有两个tableView需要同时滚动到顶部. 只能换个解决方案. 子类化了全局的UIWindow, 重写它的-pointInside:withEvent:, 在statusBar区域被点击的时候发出通知, 监听到后手动设置contentOffset到0.
  4. 由于之前很多控件是AutoLayout写的, 比如cell, 因为现在实现的方案会频繁修改约束, 导致滑动很卡(之前在iPhone 6就感受到卡顿了). 之后用手动布局改了一部分cell, 确实流畅了很多.

虽然UIKit Dynamics平时很少用到, 不过在关键时刻也发挥了巨大的作用, 很好奇Apple在实现UIScrollView会不会也用到了它.

- EOF -

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,585评论 4 365
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,923评论 1 301
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,314评论 0 248
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,346评论 0 214
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,718评论 3 291
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,828评论 1 223
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,020评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,758评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,486评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,722评论 2 251
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,196评论 1 262
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,546评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,211评论 3 240
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,132评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,916评论 0 200
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,904评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,758评论 2 274

推荐阅读更多精彩内容