一、ScrollToTopButton的由来
前几天,接到一个需求:列表从下往上滑动超过一屏,出现返回顶部的按钮,悬停3秒后消失。分析一波,列表其实就是UITableView,大家都知道当UIScrollView的scrollsToTop属性赋值为true时,点击状态栏,是会返回到顶部的,那为啥还需要这个需求呢?这个问题就问的好了,下图一为产品经理的一波解释,而图二是同事们的讨论。我觉得,加不加倒也没什么所谓,不过当效果出来后,用着确实还挺不错的。
关于ScrollToTopButton,其实它是一个view,然后addSubview一个UIButton,为啥这么写呢?唔...方便适配?姑且就这么觉得吧。那为什么叫ScrollToTopButton?顾名思义,滚到顶部的按钮嘛,没办法,实在想不到好点的命名。
二、来一波运行效果
啊哈哈哈,挺不错的吧!接下来,看下具体代码和实现吧...
三、分析一波
关于如何将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了。
在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,这也需要解决的问题。
-
对于上面问题,目前还真没有想出比较好的解决方案,如果有更好的解决方案的同学,欢迎可以通过留言指导。
(3)除了上面两种方案,那有没有第三种方案?答案是肯定的。
我们也可以在每个需要添加的列表的控制器中,都写一模一样的代码,从创建按钮到添加。
但是这种写法,不但恶心了自己,更恶心了别人。这种重复的代码根本没有可维护而言,如果哪天产品改需求了,这个按钮需要换个位置,那你就崩溃了。少写一些重复的代码,多写一些已维护的,多用扩展,封装。。。
四、来个demo
ScrollToTopButtonDemo
如果有兴趣的同学,可以下载这个很简易的demo,写的有点烂,不过重点是上面所说的思路和实现方法。我写的垃圾代码,看看就好。