自定义MJRefresh:“什么值得买”的下拉刷新实现
写在前面
“什么值得买”是我这种剁手族常用的软件,最近发现它的下拉刷新做得挺好的,而且也算是一种经常见到的样式,正好前几天刚好分析完了MJRefresh,趁热打铁,这次就来尝试实现一下它的下拉刷新吧。
一、总体构成
原厂效果图从上图可以看出,RefreshView主要是两部分组成:
- 位于上方的Label
- 位于下方的ImageView
Label就不多介绍了,我们来重点看一下Image部分。
- 下拉过程中,Circle可以随着我们下拉的位移量改变
- 刷新过程中,缺了一角的Circle会围绕“值”旋转
- 刷新完毕后,动画结束
实现难度不大,下面我们就开始动手吧。
二、刷新部分
最终实现效果最后的效果大概就是这个样子的,还算合格,我们来详细分析下。
(一)资源
提取ipa包内图片资源的方法有很多,而我这人比较懒,所以喜欢直接用工具,这里推荐给大家一款我一直用的:iOS-Images-Extractor,国内的某Coder写的,很好用,分享给大家,好用的话别忘了点个星,是给作者最大的鼓舞。
iOS-Images-Extractor使用界面使用方法很简单,把ipa包拖进去,点击start等待分析完成,之后点击Output Dir就会自动跳转到输出目录。
OK,工具介绍完,我已经把图片找出来了,一共俩:
看到这俩角色,就明了了,一开始我以为缺了一块的Circle是用ShapeLayer画的,原来是美工做的,那就直接用吧,省事。
(二)动画实现
图片"zhi"在下,"circle"在上,然后对circle做旋转动画就OK了。
- (void)viewDidLoad {
[super viewDidLoad];
[self.logoView addSubview:self.circleView];
[self.view addSubview:self.logoView];
}
- (void)viewDidLayoutSubviews{
self.logoView.center = self.view.center;
self.logoView.bounds = CGRectMake(0, 0, 30, 30);
self.circleView.frame = self.logoView.bounds;
}
- (void)viewDidAppear:(BOOL)animated{
[self.circleView.layer addAnimation:[self getTransformAnimation] forKey:nil];
}
-(CABasicAnimation *)getTransformAnimation{
CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"transform.rotation"]; //指定对transform.rotation属性做动画
animation.duration = 2.0f; //设定动画持续时间
animation.byValue = @(M_PI*2); //设定旋转角度,单位是弧度
animation.fillMode = kCAFillModeForwards;//设定动画结束后,不恢复初始状态之设置一
animation.repeatCount = 1000;//设定动画执行次数
animation.removedOnCompletion = NO;//设定动画结束后,不恢复初始状态之设置二
return animation;
}
- (UIImageView *)logoView{
if (!_logoView) {
_logoView = [[UIImageView alloc] init];
_logoView.image = [UIImage imageNamed:@"zhi"];
}
return _logoView;
}
- (UIImageView *)circleView{
if (!_circleView) {
_circleView = [[UIImageView alloc] init];
_circleView.image = [UIImage imageNamed:@"circle"];
}
return _circleView;
}
很简单,主要就是动画部分,如果对动画不熟悉的童鞋,推荐ios核心动画高级技巧。
三、下拉部分
这部分,主要是要实现Circle随我们手势改变自身完成度,先上效果图:
实现效果图(一) 用ShapeLayer画个圆:
这里的Circle部分,我们用CAShapeLayer来做:
CAShapeLayer是一个通过矢量图形而不是bitmap来绘制的图层子类。你指定诸如颜色和线宽等属性,用CGPath来定义想要绘制的图形,最后CAShapeLayer就自动渲染出来了。
形象点来说,就是你给CAShapeLayer指定脚本(Path),并设定好各属性(Color,Width)之后,CAShapeLayer就自动完成了。
-(CAShapeLayer *)getShape{
UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:self.logoView.bounds];//先写剧本
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.path = path.CGPath;//安排剧本
shapeLayer.fillColor = [UIColor clearColor].CGColor;//填充色要为透明,不然会遮挡下面的图层
shapeLayer.strokeColor = [UIColor redColor].CGColor;
shapeLayer.lineWidth = 1.0;
shapeLayer.frame = self.logoView.bounds;
return shapeLayer;
}
- (void)viewDidAppear:(BOOL)animated{
[self.logoView.layer addSublayer:[self getShape]]; //将ShapeLayer图层增加到logoView上
}
画个圆
(二)控制ShapeLayer的绘制进度
圆画完了,下面是和Slider.value关联,让我们能控制圆的绘制进度。
关键属性:strokeStart,strokeEnd
- strokeStart:从哪开始绘制
- strokeEnd:在哪结束绘制
我们设定我们的圆起始点为:
shapeLayer.strokeStart = 0;
shapeLayer.strokeEnd = 0.9;
stroke起始点
可以出,stroke属性的特点:
- 单位是百分比
- 0点在Layer右侧中心
- 顺时针绘制
有了这个属性,我们就可以很方便的实现我们的目标了。
我们把strokeEnd的初始值设为0,再与我们的Slider.value挂钩就好了。
完整代码:
- (void)viewDidAppear:(BOOL)animated{
[self.logoView.layer addSublayer:self.circleLayer];
}
- (CALayer *)circleLayer{
if (!_circleLayer) {
_circleLayer = [self getShape];
}
return _circleLayer;
}
- (CAShapeLayer *)getShape{
UIBezierPath *path = [UIBezierPath bezierPathWithOvalInRect:self.logoView.bounds];
CAShapeLayer *shapeLayer = [CAShapeLayer layer];
shapeLayer.fillColor = [UIColor clearColor].CGColor;
shapeLayer.strokeColor = [UIColor redColor].CGColor;
shapeLayer.lineWidth = 1.0;
shapeLayer.path = path.CGPath;
shapeLayer.frame = self.logoView.bounds;
shapeLayer.strokeEnd = 0;
return shapeLayer;
}
- (IBAction)didSlide:(UISlider *)sender {
self.circleLayer.strokeEnd = sender.value;
}
四、自定义MJRefresh
经过上面两个步骤,我们已经实现了下拉刷新的核心视图动画,接下来该自定义MJRefresh了。
老规矩,先上完成图:
完成效果图
自定义MJRefreshHeader,需要继承自MJRefreshHeader,看过我之前文章的小伙伴一定很熟悉了。
不熟悉也不要紧,不过就有点死记硬背的感觉了。
(一)布局
#pragma mark - Const
CGRect kZZZLogoViewBounds = {0,0,25,25};
#pragma mark 在这里做一些初始化配置(比如添加子控件)
- (void)prepare
{
[super prepare];
[self.logoView addSubview:self.circleView];
[self.logoView.layer addSublayer:self.circleLayer];
[self addSubview:self.logoView];
}
#pragma mark 在这里设置子控件的位置和尺寸
- (void)placeSubviews
{
[super placeSubviews];
self.logoView.center = CGPointMake(self.mj_w/2.0, self.mj_h/2.0 + 10.0);// +10是为了logoView在中心点往下一点的位置,方便观看
self.logoView.bounds = kZZZLogoViewBounds;
self.circleView.frame = self.logoView.bounds;
}
#pragma mark - setter & getter
- (UIImageView *)logoView{
if (!_logoView) {
_logoView = [[UIImageView alloc] init];
_logoView.image = [UIImage imageNamed:@"zhi"];
}
return _logoView;
}
- (UIImageView *)circleView{
if (!_circleView) {
_circleView = [[UIImageView alloc] init];
_circleView.image = [UIImage imageNamed:@"circle"];
_circleLayer.hidden = YES; //刷新时候的图片,开始的时候不需要显示出来
}
return _circleView;
}
- (CAShapeLayer *)circleLayer{
if (!_circleLayer) {
_circleLayer = [self creatCircleShapeLayerWithBounds:kZZZLogoViewBounds];//跟上面的getShapeLayer方法一样,不过这里我稍微改写了原函数,减少依赖
}
return _circleLayer;
}
有几点需要说明的:
- MJRefresh默认高度是54,如需修改,放在prepare文件中即可:self.mj_h = **
- prepare方法中,不能放布局相关的内容,因为调用prepare是在视图初始化的时候,这时候MJRefresh还没有加入到View Hierarchy
-
placeSubViews方法中,注意MJRefreshView的Frame.origin = (0, -self.mj_h),所以调整Y值的时候注意正负。
布局
自定义的时候,慢慢来,出了BUG一般是Frame没设置好,多利用调试工具。
(二)设置动态响应
我们只需要做两件事情:
(1)将下拉位移量与我们的strokeEnd属性关联
关联这件事情,MJRefresh已经帮我们处理了前半部分,我们只需要在相应方法里写个等式就可以了。
�
(2) 处理状态
- Idle :我们要设置各个组件是否隐藏
- Pulling: 不需要处理
- Refreshing:把CircleLayer隐藏,把CircleView显示并做旋转动画
注意的是,我们的需要在endRefreshing方法中,手动移除动画(因为我们在动画定义部分为了动画的流畅性,设置了animation.removedOnCompletion = NO),不然CircleView上的动画会一直运行。
#pragma mark 监听控件的刷新状态
- (void)setState:(MJRefreshState)state
{
MJRefreshCheckState;
switch (state) {
case MJRefreshStateIdle:
self.circleView.hidden = YES;
self.circleLayer.hidden = NO;
break;
case MJRefreshStatePulling:
break;
case MJRefreshStateRefreshing:
self.circleView.hidden = NO;
self.circleLayer.hidden = YES;
[self.circleView.layer addAnimation:[self creatTransformAnimation] forKey:nil];
break;
default:
break;
}
}
- (void)setPullingPercent:(CGFloat)pullingPercent
{
self.circleLayer.strokeEnd = pullingPercent;
}
- (void)endRefreshing{
[self.circleView.layer removeAllAnimations];
[super endRefreshing];
}
来看一下运行结果:
对比原版,貌似有几点问题:
- Refreshing状态的时候,CircleLayer的消失做了一个动画
- Refreshing结束的时候,CirCleLayer因为和PullingPersent的关联,strokeEnd直接设为了0
有问题,就解决问题呗。
第一个问题
self.circleLayer.hidden = YES;
问题出在这行代码上。
这涉及到了CoreAnimation的隐式动画部分,说白了,你对Layer做的属性修改,会触发系统的隐藏动画,所以我们取消系统隐藏动画就好了。取消方法如下:
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.circleLayer.hidden = YES;
[CATransaction commit];
运行结果
好的,这个问题已经不是问题了。
第二个问题
原厂的动画是,刷新完成之后,CircleLayer要保持StrokeEnd = 1.0的状态。
也就是说,需要个参数,能区分进入Idle状态之前是否刷新过,那我们就加个参数呗。
改动部分代码如下:
- (void)prepare
{
[super prepare];
[self.logoView addSubview:self.circleView];
[self.logoView.layer addSublayer:self.circleLayer];
[self addSubview:self.logoView];
self.hasRefreshed = NO;//初始化的时候,肯定是没有刷新过的
}
#pragma mark 监听控件的刷新状态
- (void)setState:(MJRefreshState)state
{
MJRefreshCheckState;
switch (state) {
case MJRefreshStateIdle:
self.circleView.hidden = YES;
self.circleLayer.hidden = NO;
break;
case MJRefreshStatePulling:
break;
case MJRefreshStateRefreshing:
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.circleLayer.hidden = YES;
[CATransaction commit];
self.circleView.hidden = NO;
[self.circleView.layer addAnimation:[self creatTransformAnimation] forKey:nil];
self.hasRefreshed = YES;//刷新过了
break;
default:
break;
}
}
#pragma mark 监听拖拽比例(控件被拖出来的比例)
- (void)setPullingPercent:(CGFloat)pullingPercent
{
if (self.hasRefreshed) {//刷新返回的时候,strokeEnd = 1.0
[CATransaction begin];
[CATransaction setDisableActions:YES];
self.circleLayer.strokeEnd = 1.0;
[CATransaction commit];
self.hasRefreshed = NO;//重置状态为未刷新
}else{
self.circleLayer.strokeEnd = pullingPercent;
}
}
搞定。
总结
MJRefresh给我们提供了很好的底层实现,我们可以在它的基础上,进行丰富的自定义,基本都能满足自己的需求。
哪怕是实在满足不了你了,也可以借鉴MJRefresh的整体思路,自己写一个简单的框架。
我在分析完MJRefresh的技术细节之后,不再感觉自己面对的是一个黑匣子,修改起来是相当地轻松。
所以,读源码果然是提高自己技术水平的有效手段(就是有点累)。
至此,MJRefresh的旅程就算结束了。
希望大家以后都能做出独具个性的刷新控件。