iOS开发 - 列表返回顶部按钮(ScrollToTopButton)

一、ScrollToTopButton的由来

Paste_Image.png

前几天,接到一个需求:列表从下往上滑动超过一屏,出现返回顶部的按钮,悬停3秒后消失。分析一波,列表其实就是UITableView,大家都知道当UIScrollView的scrollsToTop属性赋值为true时,点击状态栏,是会返回到顶部的,那为啥还需要这个需求呢?这个问题就问的好了,下图一为产品经理的一波解释,而图二是同事们的讨论。我觉得,加不加倒也没什么所谓,不过当效果出来后,用着确实还挺不错的。
关于ScrollToTopButton,其实它是一个view,然后addSubview一个UIButton,为啥这么写呢?唔...方便适配?姑且就这么觉得吧。那为什么叫ScrollToTopButton?顾名思义,滚到顶部的按钮嘛,没办法,实在想不到好点的命名。


图一.png
图二.png

二、来一波运行效果

啊哈哈哈,挺不错的吧!接下来,看下具体代码和实现吧...


run.gif

三、分析一波

关于如何将ScrollToTopButton添加到view上,有两种方案:一是写一个UIScrollView扩展,在扩展中,写一个相关方法,然后在对应界面的controller中,调用扩展的方法即可;二则是用runtime,在load函数中,替换系统UIView的didMoveToSuperview方法,在替换的方法中,添加ScrollToTopButton。但都存在一些问题。

1、关于ScrollToTopButton的实现

什么,你要看我写的垃圾💩代码?不好吧?在这里,我就不把我的垃圾代码贴出来了。在文章最后会贴出GitHub的地址,有兴趣的同学可以去查看,有什么建议,欢迎大神留言指导。

代码的相关说明

  • 关于KVO的observe方法,为啥会没有相关监听方法?为啥没有removeObserver方法,这不会crash吗?这篇文章会给你一点解答。分享一个关于KVO的扩展,如果不想导入FBKVOController,以及该KVO扩展的话,只需要将observe的写法改成系统的就可以了,别忘了要在适当的时候removeObserver,不然会crash的。
  • 关于需求中的,当停止滚动后,悬停3秒后隐藏。我这里使用的方法是open func perform(_ aSelector: Selector, with anArgument: Any?, afterDelay delay: TimeInterval)来延迟隐藏,以及open class func cancelPreviousPerformRequests(withTarget aTarget: Any, selector aSelector: Selector, object anArgument: Any?)方法来进行取消延迟的执行。
    为什么不用GCD的dispatch_after进行延迟呢?根据我的了解,dispatch_after一旦延迟后,好像没有相关的方法取消延迟。也就是说,当我们停止滚动后,会调用dispatch_after,但当我们再次滚动时,dispatch_after延迟是不会被取消的,延迟设置的时间到后,还是会被延迟的代码。
    performSelector方式进行的延迟,可以调用cancelPreviousPerformRequests方法来进行取消。需要注意的是,在调用该方法时,需要传入之前被延迟的方法。参考文章:取消延迟执行函数 cancelPreviousPerformRequestsWithTarget

2、如何将ScrollToTopButton添加到view上?

(1)写一个UIScrollView扩展

写扩展的好处就是,只要是UIScrollView或者继承自UIScrollView,就能调用扩展的方法。为什么要返回ScrollToTopButton,不返回可以吗?返回ScrollToTopButton,你可以将其存起来,然后进行判断,如果为nil时,才调用addScrollToTopBtn方法。其实不这样做也行,写在viewDidload方法,应该就没什么问题。

extension UIScrollView {
    
    func addScrollToTopBtn() -> ScrollToTopButton {
        return ScrollToTopButton(frame: CGRect(x: (self.width - 40) / 2, y: self.height + 100, width: 40, height: 40), scrollView: self)
    }
}

调用该方法:如果该方法有返回值:_ = tableView.addScrollToTopBtn(),_是你定义的相关变量,这里我就省略不写了;如果没有返回值:tableView.addScrollToTopBtn()。是不是觉得很简单方便,控制器根本不需要关心ScrollToTopButton是如何实现以及如何添加到view上。
之前说到,用扩展的方法,是会有问题的。假如,整个app的列表有几十个,那你就需要在这几十个列表的控制器,一一paste这行代码tableView.addScrollToTopBtn(),程序猿都是“很懒的”,那有没有办法可以,每个列表的控制器都不用写这行代码,就可以将返回顶部的按钮,添加到所有列表中呢?请看第二种方法。

(2)利用Method Swizzling - 方法交换

如果有同学不懂Method Swizzling,推荐看下玉令天下的一篇博客,Objective-C Method Swizzling,写的很详细。当然也可以通过其他的文章进行学习了解。

这里我们利用runtime的方法交换,通过自己的方法替换系统方法,在自己的方法里面添加判断,从而将按钮添加到列表中。需求中,是当我们滑动列表时,将返回顶部的按钮显示,那第一个就是想到,能否替换scrollViewDidScroll的方法,经过一番尝试之后,很可惜并不行。runtime的方法交换,能适用于替换类本身的方法。scrollView的代理方法不行,第二个想到的就是替换contentOffset的set方法,但最后的方案是选择替换UIView的didMoveToSuperview方法,这个方法是当view的父级视图更改的时候会调用此方法,因此我们就替换这个系统方法。
先新建UIScrollView的扩展,UIScrollView+Runtime,导入#import <objc/runtime.h>,在load方法中(如果是Swift的话,则在initialize方法中),进行方法的替换。为什么要在load方法中,可以通过这篇文章进行了解iOS - + initialize 与 +load。可能有一些文章,会说在load方法中,写一个dispatch_once,让代码只执行一次。其实,这是没必要多加一个dispatch_once的,因为本身load方法只会进一次而已。所以加不在dispatch_once,其实没什么关系。load的具体实现如下:

+ (void)load {
    Method ori_Method = class_getInstanceMethod([UIScrollView class], @selector(didMoveToSuperview));
    
    Method ud_Mothod = class_getInstanceMethod([UIScrollView class], @selector(ud_didMoveToSuperview));
    
    method_exchangeImplementations(ori_Method, ud_Mothod);
}

- (void)ud_didMoveToSuperview {
    [self ud_didMoveToSuperview];
    
    if (self.superview && ([self isMemberOfClass:[UITableView class]])) {
        for (UIView *view in self.superview.subviews) {
            if ([view isKindOfClass:[ScrollToTopButton class]]) {
                return;
            }
        }
        [[ScrollToTopButton alloc] initWithFrame:CGRectMake(self.width, self.height, 48, 48) scrollView:(UIScrollView *)self];
    }
}

当代码写完之后,一个Command+R,结果crash了。

Paste_Image.png

在crash信息可以看出,是因为didMoveToSuperview方法出的问题。原来,UIScrollView没有实现didMoveToSuperview方法,而直接交换 IMP 是很危险的。因为如果这个类中没有实现这个方法,class_getInstanceMethod() 返回的是某个父类的 Method 对象,这样method_exchangeImplementations() 就把父类的原始实现(IMP)跟这个类的 Swizzle 实现交换了。这样其他父类及其其他子类的方法调用就会出问题,最严重的就是 Crash。
那怎么办?那就不能用UIScrollView的扩展了,但是我们可以改成UIView的扩展,效果也是一样的。
修改之后,再次运行。不会crash了,随便找了个列表滑动后,返回顶部的按钮也显示出来,那就说明,用runtime的方法已经可行。但是,因为是UIView的扩展,我们在自己的ud_didMoveToSuperview,需要对当前的self进行判断,[self isMemberOfClass:[UITableView class],是UITableView,我们才添加返回顶部的按钮。
这里需要特别特别注意的:在我们替换的方法中,一定要调用自身的方法,非系统的方法,不然会导致死循环的。[self ud_didMoveToSuperview];这行代码实际是调用系统的didMoveToSuperview方法。

那用Method Swizzling进行方法交换的方案有什么问题呢?

  • 用这个方案,是所有列表的都添加了,但如果我有些列表不要添加呢?是不是觉得有坑了?有一种方法是,在ud_didMoveToSuperview这个方法中,对需要添加的列表进行if判断,可是这样做又破坏了封装。
  • 第二个问题是ScrollToTopButton的frame不对的问题。因为我们是拿scrollView.superview来进行计算的,如果view的底部还有一个类似的tool view的呢?那ScrollToTopButton的frame就计算错误了。
  • 还有一个问题就是,会发现这个ScrollToTopButton,只有创建,没有remove。隐藏后,也只是hidden,并没有从当前的view中remove,这也需要解决的问题。
  • 对于上面问题,目前还真没有想出比较好的解决方案,如果有更好的解决方案的同学,欢迎可以通过留言指导。


    Paste_Image.png
Paste_Image.png

(3)除了上面两种方案,那有没有第三种方案?答案是肯定的。

我们也可以在每个需要添加的列表的控制器中,都写一模一样的代码,从创建按钮到添加。
但是这种写法,不但恶心了自己,更恶心了别人。这种重复的代码根本没有可维护而言,如果哪天产品改需求了,这个按钮需要换个位置,那你就崩溃了。少写一些重复的代码,多写一些已维护的,多用扩展,封装。。。

四、来个demo

ScrollToTopButtonDemo
如果有兴趣的同学,可以下载这个很简易的demo,写的有点烂,不过重点是上面所说的思路和实现方法。我写的垃圾代码,看看就好。

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

推荐阅读更多精彩内容

  • *面试心声:其实这些题本人都没怎么背,但是在上海 两周半 面了大约10家 收到差不多3个offer,总结起来就是把...
    Dove_iOS阅读 27,134评论 30 470
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,637评论 18 139
  • 新海诚2013年作品 故事:女教师与高中生之间陌生的微妙的感情,他们都是生活在正常人之外的人,高中生整天打工、制鞋...
    徐徐图之Q阅读 595评论 0 0
  • 闭上眼想想 多年以后 在远山同去回来的路上 你唱歌 树叶听着,我听着。 11.28
    花花cq阅读 323评论 0 2
  • 最近生活太累,工作太忙,于是在朋友圈发牢骚,我是个害怕麻烦的人,只写了“生无可恋”四个字,然后把手机丢在一...
    echo兔阅读 336评论 0 0