【瞎搞iOS开发01】封装仿知乎日报的导航栏刷新控件

有幸拜读了[一生X命]分享的文章仿写知乎日报-主页面(Part 1),自己瞎搞一上午,封装了一个基于导航栏navigationitem.titleview的简单刷新控件。集成刷新和显示Title,支持拓展成响应事件的图片按钮。效果图还是用[一生X命]的吧。

动图来自:一生X命

目录

  • 布局
  • 功能实现
  • 对打破Block循环引用的简单总结
布局思路

通过标题title去计算好Size,然后计算底层的容器视图contenView的宽高再进行布局。标题居中,左侧放菊花(高度20),右侧放一个view(用于拓展),跟菊花的位置对称。为了美观,视图之间留有space=2的空隙,如破图:


JPRefreshTitleView.png

** 这里有3种情况:**

  1. 用title计算的size.height小于菊花的高度
  2. size.height大于菊花的高度
  3. 传入的title为nil 或者 @""时,将菊花居中

宏定义菊花的高度和空隙的宽度

#define JKACTIVITY_HEIGHT 20
#define JKSPACE 2

首先计算title的size


CGSize size = [title boundingRectWithSize:CGSizeMake(MAXFLOAT, 44)
                                      options:NSStringDrawingUsesLineFragmentOrigin
                                   attributes:@{NSFontAttributeName:contenView.titleLabel.font}
                                      context:nil].size;
CGSize newSize = [contenView.titleLabel sizeThatFits:size];// 进一取“整”,有约束时慎用此方法

再计算contentView的宽高以及titleLabel、菊花activityIndicator的中心坐标和宽高(以下是主要代码,非全部)

    JPRefreshTitleView * contenView = [[JPRefreshTitleView alloc]init];

    contenView.viewHeight = newSize.height < JKACTIVITY_HEIGHT ? JKACTIVITY_HEIGHT : newSize.height;
    // 传title时,会将title居中,传入nil时,将activityIndicator居中。
    contenView.viewWidth  = size.width ? newSize.width + (2 * JKSPACE + JKACTIVITY_HEIGHT) * 2 : newSize.width + 2 * JKSPACE + JKACTIVITY_HEIGHT;

    CGPoint labelCenter = CGPointMake(JKACTIVITY_HEIGHT + 2 * JKSPACE + newSize.width/2.0, contenView.viewHeight/2.0);
    CGRect labelBounds = CGRectMake(0, 0, newSize.width, newSize.height);

    contenView.activityIndicator.bounds = CGRectMake(0, 0, JKACTIVITY_HEIGHT, JKACTIVITY_HEIGHT);
    contenView.activityIndicator.center = CGPointMake(JKSPACE + JKACTIVITY_HEIGHT/2, contenView.viewHeight/2.0);

    contenView.bounds = CGRectMake(0, 0, contenView.viewWidth, contenView.viewHeight);
    [contenView addSubview:contenView.titleLabel];
    [contenView addSubview:contenView.activityIndicator];
    viewController.navigationItem.titleView = contenView;

然后用CAShapeLayer和贝塞尔曲线创建2个圆圈,需要时再显示

- (void)addCircleLayersWithColor:(UIColor *)color{
    
    self.backgroundLayer = [CAShapeLayer layer];
    self.backgroundLayer.anchorPoint = CGPointMake(0.5, 0.5);
    self.backgroundLayer.strokeColor = [UIColor lightGrayColor].CGColor;
    self.backgroundLayer.fillColor = [UIColor clearColor].CGColor;
    self.backgroundLayer.position  = self.activityIndicator.center;
    self.backgroundLayer.lineWidth = 1.5;
    self.backgroundLayer.strokeStart = 0;
    self.backgroundLayer.strokeEnd = 1.0;
    
    CGRect bounds = self.activityIndicator.bounds;
    //bounds.size.height -= 2;
    //bounds.size.width -= 2;   //对应cornerRadius:JKACTIVITY_HEIGHT/2.0-1
    self.backgroundLayer.bounds = bounds;
    
    UIBezierPath * backPath = [UIBezierPath bezierPathWithRoundedRect:bounds cornerRadius:JKACTIVITY_HEIGHT/2.0];
    self.backgroundLayer.path = backPath.CGPath;
    
    self.foregroundLayer = [CAShapeLayer layer];
    self.foregroundLayer.anchorPoint = CGPointMake(0.5, 0.5);
    self.foregroundLayer.strokeColor = color ? color.CGColor : [UIColor darkGrayColor].CGColor;
    self.foregroundLayer.fillColor = [UIColor clearColor].CGColor;
    self.foregroundLayer.position  = self.activityIndicator.center;
    self.foregroundLayer.lineWidth = 2;
    self.foregroundLayer.strokeStart = 0;
    self.foregroundLayer.strokeEnd = 0;
    self.foregroundLayer.bounds = bounds;
    self.foregroundLayer.path = backPath.CGPath;
    
    [self.layer addSublayer:self.backgroundLayer];
    [self.layer addSublayer:self.foregroundLayer];
    
    [self hideCircleLayer];
}

- (void)hideCircleLayer{
    self.backgroundLayer.hidden = YES;
    self.foregroundLayer.hidden = YES;
}

- (void)displayCircleLayer{
    self.backgroundLayer.hidden = NO;
    self.foregroundLayer.hidden = NO;
}

用于拓展的rightView属性对外公开,但是重写setter方法控制大小。通常在titleView上增加按钮都会带上文字,所以不考虑传入空title时的布局,只需考虑左右对称。

- (void)setRightView:(UIView *)rightView{
    if (_rightView) { 
         [_rightView removeFromSuperview];
          _rightView = nil;
    }
    
    rightView.bounds = CGRectMake(0, 0, JKACTIVITY_HEIGHT, JKACTIVITY_HEIGHT);
    rightView.center = CGPointMake(self.viewWidth - JKACTIVITY_HEIGHT/2.0 + JKSPACE, self.viewHeight/2.0);
    _rightView = rightView;
    [self addSubview:_rightView];
}
实现功能

通过KVO监测scrollView/tableView/collectionView的contentOffset
**
对外公开的方法可以传入viewController以及UIScrollView继承体系对象。viewController用处不大,主要为了内部实现设置navgitionItem.titleView,
引入scrollView是为了在控制器释放后还能移除KVO,以及监测contentInset**。关键代码如下,


@property (nonatomic, strong)UIScrollView * scrollView;

+ (JPRefreshTitleView *)showRefreshViewInViewController:(UIViewController *)viewController
                                  observableScrollView:(UIScrollView *)scrollView
                                                 title:(NSString *)title
                                                  font:(UIFont *)font
                                             textColor:(UIColor *)textColor
                                activityIndicatorColor:(UIColor *)activityIndicatorColor;

if (scrollView) [scrollView addObserver:contenView forKeyPath:@"contentOffset" options:NSKeyValueObservingOptionNew context:nil];

// 避免内存泄露
- (void)removeJPRefreshTitleView{
    [self stopRefresh];
    if(self.scrollView) {
         [self.scrollView removeObserver:self forKeyPath:@"contentOffset"];
          self.scrollView = nil;
    }
    if (self.rightView) self.rightView = nil;
}

- (void)dealloc{
    [self removeJPRefreshTitleView];
    JKLog(@"%@被释放",[self class]);
}

实现KVO的回调方法,以实现实时监测ScrollView的滑动偏移。这里增加三个属性配合使用,因为系统优化机制viewController.automaticallyAdjustsScrollViewInsets及手动设置contentInset都会改变距离顶部的偏移量,所有咱用marginTop来记录scrollView/tableView/collectionView的contentInset.top,contentInset会影响内容的内嵌显示,contentOffset则影响内容的偏移,而我们要监听计算的实际滑动偏移是两者之和,即CGFloat newoffsetY = offsetY + self.marginTop,最开始为0。刷新的临界点threshold,向下拖动的偏移【绝对值或者距离】超过80松手后就刷新,实际数值是-80。

@property (nonatomic, assign)CGFloat marginTop;

@property (nonatomic, assign)CGFloat threshold;

@property (nonatomic, assign)CGFloat progress;

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context{
    if ([keyPath isEqualToString:@"contentOffset"]) {
        
        // 实时监测scrollView.contentInset.top,存在系统优化机制时为-64,关闭后为0(不包括手动设置的情况)
        if (self.marginTop != self.scrollView.contentInset.top) {
            self.marginTop = self.scrollView.contentInset.top;
        }
        if (self.isRefreshing) return;
        
        CGFloat offsetY = [change[@"new"] CGPointValue].y;
        
        // 栗子:存在系统优化机制时scrollView.contentInset.top = 64,而scrollView.contenOffset.y= -64
        // 相加之和,即newoffsetY便是我们要算的实际偏移,最开始等于0(向下拖时,newoffsetY < 0)
        CGFloat newoffsetY = offsetY + self.marginTop;
        
        // -80<newoffsetY<0 即拖动距离大于0,小于80,重写progress的setter方法进行进度条的逻辑处理
       
         if (newoffsetY > 0){   // 一直向上拖
            self.progress = 0; // KVO有点延迟,滑动过快会导致越过0点后progress >0。
           
        }else if (newoffsetY >= self.threshold && newoffsetY <= 0) {
            self.progress = newoffsetY/self.threshold;
            
        // 临界点,松手后开始刷新
        }else if (newoffsetY < self.threshold && !self.scrollView.isDragging){ 
            [self startRefresh];
            self.progress = 0;
  
        }else{  // 超过临界点,但是还在拖拽
            if (self.progress > 0 && self.progress < 1) {
                self.progress = 1;  // KVO有点延迟,拖拽过快会导致越过临界点后progress <1。
            }
        }
        
        
    }else [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}

// 重写progress的setter方法,在这处理进度条圆圈的显示和隐藏
- (void)setProgress:(CGFloat)progress{
    if (_progress == progress) {
        return;
    }
    _progress = progress;
    if (progress == 0) {
        [self hideCircleLayer];

    // 松手后才能隐藏
    } else if (progress == 1 && !self.scrollView.isDragging){
        [self hideCircleLayer];
    }else{
        [self displayCircleLayer];
    }
    
// 这里处理进度条回退的动画,分拖拽回退和自动回退的动画,分别使用线性和缓慢结束的效果,看起来比较流畅。
    [CATransaction begin];
    [CATransaction setDisableActions:NO];
    if (self.scrollView.isDragging) {
        [CATransaction setAnimationDuration:0.15];
        [CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]];
    }else{
        [CATransaction setAnimationDuration:0.25];
        [CATransaction setAnimationTimingFunction:[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseOut]];
    }
    self.foregroundLayer.strokeEnd = MIN(progress, 1);
    [CATransaction commit];
}
对Block循环引用的一点小总结

开发中很多时候都会用到Block,但是block容易引起循环引用。比如下面这种写法,self强引用JPRefreshTitleView ,而JPRefreshTitleView 的refreshingBlock又会强引用self,就形成了循环引用,造成内存泄露。

    self.refrshView = [JPRefreshTitleView showRefreshViewInViewController:self
                                                     observableScrollView:self.tableView
                                                                    title:@"JPRefreshTitleView"
                                                                     font:[UIFont systemFontOfSize:18]
                                                                textColor:[UIColor blackColor]
                                                          refreshingBlock:^{
                                                              
                                                              [self.tableView reloadData];
                                                              [self endRefresh];
                                                          }];

为了少敲点强弱转换的代码并在block里面直接用self,尝试着在JPRefreshTitleView内部做点手脚,几经折腾,最终还是选择用转换self强弱引用的方法。

为了打破循环引用,通常我们选择转换成weakSelf

__weak typeof(self) weakself = self;

但是这种写法有个缺点,就是self过早释放造成weakSelf置空,block回调容易crash。为了避免过早释放self,可以在block里面进行强引用转换。block执行完了后都会被释放掉,这样既能避免block循环引用,又能避免self过早释放。

__strong typeof(weakself) strongself = weakself;

能不能再懒一点,少敲点代码?当然可以,在网上搜到了强弱转换的宏,但不知咋个回事,从网上拷贝的宏定义不能用,然后自己瞎搞了下,又能用了。需要弱转换时输入:

WeakSelf; 或者 Weak(self);

在block里面强引用输入:

StrongSelf; 或者 Strong(self);

然后就可以直接用self了。其实这个self就是上面的strongself ,曲线救国,命名为self而已。当然上面的strongself 也能命名成self,于是就有下面三种混搭写法:

    WeakSelf; // Weak(self); // __weak typeof(self) weakself = self;
    self.refrshView = [JPRefreshTitleView showRefreshViewInViewController:self
                                                     observableScrollView:self.tableView
                                                                    title:@"JPRefreshTitleView"
                                                                     font:[UIFont systemFontOfSize:18]
                                                                textColor:[UIColor blackColor]
                                                          refreshingBlock:^{

                           StrongSelf; // Strong(self); // __strong typeof(weakself) self = weakself;
                           [self.tableView reloadData];
                     }];

另外一种方法可以借鉴AFNetworking里面的用法,但是这种方法只适合一次性的Block,用完就会置为nil.即相当于在JPRefreshTitleView内部调用完refreshingBlock就执行refreshingBlock=nil;但是JPRefreshTitleView的block是多次调用的,所以不能用完一次就置为nil,只能选择用强弱转换self的方法。关于AFNetworking里面的用法可以参考知乎的一些答案:为什么系统的block,AFN网络请求的block内使用self不会造成循环引用?

知乎答案截图
知乎答案截图

这是我修改后的宏定义,可以直接用(ARC模式亲测可以,MRC没测试)

#ifndef    weak_self
#if __has_feature(objc_arc)
#define WeakSelf __weak __typeof__(self) weakself = self;
#else
#define WeakSelf autoreleasepool{} __block __typeof__(self) blockSelf = self;
#endif
#endif
#ifndef    strong_self
#if __has_feature(objc_arc)
#define StrongSelf  __typeof__(weakself) self = weakself;
#else
#define StrongSelf try{} @finally{} __typeof__(blockSelf) self = blockSelf;
#endif
#endif



#ifndef    Weak
#if __has_feature(objc_arc)
#define Weak(object) __weak __typeof__(object) weak##object = object;
#else
#define Weak(object) autoreleasepool{} __block __typeof__(object) block##object = object;
#endif
#endif
#ifndef    Strong
#if __has_feature(objc_arc)
#define Strong(object) __typeof__(object) object = weak##object;
#else
#define Strong(object) try{} @finally{} __typeof__(object) object = block##object;
#endif
#endif

第一次在简书上发表文章,其实也没啥技术含量,但是开了个头,就会继续写下去,初出茅庐,还望各位同行多多指教。有了前辈的思想,封装起来并不难,通过KVO监听偏移和实现刷新,注意Block循环引用和移除KVO就行。难理解的地方应该是计算实际偏移,存在优化机制和手动设置时会影响内嵌和偏移量,所以要通过抵消来计算实际偏移。PS: 手动设置tableView的contentInset需要在viewDidAppear方法里面实现。

GitHub
目前只支持监听一个tableView,后续会继续更新以支持监听多个tableView。

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

推荐阅读更多精彩内容

  • 1.badgeVaule气泡提示 2.git终端命令方法> pwd查看全部 >cd>ls >之后桌面找到文件夹内容...
    i得深刻方得S阅读 4,640评论 1 9
  • 前言 由于最近两个多月,笔者正和小伙伴们忙于对公司新项目的开发,笔者主要负责项目整体架构的搭建以及功能模块的分工。...
    CoderMikeHe阅读 27,013评论 74 271
  • *7月8日上午 N:Block :跟一个函数块差不多,会对里面所有的内容的引用计数+1,想要解决就用__block...
    炙冰阅读 2,477评论 1 14
  • *面试心声:其实这些题本人都没怎么背,但是在上海 两周半 面了大约10家 收到差不多3个offer,总结起来就是把...
    Dove_iOS阅读 27,131评论 30 470
  • 今天早上迷迷糊糊中醒来,刚好看到老公把房间的门关上,然后在客厅跟多宝在聊天…… 多宝:妈妈醒了没? 老公:妈妈还在...
    金晶花阅读 242评论 0 0