从零开始写下拉上拉刷新控件-思考、设计、实现

前言

一直以来,由于业务的高速发展,Alibaba.com网站的买家卖家友办三个App iOS端的下拉上拉等UI控件并没有完全统一,本着打造精品化App的理念,最近刚开发完成了统一的、可以适配三个App端的下拉上拉刷新控件ASCPullToRefresh,效果Demo如下:

下拉刷新
PullToRefresh_下拉

上拉加载
PullToRefresh_上拉

接下来,从思考、设计、实现三个阶段,回顾一下整个开发过程~

思考

为什么造轮子

首先是为什么,为什么要从零开始全新写一个下拉上拉的控件?

确实,目前GitHub上开源的下拉上拉刷新控件非常多,Star数量多的、常用的如:MJRefreshSVPullToRefresh,都已经久经考验。但是经过一番预研,还是决定从头“造轮子”,总结的原因可以归为以下几点:

  • 定制化:现有的开源库无法满足方便的进行高度定制,或者实现起来比较烦琐
  • 稳定性:无法保证开源库的代码稳定性,API的变动很容易影响现有业务开发
  • 冗余程度:自定义程度高、覆盖面广的开源库往往代码、API数量繁多,各种不需要的样式、配置都会一起包含起来,无形当中造成了冗余

所以说,造“定制化的轮子“还是很有必要的=。=

自定义程度和API复杂度

既然要自己造轮子,那就不得不说说自定义程度和API复杂度的关系。先看看下面的图:

自定义程度和API复杂度

自定义程度高,带来的就是API复杂度高,例如MJReFresh,针对文字、Loading样式、时间戳、GIF动画等多种样式做了兼容,API数量就比较多。自定义程度低,如SVPullToRefresh,只有文字、Loading菊花动画,箭头,API就比较简单。

如何平衡?

那么如何平衡自定义和API数量的关系?ASCPullToRefresh采用的办法是:

“只满足两个“极端情况“,如果是预置样式,那么就完全不可定制,API最简化;如果要定制,那么就要完全重写整个动画,ASCPullToRefresh只提供最基本的状态、拉动数值变化等响应事件,控制API的数量”

由于大部分情况下页面的下拉上拉都是预置统一的样式,所以最简化的API可以减少使用难度和干扰;如果需要定制化,比如大促、活动、某个页面需要不同的动画,就重新按照API要求重新写动画,底层只暴露动画需要的回调、事件,控制API的数量。

MindNode整理关键点

对自定义、API复杂度有了个简单的感觉后,就可以头脑风暴一下,用MindNode整理一下整个下拉上拉的一些重要的点,如状态类型、三端动画、附加特性等:

PullToRefresh_思维导图

  • 控件状态:这个是下拉上拉控件的基本
  • 开源库参考:参考优秀的轮子,吸取精华
  • 附加功能:根据日常经验、开源库总结的附加功能点
  • 注意点:一些可能导致Bug的点
  • 预置固定动画:Alibaba Logo水波动画下拉,和地球转动上拉的预置动画预先思考的技术点
  • 自定义动画:自定义动画实现要响应的内容
  • 全局动画配置:由于三个客户端的样式可能不同,独立出来配置管理
  • 下拉上拉容器:真正的状态、事件转换、监听

设计

关键点整理完了以后,就可以开始代码层次上的设计。

类设计-职责拆分

首先是大体上对代码层次职责做拆分。

下拉上拉控件本质上就是:"监听ScrollView滑动 --> 转换状态 --> 执行动画/回调",所以,按照这个流程,可以做出如下拆分:

PullToRefresh_职责拆分

  • UIScrollView:采用category实现下拉上拉管理,持有下拉上拉View,便于实现如下拉上拉互斥、是否可以触发等需要相互结合的逻辑
  • ContainerView:监听ScrollView的contentOffset、contentSize等值的变化,并作出状态改变、切换,并将状态、滑动参数反馈给ScrollView、AnimationView
  • AnimationView:最终呈现给用户的View,根据ContainerView的状态、滑动反馈,执行动画;将用户的点击等操作返回给ContainerView

依照这个思路,再加上统一的动画管理、常量定义、工具类,类的设计可以总结如下:

ASCPullToRefresh_代码类结构

API设计-精简

预置动画样式
对于预置动画样式,API的设计就可以非常精简,只用最基本的即可,以下拉刷新为例,使用的时候只用引入UIScrollView的Category即可:

// 通过UIScrollView的Category暴露

// 主动触发
- (void)asc_triggerPullToRefresh;

// 停止刷新
- (void)asc_stopPullToRefresh;

// 设置是否开启
- (void)asc_setPullToRefreshEnable:(BOOL)enable;

// 设置回调
- (void)asc_setPullToRefreshReloadBlock:(ASCPullToRefreshLoadBlock)reloadBlock;

自定义动画样式
对于自定义的动画,在UIScrollView的Category中增加一个设置方法,动画View实现Protocol:

// 通过UIScrollView的Category暴露

// 设置回调,并使用自定义的动画View
- (void)asc_setPullToRefreshReloadBlock:(ASCPullToRefreshLoadBlock)reloadBlock
                          animationView:(ASCPullToRefreshBaseAnimationView *)animationView;

自定义动画View需要实现的Protocol:

// 动画View要实现的接口
@protocol ASCPullToRefreshAnimationViewProtocol <NSObject>

// 动画内容高度
- (CGFloat)heightForContentInPullToRefreshContainerView:(ASCPullToRefreshContainerView *)containerView;

// 触发高度
- (CGFloat)heightForTriggerRefreshInPullToRefreshContainerView:(ASCPullToRefreshContainerView *)containerView;

// pulling滑动回调
- (void)pullToRefreshContainerView:(ASCPullToRefreshContainerView *)containerView
               didPullWithDistance:(CGFloat)scrollDistance
                  scrollPercentage:(CGFloat)scrollPercentage;

// 状态变化回调
- (void)pullToRefreshContainerView:(ASCPullToRefreshContainerView *)containerView
                didChangeStateFrom:(ASCPullToRefreshState)oldState
                                to:(ASCPullToRefreshState)newState;
@end

Container要实现的Animation的Protocol同理,就不再贴代码了。

实现

终于到了实现,由于技术实现上比较简单,所以这里只对关键部分做简单的描述。

UIScrollView+ASCPullToRefresh

Category要干的事情就是:

  • 创建容器ContainerView
  • 从ConfigManager获取当前App端对应的动画AnimationView
  • 保存外部传进来的回调Block
  • 组合ContainerView和AnimationView,设置delegate
  • 在下拉/上拉触发的时候回调Block执行回调
  • Category里面保存变量用Associated Objects

ConfigManager

由于要适配三个App端,每个端的动画样式、多语言配置可能都不一样,所以ConfigManager管理类要干的事就是:

  • 在AppDelegate启动阶段初始化
  • 根据当前客户端配置预置动画、多语言、样式

容器ContainerView

容器ContainerView要做的事情就是监听ScrollView滑动时的contentOffset、size的变化,并转换成对应的状态。

willMoveToSuperview:的时候添加、删除KVO监听:

- (void)willMoveToSuperview:(UIView *)newSuperview {
    if (!self.superview && [newSuperview isKindOfClass:[UIScrollView class]]) {
        // 添加KVO、更新Frame、enable
    }
    if (self.superview && !newSuperview) {
        // 移除KVO、disable
    }
}

在ScrollView的contentOffset变化时,根据offset的值,切换状态,调整inset

- (void)onKVOValueChangeForContentOffset:(CGPoint)contentOffset {
    // 如果不可见、没有开启、更新contentInset时,直接返回
    if (!self.window || !_enable || _isModifyingContentInset || _isLoading) {
        return;
    }

    // 滚动的绝对距离
    CGFloat scrollDistance = -(contentOffset.y + _scrollView.contentInset.top);

    // 根据滑动距离的范围,转换状态,还要考虑到是否正在滑动、减速等
    // if (scrollDistance < 0) ... 
    // if (scrollDistance < TriggerThreshold) ...

    // 向ScrollView、AnimationView反馈状态、滑动距离、百分比
}

加载动画

Logo水波动画主要由三层组成,两层水波+一层Logo的Mask,如下:

ASCPullToRefresh_Logo动画拆解

利用PaintCode for Sketch导出Logo的代码
手动实现Logo的贝塞尔曲线代码显然是不理智的=。=,还好有PaintCode plugin for Sketch,设计师给Logo的Sketch文件,然后去掉背景色等所有颜色,导出贝塞尔曲线代码即可。

Logo的UIBezierPath路径的大小调整
由于导出的代码路径的大小、位置是固定的,所以还要对其作调整,还好UIBezierPath支持CGAffineTransform变换。

// 大小调整
[logoPath applyTransform:CGAffineTransformMakeScale(widthScale, heightScale)];
// 位置调整
[logoPath applyTransform:CGAffineTransformMakeTranslation(xOffset, yOffset)];

Wave水波动画
所谓水波,其实就是移动的正选曲线,CADisplayLink+y=Asin(ωx+φ)+k+UIBezierPath+CAShapeLayer+CAGradientLayer就可以实现。
原理就是每次CADisplayLink回调的时候,递增或递减φ偏移,然后在X轴上递增的用UIBezierPath连线,然后对背景CAGradientLayer用曲线做mask,每秒60帧的刷新绘制,就会产生动画。

上拉加载 - 地球转动动画

上拉加载的动画是个转动的地球,原理就是两张世界地图并排循环移动。

ASCPullToRefresh_地球动画

两张地图图片循环播放
用CAAnimation循环移动两张并列的地图图片就可以实现地球转动动画。

App切换到后台时,动画停止
在App切换到后台时,动画会停止,所以还要监听UIApplicationWillEnterForegroundNotificationUIApplicationDidEnterBackgroundNotification两个Notification,在合适的时候重启动画。

实现部分的关键点就是上面这些了,技术难度都不大,重点更在于细节的处理。

总结

永远精益求精~