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,写的有点烂,不过重点是上面所说的思路和实现方法。我写的垃圾代码,看看就好。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容

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