用UIKit Dynamics模仿UIScrollView
饿了么在上个版本的时候对餐厅页做了很大的改动, 无论是视觉上还是交互上都有很不错的效果. 为了实现这种效果, 我们自己用UIPanGestureRecognizer和UIKit Dynamics模拟了系统的UIScrollView, 包括惯性滚动, 弹性, 橡皮筋(RubberBanding)效果.
在刚接到这个任务的时候, 有过几种想法:
- 这个效果很像是UITableView加上Header的Parallel效果
- 可以在一个UIScrollView上面嵌套一个UITableView作为子视图
这些方案都被否决了. 第一种方案, 因为当前页面不仅有两个TableView(食物类别和菜单), 而且要支持左右滚动在”商品”, "评价”, "详情”三个页面切换. 用TableView的header做视差效果是不太可能做到的. 对第二种方案, 是在-[UIScrollViewDelegate scrollViewDidScroll:]
中再手动修改其中一个ScrollView的contentOffset
, 使得当前的两个scrollView的contentOffset
都是正确的, 但是难点是很难去指定手指在屏幕上滑动的时候, 是父view还是子view的UIPanGestureRecognizer手势被响应. 而考虑先禁用其中的一个手势(比如子view的), 先让父View的手势可以驱动父View的contentOffset
改变, 直到父view的contentOffset
到了某个位置再启用子view的手势, 禁用父view的. 这带来一个问题, 在切换手势的enable的时候, 即使手指没有离开屏幕, 但是手势已经禁用, 导致滚动中断, 除非手指离开屏幕后重新触摸才能再次滚动, 这样的效果比较不流畅, 并且其中的逻辑比较复杂, 不太容易处理. 或者子类UIScrollView和UITableView, 在手势代理gestureRecognizer:shouldRecognizeSimultaneouslyWithGestureRecognizer
返回YES, 使得两个ScrollView可以同时滚动, 然后在scrollViewDidScroll:
中还原其中一个ScrollView的contentOffset. 但是这样导致逻辑变得复杂, 因为视图中的手势太多. 把ScrollView添加为另一个ScrollView的子视图并不推荐.
最后, 考虑到这个效果订制程度很高, 于是自己去模仿一下UIScrollView的特性.
首先说明一下视图的结构:
<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都禁用. 由于使用了AutoLayout,
[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对应的约束, 就可以做出如下的效果:
为了让交互的所有细节都可控, 我们要把FoodListView, RatingListView, SummaryListView的scrollEnabled设置为NO, 也就是将UIScrollView自带的panGestureRecognizer禁用, 然后在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属性:
在每一帧动画的时候都会调用下. 这些组合起来, 足够去模拟ScrollView的各种行为了.
一般我们使用UIKit Dynamics的时候, 我们是把各种Behaviour直接添加到UIView上, 然后视图就会在它到作用下动起来. 但在现在的情况下, 并不能够直接对视图添加Behaviour. 由于Behaviour实际是对遵循UIDynamicItem协议的对象做物理动画, 所以可以把contentOffset或者顶部约束的值做一层抽象.
objc @interface DynamicItem : NSObject