之前用到了一個(gè)比較方便的第三方HUD庫SwiftNotice,一行代碼即可實(shí)現(xiàn)一個(gè)簡潔方便的HUD,最近心血來潮突發(fā)奇想準(zhǔn)備研究研究這個(gè)庫的內(nèi)部實(shí)現(xiàn)原理。在看之前本以為挺簡單的,殊不知麻雀雖小,五臟俱全,涉及到的知識點(diǎn)非常多,看得我這個(gè)小白一頭包。不過經(jīng)過各種查閱資料后,基本原理還是搞清楚了,接下來我將逐步給大家分析下這個(gè)庫是如何一步步地實(shí)現(xiàn)下圖所示的各種功能的(GIF來自于作者的github):

從gif圖中我們可以看到作者一共為我們提供了六種HUD類型,還有一個(gè)一鍵清除所有Notice。調(diào)用非常方便,我以noticeOnlyText為例,代碼如下:
self.noticeOnlyText("Only Text")
根據(jù)作者的使用場景介紹:
Pretty easy to use:
In any subclass of UIView, UIScrollView, UIViewController, UITableViewController, UITableViewCell.
我們猜測作者應(yīng)該針對上述UI控件實(shí)現(xiàn)了一個(gè)擴(kuò)展,現(xiàn)在看代碼,證明了我們的猜測是正確的:
extension UIResponder {
/// wait with your own animated images
@discardableResult
func pleaseWaitWithImages(_ imageNames: Array<UIImage>, timeInterval: Int) -> UIWindow{
return SwiftNotice.wait(imageNames, timeInterval: timeInterval)
}
......
}
作者對UIResponder做了一個(gè)擴(kuò)展,UIResponder是上述控件的基類,具體細(xì)節(jié)就不展開了,有興趣的童鞋可以自行查閱資料了解。
作者在UIResponder的擴(kuò)展中聲明了展示所有種類Notice需要調(diào)用的方法,包括:
pleaseWaitWithImages
noticeTop
noticeSuccess
noticeError
noticeInfo
noticeOnlyText
clearAllNotice
等等等等,這些是作為接口暴露給用戶的,具體的實(shí)現(xiàn)由SwiftNotice這個(gè)類完成,SwiftNotice中對應(yīng)的方法才是真正展示HUD的核心。
因?yàn)榫唧w的實(shí)現(xiàn)都是大同小異的,所以我選了幾個(gè)有代表性的方法來具體分析,分別是:
noticeOnStatusBar:狀態(tài)欄通知;
wait:等待中HUD;
showNoticeWithText:帶文字描述的HUD,包括三種類別:成功、錯(cuò)誤、信息;
hideNotice:隱藏通知或HUD;
clear:清空所有的通知和HUD;
其它的都是類似的。
限于篇幅,我不會分析作者對iOS9之前的系統(tǒng)所做的處理,也不會對其它非核心知識點(diǎn)展開分析,例如:動畫效果、位置大小屬性等等。
在分析方法之前,我們首先看下SwiftNotice類中對應(yīng)的變量:
static var windows = Array<UIWindow!>()
static let rv = UIApplication.shared.keyWindow?.subviews.first as UIView!
static var timer: DispatchSource!
static var timerTimes = 0
windows是一個(gè)UIWindow數(shù)組,每一個(gè)的HUD都建立在一個(gè)UIWindow上。為什么要用UIWindow呢,按照我的理解,每一個(gè)HUD對象的出現(xiàn)都會打斷用戶當(dāng)前的操作,而UIWindow有三個(gè)不同級別的層級,分別是Normal,StatusBar,Alert,層級越高顯示越靠上,通常我們的程序的界面都是處于Normal這個(gè)級別上的,所以我們可以自己定義一個(gè)較高層級的UIWindow,來防止我們的HUD被其他UI控件遮擋;
rv是一個(gè)keyWindow的第一個(gè)子View,這里需要先解釋下keyWindow,在我們的程序中,可以有很多個(gè)window,但是同一時(shí)刻只能有一個(gè)keyWindow,舉個(gè)例子:一個(gè)程序啟動,此時(shí)只有一個(gè)默認(rèn)的window,級別為Normal,這個(gè)window同時(shí)也是keyWindow,然后我們自己新建一個(gè)window并將其顯示在屏幕上,級別設(shè)置成更高的StatusBar,注意,此時(shí)的keyWindow就變成了我們新建的window,默認(rèn)的window變?yōu)榉?code>keyWindow。換句話說,rv是獲取當(dāng)前級別最高的window的第一個(gè)子View;
timer是一個(gè)計(jì)時(shí)器,所有的HUD和通知的自動消失都是通過計(jì)時(shí)器完成的,作者并沒有使用NSTimer而是使用了DispatchSource,關(guān)于DispatchSource的使用和優(yōu)點(diǎn)可以參考這篇文章:打造一個(gè)優(yōu)雅的Timer;
timerTimes用來計(jì)數(shù),稍后在wait方法中具體分析;
我們首先分析第一個(gè)方法,noticeOnStatusBar,直接上代碼:
static func noticeOnStatusBar(_ text: String, autoClear: Bool, autoClearTime: Int) -> UIWindow{
let frame = UIApplication.shared.statusBarFrame
let window = UIWindow()
window.backgroundColor = UIColor.clear
let view = UIView()
view.backgroundColor = UIColor(red: 0x6a/0x100, green: 0xb4/0x100, blue: 0x9f/0x100, alpha: 1)
let label = UILabel(frame: frame)
label.textAlignment = NSTextAlignment.center
label.font = UIFont.systemFont(ofSize: 12)
label.textColor = UIColor.white
label.text = text
view.addSubview(label)
window.frame = frame
view.frame = frame
if let version = Double(UIDevice.current.systemVersion),
version < 9.0 {
// change center
var array = [UIScreen.main.bounds.width, UIScreen.main.bounds.height]
array = array.sorted(by: <)
let screenWidth = array[0]
let screenHeight = array[1]
let x = [0, screenWidth/2, screenWidth/2, 10, screenWidth-10][UIApplication.shared.statusBarOrientation.hashValue] as CGFloat
let y = [0, 10, screenHeight-10, screenHeight/2, screenHeight/2][UIApplication.shared.statusBarOrientation.hashValue] as CGFloat
window.center = CGPoint(x: x, y: y)
// change direction
window.transform = CGAffineTransform(rotationAngle: CGFloat(degree * Double.pi / 180))
}
window.windowLevel = UIWindowLevelStatusBar //UIWindow的層級
window.isHidden = false
window.addSubview(view)
windows.append(window)
var origPoint = view.frame.origin //確定初始位置
origPoint.y = -(view.frame.size.height)
let destPoint = view.frame.origin //確定最終位置
view.tag = sn_topBar
view.frame = CGRect(origin: origPoint, size: view.frame.size)
UIView.animate(withDuration: 0.3, animations: {
view.frame = CGRect(origin: destPoint, size: view.frame.size)
}, completion: { b in //閉包判斷是否需要自動消失
if autoClear {
let selector = #selector(SwiftNotice.hideNotice(_:))
self.perform(selector, with: window, afterDelay: TimeInterval(autoClearTime))
}
})
return window
}
上述代碼可以分為兩個(gè)部分:確定位置和大?。▽傩裕?、顯示和消失(動作)(iOS9以下處理的部分不考慮)。
window本身不用做展示,顯示層級依次為:window->view->label,需要確定他們的位置和大小以及一些其它的屬性。需要注意的是設(shè)置window的層級以及將window添加到windows數(shù)組中。因?yàn)樵撏ㄖ詣赢嫷姆绞匠霈F(xiàn),所以需要確定動畫初始位置和動畫結(jié)束位置,動畫閉包中判斷是否需要自動消失(根據(jù)傳的參數(shù)),如果需要的話調(diào)用hideNotice方法。這里我們調(diào)用了一個(gè)perform方法,注意注意,敲黑板劃重點(diǎn)了,這是iOS中延遲執(zhí)行的一個(gè)方法,引用這位博主所說:
當(dāng)調(diào)用 NSObject 的 performSelecter:afterDelay: 后,實(shí)際上其內(nèi)部會創(chuàng)建一個(gè) Timer 并添加到當(dāng)前線程的 RunLoop 中。所以如果當(dāng)前線程沒有 RunLoop,則這個(gè)方法會失效。
RunLoop我目前也不是特別了解,有興趣的讀者可以自行查閱資料或者查看上面我引用的那個(gè)博主的博客。
接下來我們分析hideNotice這個(gè)方法,代碼先上:
static func hideNotice(_ sender: AnyObject) {
if let window = sender as? UIWindow {
if let v = window.subviews.first { //獲取window的第一個(gè)子view并將它消失(動畫效果)
UIView.animate(withDuration: 0.2, animations: {
if v.tag == sn_topBar { //再次確定view的tag(確定是否是StatusNotice)
v.frame = CGRect(x: 0, y: -v.frame.height, width: v.frame.width, height: v.frame.height) //移出屏幕
}
v.alpha = 0 //變?yōu)橥该? }, completion: { b in
if let index = windows.index(where: { (item) -> Bool in //這里用到了Swift數(shù)組的index方法,參數(shù)是一個(gè)閉包,只有參數(shù)和window相同時(shí)返回真,找到window在windows中的下標(biāo)
return item == window
}) {
windows.remove(at: index) //從視圖數(shù)組中移除視圖
}
})
}
}
}
這個(gè)方法將StatusBar通知和HUD的隱藏寫在了一起,首先確定要移除的window,然后獲取到window的子View,在執(zhí)行消失動畫的時(shí)候判斷該View是不是StatusBar通知View,對應(yīng)不同的消失方法,在動畫方法的閉包中獲取到要移除的window在windows數(shù)組中的下標(biāo),然后將其從數(shù)組中移除。這里多說幾句,注意下面這個(gè)方法:
if let index = windows.index(where: { (item) -> Bool in //這里用到了Swift數(shù)組的index方法,參數(shù)是一個(gè)閉包,只有參數(shù)和window相同時(shí)返回真,找到window在windows中的下標(biāo)
return item == window
}) {
windows.remove(at: index) //從視圖數(shù)組中移除視圖
}
注意我寫的注釋:這里用到了Swift數(shù)組的index方法,參數(shù)是一個(gè)閉包,只有參數(shù)和window相同時(shí)返回真,找到window在windows中的下標(biāo)。
現(xiàn)在我們再來分析下wait方法,老套路,先看代碼:
static func wait(_ imageNames: Array<UIImage> = Array<UIImage>(), timeInterval: Int = 0) -> UIWindow { //參數(shù)為可選參數(shù),調(diào)用時(shí)沒有參數(shù)即使用默認(rèn)參數(shù)
let frame = CGRect(x: 0, y: 0, width: 78, height: 78)
let window = UIWindow()
window.backgroundColor = UIColor.clear
let mainView = UIView()
mainView.layer.cornerRadius = 12
mainView.backgroundColor = UIColor(red:0, green:0, blue:0, alpha: 0.8)
if imageNames.count > 0 {
if imageNames.count > timerTimes {
let iv = UIImageView(frame: frame)
iv.image = imageNames.first!
iv.contentMode = UIViewContentMode.scaleAspectFit
mainView.addSubview(iv)
timer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags(rawValue: UInt(0)), queue: DispatchQueue.main) as! DispatchSource
timer.scheduleRepeating(deadline: DispatchTime.now(), interval: DispatchTimeInterval.milliseconds(timeInterval))
timer.setEventHandler(handler: { () -> Void in
let name = imageNames[timerTimes % imageNames.count]
iv.image = name
timerTimes += 1
})
timer.resume()
}
} else {
let ai = UIActivityIndicatorView(activityIndicatorStyle: UIActivityIndicatorViewStyle.whiteLarge)
ai.frame = CGRect(x: 21, y: 21, width: 36, height: 36)
ai.startAnimating()
mainView.addSubview(ai)
}
window.frame = frame
mainView.frame = frame
window.center = rv!.center
if let version = Double(UIDevice.current.systemVersion),
version < 9.0 {
// change center
window.center = getRealCenter()
// change direction
window.transform = CGAffineTransform(rotationAngle: CGFloat(degree * Double.pi / 180))
}
window.windowLevel = UIWindowLevelAlert
window.isHidden = false
window.addSubview(mainView)
windows.append(window)
mainView.alpha = 0.0
UIView.animate(withDuration: 0.2, animations: {
mainView.alpha = 1
})
return window
}
從代碼中可以看出視圖層級是這樣的:
window->mainView->imageView,基本類似于之前的層級。
唯獨(dú)需要注意的地方是image部分,作者已經(jīng)為我們自定義wait時(shí)的動畫做好了準(zhǔn)備工作,我們只要將動畫分成幾個(gè)不同的image,放到imageNames這個(gè)數(shù)組中。那么具體是如何實(shí)現(xiàn)image依次出現(xiàn)并循環(huán)播放的呢?這里用到了之前定義的timer。下面是核心代碼:
timer = DispatchSource.makeTimerSource(flags: DispatchSource.TimerFlags(rawValue: UInt(0)), queue: DispatchQueue.main) as! DispatchSource
timer.scheduleRepeating(deadline: DispatchTime.now(), interval: DispatchTimeInterval.milliseconds(timeInterval))
timer.setEventHandler(handler: { () -> Void in
let name = imageNames[timerTimes % imageNames.count]
iv.image = name
timerTimes += 1
})
timer.resume()
在分析代碼之前,我首先引用上文中提到的《打造一個(gè)優(yōu)雅的Timer》中的一段話:
一般來說,DispatchSource的使用步驟就是:創(chuàng)建一個(gè)想要監(jiān)聽的事件類型對應(yīng)的dispatch source,然后給這個(gè)source指定一個(gè)閉包,指定一個(gè)Dispatch Queue。當(dāng)source監(jiān)聽到相應(yīng)的事件時(shí),就會將該閉包自動加到queue中執(zhí)行。
現(xiàn)在我們分析代碼:
第一行,作者創(chuàng)建了一個(gè)Dispatch Source,對應(yīng)Timer Dispatch Source類型,并指定了DispatchQueue為主隊(duì)列;
第二行,作者設(shè)置了Source的監(jiān)聽事件為重復(fù)執(zhí)行;
第三行,作者為該Source指定了一個(gè)閉包,為了實(shí)現(xiàn)循環(huán)播放圖片形成動畫,作者用計(jì)數(shù)變量timerTimes和image數(shù)組個(gè)數(shù)取余,得到的結(jié)果作為image數(shù)組的下標(biāo),非常巧妙;
最后一行,不要忘了啟動計(jì)時(shí)器。
如果沒有自定義的圖片作為等待時(shí)的展示,就直接使用默認(rèn)的UIActivityIndicatorView,這里不再細(xì)說,代碼寫得非常清楚。
下面我們要分析的是showNoticeWithText這個(gè)方法,和之前的wait方法不同的是,showNoticeWithText不需要用戶提供圖片,圖片由庫本身提供,具體如何提供則是由另一個(gè)類來完成,SwiftNoticeSDK。因此,SwiftNoticeSDK是我們著重分析的對象。
先看代碼:
class SwiftNoticeSDK {
struct Cache {
static var imageOfCheckmark: UIImage?
static var imageOfCross: UIImage?
static var imageOfInfo: UIImage?
}
class func draw(_ type: NoticeType) {
let checkmarkShapePath = UIBezierPath()
/*
畫圓參數(shù)解釋:
1.center: CGPoint 中心點(diǎn)坐標(biāo)
2.radius: CGFloat 半徑
3.startAngle: CGFloat 起始點(diǎn)所在弧度
4.endAngle: CGFloat 結(jié)束點(diǎn)所在弧度
5.clockwise: Bool 是否順時(shí)針繪制
7.畫圓時(shí),沒有坐標(biāo)這個(gè)概念,根據(jù)弧度來定位起始點(diǎn)和結(jié)束點(diǎn)位置。M_PI即是圓周率。畫半圓即(0,M_PI),代表0到180度。全圓則是(0,M_PI*2),代表0到360度
*/
// draw circle
checkmarkShapePath.move(to: CGPoint(x: 36, y: 18))
checkmarkShapePath.addArc(withCenter: CGPoint(x: 18, y: 18), radius: 17.5, startAngle: 0, endAngle: CGFloat(Double.pi*2), clockwise: true)
checkmarkShapePath.close()
switch type {
case .success: // draw checkmark
checkmarkShapePath.move(to: CGPoint(x: 10, y: 18))
checkmarkShapePath.addLine(to: CGPoint(x: 16, y: 24))
checkmarkShapePath.addLine(to: CGPoint(x: 27, y: 13))
checkmarkShapePath.move(to: CGPoint(x: 10, y: 18))
checkmarkShapePath.close()
case .error: // draw X
checkmarkShapePath.move(to: CGPoint(x: 10, y: 10))
checkmarkShapePath.addLine(to: CGPoint(x: 26, y: 26))
checkmarkShapePath.move(to: CGPoint(x: 10, y: 26))
checkmarkShapePath.addLine(to: CGPoint(x: 26, y: 10))
checkmarkShapePath.move(to: CGPoint(x: 10, y: 10))
checkmarkShapePath.close()
case .info:
checkmarkShapePath.move(to: CGPoint(x: 18, y: 6))
checkmarkShapePath.addLine(to: CGPoint(x: 18, y: 22))
checkmarkShapePath.move(to: CGPoint(x: 18, y: 6))
checkmarkShapePath.close()
UIColor.white.setStroke()
checkmarkShapePath.stroke()
let checkmarkShapePath = UIBezierPath()
checkmarkShapePath.move(to: CGPoint(x: 18, y: 27))
checkmarkShapePath.addArc(withCenter: CGPoint(x: 18, y: 27), radius: 1, startAngle: 0, endAngle: CGFloat(Double.pi*2), clockwise: true)
checkmarkShapePath.close()
UIColor.white.setFill()
checkmarkShapePath.fill()
}
UIColor.white.setStroke()
checkmarkShapePath.stroke()
}
class var imageOfCheckmark: UIImage {
if (Cache.imageOfCheckmark != nil) {
return Cache.imageOfCheckmark!
}
UIGraphicsBeginImageContextWithOptions(CGSize(width: 36, height: 36), false, 0)
SwiftNoticeSDK.draw(NoticeType.success)
Cache.imageOfCheckmark = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return Cache.imageOfCheckmark!
}
class var imageOfCross: UIImage {
if (Cache.imageOfCross != nil) {
return Cache.imageOfCross!
}
UIGraphicsBeginImageContextWithOptions(CGSize(width: 36, height: 36), false, 0)
SwiftNoticeSDK.draw(NoticeType.error)
Cache.imageOfCross = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return Cache.imageOfCross!
}
class var imageOfInfo: UIImage {
if (Cache.imageOfInfo != nil) {
return Cache.imageOfInfo!
}
UIGraphicsBeginImageContextWithOptions(CGSize(width: 36, height: 36), false, 0)
SwiftNoticeSDK.draw(NoticeType.info)
Cache.imageOfInfo = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return Cache.imageOfInfo!
}
}
SwiftNoticeSDK主要由三個(gè)部分組成,分別是結(jié)構(gòu)體Cache,繪制方法draw和三個(gè)通過懶加載實(shí)現(xiàn)的UIImage對象,其中核心是draw方法,至此我們知道,SwiftNoticeSDK中并不包含圖像,它直接繪制了三個(gè)圖像。那么它是如何繪制的呢?這里作者使用了一個(gè)狂拽酷炫吊炸天的類:UIBezierPath。使用UIBezierPath可以創(chuàng)建基于矢量的路徑。使用此類可以定義簡單的形狀,如橢圓、矩形或者有多個(gè)直線和曲線段組成的形狀等。它的部分屬性如下:
moveToPoint: //設(shè)置起始點(diǎn)
addLineToPoint: //從上一點(diǎn)連接一條線到本次指定的點(diǎn)
closePath() //閉合路徑,把起始點(diǎn)和終點(diǎn)連接起來
appendPath: //多條路徑合并
removeAllPoints() //刪除所有點(diǎn)和線
lineWidth //路徑寬度
lineCapStyle //端點(diǎn)樣式(枚舉)
lineJoinStyle //連接點(diǎn)樣式(枚舉)
//下面這幾個(gè)屬性要用在UIView中重寫drawRect:方法中使用才有效,否則不會出現(xiàn)效果
UIColor.redColor().setStroke() //設(shè)置路徑顏色(不常用)
stroke()渲染路徑
UIColor.redColor().setFill() //設(shè)置填充顏色(不常用)
fill()渲染填充部分
通過上述屬性我們可以很容易理解各種圖形的繪制過程。但是我們還需要將繪制好的內(nèi)容賦值給對應(yīng)的圖像,在draw方法調(diào)用之前調(diào)用UIGraphicsBeginImageContextWithOptions方法,按照蘋果官方文檔的解釋,UIGraphicsBeginImageContextWithOptions方法使用指定的選項(xiàng)創(chuàng)建一個(gè)基于位圖的圖形上下文。當(dāng)此函數(shù)創(chuàng)建的上下文是當(dāng)前上下文,就可以調(diào)用UIGraphicsGetImageFromCurrentImageContext函數(shù)根據(jù)上下文的當(dāng)前內(nèi)容檢索圖像對象,因此我們在draw方法之后調(diào)用UIGraphicsGetImageFromCurrentImageContext函數(shù)得到UIImage圖像并賦值給對應(yīng)的值。當(dāng)然了,最后需要從棧中刪除頂部當(dāng)前基于位圖的圖形上下文,使用UIGraphicsEndImageContext方法。
最后我們來分析清除所有屏幕上的HUD和通知的clear方法,代碼如下:
static func clear() {
self.cancelPreviousPerformRequests(withTarget: self)
if let _ = timer {
timer.cancel()
timer = nil
timerTimes = 0
}
windows.removeAll(keepingCapacity: false)
}
總體思路比較簡單,就是將windows數(shù)組中的window全部移除,同時(shí)完成一些收尾工作:如果perform方法還未執(zhí)行則取消執(zhí)行,計(jì)時(shí)器取消并置為nil。
至此,SwiftNotice的核心代碼,包括實(shí)現(xiàn)思路就都過了一遍。在查看并研究源碼的這兩天,我也查漏補(bǔ)缺,學(xué)習(xí)了不少新知識,但是限于我個(gè)人的知識面,可能一些分析并不是十分完整,也歡迎大家給我提出寶貴意見。在我看來,知其然,更要知其所以然,在我們使用第三方庫的時(shí)候,不光要會用,也應(yīng)該知道作者是如何實(shí)現(xiàn)它的,然后自己再實(shí)現(xiàn)一次,從易到難,對自己的編程能力一定會有很大的提升!