一、ScrollToTopButton的由來

前幾天,接到一個(gè)需求:列表從下往上滑動超過一屏,出現(xiàn)返回頂部的按鈕,懸停3秒后消失。分析一波,列表其實(shí)就是UITableView,大家都知道當(dāng)UIScrollView的scrollsToTop屬性賦值為true時(shí),點(diǎn)擊狀態(tài)欄,是會返回到頂部的,那為啥還需要這個(gè)需求呢?這個(gè)問題就問的好了,下圖一為產(chǎn)品經(jīng)理的一波解釋,而圖二是同事們的討論。我覺得,加不加倒也沒什么所謂,不過當(dāng)效果出來后,用著確實(shí)還挺不錯的。
關(guān)于ScrollToTopButton,其實(shí)它是一個(gè)view,然后addSubview一個(gè)UIButton,為啥這么寫呢?唔...方便適配?姑且就這么覺得吧。那為什么叫ScrollToTopButton?顧名思義,滾到頂部的按鈕嘛,沒辦法,實(shí)在想不到好點(diǎn)的命名。


二、來一波運(yùn)行效果
啊哈哈哈,挺不錯的吧!接下來,看下具體代碼和實(shí)現(xiàn)吧...

三、分析一波
關(guān)于如何將ScrollToTopButton添加到view上,有兩種方案:一是寫一個(gè)UIScrollView擴(kuò)展,在擴(kuò)展中,寫一個(gè)相關(guān)方法,然后在對應(yīng)界面的controller中,調(diào)用擴(kuò)展的方法即可;二則是用runtime,在load函數(shù)中,替換系統(tǒng)UIView的didMoveToSuperview方法,在替換的方法中,添加ScrollToTopButton。但都存在一些問題。
1、關(guān)于ScrollToTopButton的實(shí)現(xiàn)
什么,你要看我寫的垃圾??代碼?不好吧?在這里,我就不把我的垃圾代碼貼出來了。在文章最后會貼出GitHub的地址,有興趣的同學(xué)可以去查看,有什么建議,歡迎大神留言指導(dǎo)。
代碼的相關(guān)說明
- 關(guān)于KVO的observe方法,為啥會沒有相關(guān)監(jiān)聽方法?為啥沒有removeObserver方法,這不會crash嗎?這篇文章會給你一點(diǎn)解答。分享一個(gè)關(guān)于KVO的擴(kuò)展,如果不想導(dǎo)入FBKVOController,以及該KVO擴(kuò)展的話,只需要將observe的寫法改成系統(tǒng)的就可以了,別忘了要在適當(dāng)?shù)臅r(shí)候removeObserver,不然會crash的。
- 關(guān)于需求中的,當(dāng)停止?jié)L動后,懸停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?)方法來進(jìn)行取消延遲的執(zhí)行。
為什么不用GCD的dispatch_after進(jìn)行延遲呢?根據(jù)我的了解,dispatch_after一旦延遲后,好像沒有相關(guān)的方法取消延遲。也就是說,當(dāng)我們停止?jié)L動后,會調(diào)用dispatch_after,但當(dāng)我們再次滾動時(shí),dispatch_after延遲是不會被取消的,延遲設(shè)置的時(shí)間到后,還是會被延遲的代碼。
performSelector方式進(jìn)行的延遲,可以調(diào)用cancelPreviousPerformRequests方法來進(jìn)行取消。需要注意的是,在調(diào)用該方法時(shí),需要傳入之前被延遲的方法。參考文章:取消延遲執(zhí)行函數(shù) cancelPreviousPerformRequestsWithTarget
2、如何將ScrollToTopButton添加到view上?
(1)寫一個(gè)UIScrollView擴(kuò)展
寫擴(kuò)展的好處就是,只要是UIScrollView或者繼承自UIScrollView,就能調(diào)用擴(kuò)展的方法。為什么要返回ScrollToTopButton,不返回可以嗎?返回ScrollToTopButton,你可以將其存起來,然后進(jìn)行判斷,如果為nil時(shí),才調(diào)用addScrollToTopBtn方法。其實(shí)不這樣做也行,寫在viewDidload方法,應(yīng)該就沒什么問題。
extension UIScrollView {
func addScrollToTopBtn() -> ScrollToTopButton {
return ScrollToTopButton(frame: CGRect(x: (self.width - 40) / 2, y: self.height + 100, width: 40, height: 40), scrollView: self)
}
}
調(diào)用該方法:如果該方法有返回值:_ = tableView.addScrollToTopBtn(),_是你定義的相關(guān)變量,這里我就省略不寫了;如果沒有返回值:tableView.addScrollToTopBtn()。是不是覺得很簡單方便,控制器根本不需要關(guān)心ScrollToTopButton是如何實(shí)現(xiàn)以及如何添加到view上。
之前說到,用擴(kuò)展的方法,是會有問題的。假如,整個(gè)app的列表有幾十個(gè),那你就需要在這幾十個(gè)列表的控制器,一一paste這行代碼tableView.addScrollToTopBtn(),程序猿都是“很懶的”,那有沒有辦法可以,每個(gè)列表的控制器都不用寫這行代碼,就可以將返回頂部的按鈕,添加到所有列表中呢?請看第二種方法。
(2)利用Method Swizzling - 方法交換
如果有同學(xué)不懂Method Swizzling,推薦看下玉令天下的一篇博客,Objective-C Method Swizzling,寫的很詳細(xì)。當(dāng)然也可以通過其他的文章進(jìn)行學(xué)習(xí)了解。
這里我們利用runtime的方法交換,通過自己的方法替換系統(tǒng)方法,在自己的方法里面添加判斷,從而將按鈕添加到列表中。需求中,是當(dāng)我們滑動列表時(shí),將返回頂部的按鈕顯示,那第一個(gè)就是想到,能否替換scrollViewDidScroll的方法,經(jīng)過一番嘗試之后,很可惜并不行。runtime的方法交換,能適用于替換類本身的方法。scrollView的代理方法不行,第二個(gè)想到的就是替換contentOffset的set方法,但最后的方案是選擇替換UIView的didMoveToSuperview方法,這個(gè)方法是當(dāng)view的父級視圖更改的時(shí)候會調(diào)用此方法,因此我們就替換這個(gè)系統(tǒng)方法。
先新建UIScrollView的擴(kuò)展,UIScrollView+Runtime,導(dǎo)入#import <objc/runtime.h>,在load方法中(如果是Swift的話,則在initialize方法中),進(jìn)行方法的替換。為什么要在load方法中,可以通過這篇文章進(jìn)行了解iOS - + initialize 與 +load。可能有一些文章,會說在load方法中,寫一個(gè)dispatch_once,讓代碼只執(zhí)行一次。其實(shí),這是沒必要多加一個(gè)dispatch_once的,因?yàn)楸旧韑oad方法只會進(jìn)一次而已。所以加不在dispatch_once,其實(shí)沒什么關(guān)系。load的具體實(shí)現(xiàn)如下:
+ (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];
}
}
當(dāng)代碼寫完之后,一個(gè)Command+R,結(jié)果crash了。

在crash信息可以看出,是因?yàn)?code>didMoveToSuperview方法出的問題。原來,UIScrollView沒有實(shí)現(xiàn)
didMoveToSuperview方法,而直接交換 IMP 是很危險(xiǎn)的。因?yàn)槿绻@個(gè)類中沒有實(shí)現(xiàn)這個(gè)方法,class_getInstanceMethod() 返回的是某個(gè)父類的 Method 對象,這樣method_exchangeImplementations() 就把父類的原始實(shí)現(xiàn)(IMP)跟這個(gè)類的 Swizzle 實(shí)現(xiàn)交換了。這樣其他父類及其其他子類的方法調(diào)用就會出問題,最嚴(yán)重的就是 Crash。那怎么辦?那就不能用UIScrollView的擴(kuò)展了,但是我們可以改成UIView的擴(kuò)展,效果也是一樣的。
修改之后,再次運(yùn)行。不會crash了,隨便找了個(gè)列表滑動后,返回頂部的按鈕也顯示出來,那就說明,用runtime的方法已經(jīng)可行。但是,因?yàn)槭荱IView的擴(kuò)展,我們在自己的
ud_didMoveToSuperview,需要對當(dāng)前的self進(jìn)行判斷,[self isMemberOfClass:[UITableView class],是UITableView,我們才添加返回頂部的按鈕。這里需要特別特別注意的:在我們替換的方法中,一定要調(diào)用自身的方法,非系統(tǒng)的方法,不然會導(dǎo)致死循環(huán)的。
[self ud_didMoveToSuperview];這行代碼實(shí)際是調(diào)用系統(tǒng)的didMoveToSuperview方法。
那用Method Swizzling進(jìn)行方法交換的方案有什么問題呢?
- 用這個(gè)方案,是所有列表的都添加了,但如果我有些列表不要添加呢?是不是覺得有坑了?有一種方法是,在
ud_didMoveToSuperview這個(gè)方法中,對需要添加的列表進(jìn)行if判斷,可是這樣做又破壞了封裝。 - 第二個(gè)問題是ScrollToTopButton的frame不對的問題。因?yàn)槲覀兪悄?code>scrollView.superview來進(jìn)行計(jì)算的,如果view的底部還有一個(gè)類似的tool view的呢?那ScrollToTopButton的frame就計(jì)算錯誤了。
- 還有一個(gè)問題就是,會發(fā)現(xiàn)這個(gè)ScrollToTopButton,只有創(chuàng)建,沒有remove。隱藏后,也只是hidden,并沒有從當(dāng)前的view中remove,這也需要解決的問題。
-
對于上面問題,目前還真沒有想出比較好的解決方案,如果有更好的解決方案的同學(xué),歡迎可以通過留言指導(dǎo)。
Paste_Image.png

(3)除了上面兩種方案,那有沒有第三種方案?答案是肯定的。
我們也可以在每個(gè)需要添加的列表的控制器中,都寫一模一樣的代碼,從創(chuàng)建按鈕到添加。
但是這種寫法,不但惡心了自己,更惡心了別人。這種重復(fù)的代碼根本沒有可維護(hù)而言,如果哪天產(chǎn)品改需求了,這個(gè)按鈕需要換個(gè)位置,那你就崩潰了。少寫一些重復(fù)的代碼,多寫一些已維護(hù)的,多用擴(kuò)展,封裝。。。
四、來個(gè)demo
ScrollToTopButtonDemo
如果有興趣的同學(xué),可以下載這個(gè)很簡易的demo,寫的有點(diǎn)爛,不過重點(diǎn)是上面所說的思路和實(shí)現(xiàn)方法。我寫的垃圾代碼,看看就好。
