如你所知,下拉更新元件其實就是當資料正在載入而表格視圖的內容尚未更新時,出現在表格視圖上方的活動視圖指示器(通常還會伴隨著一些簡短的訊息)。事實上,下拉更新元件有點像是當使用者在等待擷取和顯示新內容時所看到的「請稍候……」訊息。在使用了此類元件的 App 當中,大家最熟知的莫過於「郵件」了,透過將電子郵件表格視圖往下拖曳,便可以重新整理郵件的內容。此元件首次登場是在 iOS 6 ,從那時候起這招便廣泛被運用在無數的 App 當中。
假使你曾經想要在自己的 App 當中使用下拉更新元件,並且曾經搜尋過如何導入此元件的作法,那麼你一定要閱讀 Simon 的文章,裡頭有所有你需要知道的一切。在本文中,我們想要探討的是下拉更新的其他面向:如何建立自訂的下拉更新元件。這個主題可以讓你在這個小歸小但是卻十分重要的細節上套用不同的樣式,為你的 App 加入全新的風貌。
那麼,在下面幾個小節中,你將可以看到新增任何自訂的內容與動畫的技巧,以便「取代」預設的下拉更新元件。請留意,稍候將只會示範步驟及邏輯;實際的自訂內容必須由你自己填入,說得更精確一點,必須由你自己想像。現在就讓我們開始吧,很快你就能夠建立自訂的下拉更新內容了!
範例 App 簡介
下列的動畫示範的是本文所要建立的自訂下拉更新元件:
如你所見,表格視圖包含了一些虛構資料,因為本文的目的並非從網頁伺服器取得真實的資料。最重要的是不要看見活動指示器視圖,而是在整個更新期間出現自訂動畫風格的「 APPCODA 」字樣。
假使你擔心虛構的更新作業何時才會結束,其實我使用了計時器( NSTimer
)物件,在 4 秒鐘過後便會停止更新,並且進而隱藏自訂的下拉更新元件。本範例的 4 秒鐘是隨便選擇的時間長度。用意無非只是為了在本文中展示自訂的下拉更新元件。在許多情況中,資料更新的時間都比 4 秒鐘還短(尤其如果網路連線速度很快的話),千萬不要只是為了想要顯示令使用者印象深刻的自訂下拉更新元件而在真實的 App 中使用計時器。使用者有很多機會可以看到那些自訂的下拉更新元件,只要你的 App 夠棒,他們就會經常使用。
我們即將開發的專案非常簡單。但是我並不打算從頭開始建造。一如往常,你可以從下載 Starter 專案開始。解壓縮之後你可以看見 Storyboard 介面檔案,以及另一個名為 RefreshContents.xib 的 Interface Builder 檔案。我在裡頭加入了用來取代原先下拉更新元件的自訂內容。事實上,構成「 APPCODA 」字樣總共需要使用 7 個標籤物件( UILabels
),以及一個用來容納這些標籤的自訂視圖( UIView
)容器
。所需的字型格式化作業已經完成,而且約束也妥善設定好了。稍候,只需要將這些元件載入到我們的視圖控制器中,再適當加以處理即可。
那麼,就讓我們從下載 Starter 專案開始。請打開 Xcode ,好好享受閱讀程式碼的樂趣吧!
預設的下拉更新元件
我們要在範例 App 中做的第一件事,就是在表格視圖中顯示虛構的資料。在剛才下載的 Starter 專案的 Storyboard 中,已經有一個名為 tblDcodeo
的 IBOutlet 屬性與表格視圖相連接了,所以我們只需要撰寫必要的表格視圖委派和資料來源函式即可。不過在這之前,我們必須先指定要在表格視圖中顯示的資料。所以開啟 ViewController.swift
檔案,來到類別的最開頭,只要加入下列這行就夠了:
var dataArray: Array = ["One", "Two", "Three", "Four", "Five"]
現在,稍微修改類別的表頭列,加入 UITableViewDelegate
和 UITableViewDataSource
協定,示範如下:
class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource
接著,我們必須將 ViewController
類別實體設定成委派,並且設定表格視圖的資料來源。我們將在 viewDidLoad
中完成這些設定:
override func viewDidLoad() {
...
tblDemo.delegate = self
tblDemo.dataSource = self
}
現在,讓我們來加入用來顯示虛構資料的表格視圖函式:
func numberOfSectionsInTableView(tableView: UITableView) -> Int {
return 1
}
func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return dataArray.count
}
func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCellWithIdentifier("idCell", forIndexPath: indexPath) as! UITableViewCell
cell.textLabel!.text = dataArray[indexPath.row]
return cell
}
func tableView(tableView: UITableView, heightForRowAtIndexPath indexPath: NSIndexPath) -> CGFloat {
return 60.0
}
這樣就完成了。上述的實作並沒有特別困難之處,如果你執行此 App 的話,將會看到「 One 、 Two ……」被顯示在表格視圖中:
現在,讓我們將焦點放在如何顯示和使用預設的下拉更新元件。在這類繼承自 ViewController
類別或是任何其他不是 UITableViewController
類別的 App 中,下拉更新元件必須新增為表格視圖的子視圖。(關於如何在 UITableViewController
中使用下拉更新元件,請參閱 Simon 的文章。)一開始,我們必須在類別開頭宣告更新元件:
var refreshControl: UIRefreshControl!
別忘了,即便更新元件是由多個特殊元件所組成,仍舊必須像任何其他屬性或物件那樣宣告與處理,因此上述的宣告是不可或缺的。
再次來到 viewDidLoad
函式,首先我們將會初始化更新元件,然後將其加入到表格視圖中:
override func viewDidLoad() {
...
refreshControl = UIRefreshControl()
tblDemo.addSubview(refreshControl)
}
再次執行此 App ,當你往下拖曳表格視圖(即所謂的下拉動作)時,將會看見預設的旋轉圖示。千萬別預期此更新元件會自己隱藏起來;隱藏的動作並不會自動發生。我們必須明確地結束更新作業的進度,不過我們稍後才會處理這個部分。好消息是現在更新指示的顯示看起來沒有什麼問題:
在此提供一個小技巧,你可以同時改變更新元件的背景和前景( Tint )顏色。舉例而言,下列這 2 行程式碼可以讓下拉更新元件擁有紅色背景與黃色的旋轉圖示:
override func viewDidLoad() {
...
refreshControl = UIRefreshControl()
refreshControl.backgroundColor = UIColor.redColor()
refreshControl.tintColor = UIColor.yellowColor()
tblDemo.addSubview(refreshControl)
}
自訂更新的內容
自訂下拉更新元件背後的主要概念是可以讓我們新增任何想要的額外內容,來作為此元件本身的子視圖。在我們的範例 App 中,額外的內容就放在 RefreshContents.xib
檔案之中。這份 Interface Builder 檔案的內容如下所示:
如你所見,這是一個依序包含了 7 個標籤的視圖物件。每個標籤剛好對應到「 APPCODA 」字樣的一個字母。
在本節中,我們要做的事情非常簡單:我們將透過程式碼來存取此 .xib 檔案,並將其內容依序指派為屬性。說得更清楚一些,該視圖物件將被指派為 UIView
屬性,而所有的標籤則會被加入到陣列之中。透過這樣,我們將能夠在這些視圖上套用任何我們想要的自訂動畫(我們稍後也將看到一些效果)。
現在讓我們來一一檢視所有的實作細節。首先,在類別的開頭加入下列的宣告:
var customView: UIView!
var labelsArray: Array = []
有了上述這 2 個新的屬性之後,我們就可以開始建立要用來載入 .xib 檔案所有內容的新自訂函式了:
func loadCustomRefreshContents() {
let refreshContents = NSBundle.mainBundle().loadNibNamed("RefreshContents", owner: self, options: nil)
}
我們還要繼續在上述的自訂函式中撰寫一些程式碼。下一步是將已透過上述那行程式碼載入的視圖物件指派給 customView
屬性。請留意,從外部 .xib 檔案擷取子視圖的作法跟上述程式碼也很類似,你將取得一個包含了所有東西的陣列
。在我們的範例 App 中,此陣列只會包含自訂的視圖物件,而標籤只是它的子視圖, .xib 中沒有任何其他獨立的視圖。此外也請留意,在下列的程式碼中,我們將會設定自訂視圖的 frame
(外框),讓自訂視圖跟預設更新元件的 bounds
(邊界)相同:
func loadCustomRefreshContents() {
...
customView = refreshContents[0] as! UIView
customView.frame = refreshControl.bounds
}
上述最後一行連結了先前所設定的約束,將使得自訂視圖能夠在開始展開更新進度時,自動根據表格視圖被往下拉的長度來變更其尺寸。
現在,讓我們將所有的標籤載入到 labelsArray
陣列中。或許你已經注意到了, RefreshContents.xib
檔案中的每個標籤都被指派了一個標記
( Tag )數字。從左邊開始,標記數字從 1 到 7 。我們將透過這些標記數字來分別存取每個標籤:
func loadCustomRefreshContents() {
...
for var i=0; i
最後,讓我們將自訂視圖新增為更新元件的子視圖:
func loadCustomRefreshContents() {
...
refreshControl.addSubview(customView)
}
這樣就完成了!現在我們需要做的,就只有呼叫上述的函式。喔,當然了,會是在 viewDidLoad
函式中進行呼叫:
override func viewDidLoad() {
...
loadCustomRefreshContents()
}
還有一件很重要的事必須做。就是要在 viewDidLoad
函式中,將更新元件的背景和前景顏色都清除掉(變成透明)。 viewDidLoad
函式最終應該如下所示:
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
tblDemo.delegate = self
tblDemo.dataSource = self
refreshControl = UIRefreshControl()
refreshControl.backgroundColor = UIColor.clearColor()
refreshControl.tintColor = UIColor.clearColor()
tblDemo.addSubview(refreshControl)
loadCustomRefreshContents()
}
現在來執行並測試此 App 。你將會在下拉更新時看到擁有那些標籤的自訂視圖被顯示出來,而不是預設的旋轉圖示。當然了,目前還沒有動畫,所以緊接著讓我們來完成動畫的部分吧。
啟動自訂的動畫
首先讓我們再看一次要在範例 App 中實現的動畫是長什麼樣子:
如果你仔細觀察的話,將會發現整個動畫程序包含了 2 個部分:
- 在第 1 部分中,每個標籤會稍微旋轉一下( 45 度角),同時文字的顏色也會變化一下。在第 2 部分的動畫開始之前,一切都會恢復原狀。
- 在所有標籤都旋轉過並且恢復原狀之後,會再全部一起放大然後縮小為原始尺寸。
我們希望盡可能讓事情越簡單越好,所以我們將個別的動畫部分實作在獨立的自訂函式內。在這之前,先提供你一些稍後會使用到的新屬性。分別是:
var isAnimating = false
var currentColorIndex = 0
var currentLabelIndex = 0
它們的作用分述如下:
-
isAnimating
旗標(顯然)是用來指示自訂動畫是否正在進行中。透過此屬性,我們可以知道是否要開始新的動畫(畢竟我們並不想看到 2 個重複的動畫)。
-
currentColorIndex
是我們即將實作的另一個自訂函式才會使用到的屬性。在該函式中,我們會有一個顏色陣列(事實上是文字顏色),而此屬性則是指示應該被套用到下一個標籤的合適顏色。
-
currentLabelIndex
屬性指示的是正在進行第 1 部分動畫的標籤索引。有了這個屬性,我們不僅可以知道下一個應該要旋轉及變色的標籤,也可以判斷第 2 部分的縮放動畫何時應該啟動。
至此,我們已經準備好可以檢視第 1 部分的動畫了。接下來我將提供 animateRefreshStep1()
這個新函式的完整實作:
func animateRefreshStep1() {
isAnimating = true
UIView.animateWithDuration(0.1, delay: 0.0, options: UIViewAnimationOptions.CurveLinear, animations: { () -> Void in
self.labelsArray[self.currentLabelIndex].transform = CGAffineTransformMakeRotation(CGFloat(M_PI_4))
self.labelsArray[self.currentLabelIndex].textColor = self.getNextColor()
}, completion: { (finished) -> Void in
UIView.animateWithDuration(0.05, delay: 0.0, options: UIViewAnimationOptions.CurveLinear, animations: { () -> Void in
self.labelsArray[self.currentLabelIndex].transform = CGAffineTransformIdentity
self.labelsArray[self.currentLabelIndex].textColor = UIColor.blackColor()
}, completion: { (finished) -> Void in
++self.currentLabelIndex
if self.currentLabelIndex < self.labelsArray.count {
self.animateRefreshStep1()
}
else {
self.animateRefreshStep2()
}
})
})
}
現在讓我們來討論一下此函式中最重要的幾個部分。首先, isAnimating
旗標變為 true ,所以我們可以確定不會再啟動新的動畫。稍後我們會看到要在哪裡以及如何檢查此旗標。接著,或許你已經注意到了,有 2 個動畫區塊,其中第 2 個區塊是在第 1 個區塊的完成處理常式( Completion Handler )中被啟動的。原因在於:
- 在第 1 個動畫區塊中,我們將目前的標籤(參考
currentLabelIndex
屬性)旋轉並將其文字變色。
- 在子動畫結束時,我們希望將標籤恢復成初始狀態,而且必須看起來很優雅,動作不能夠發生得太突兀。顯然這就是我們之所以需要第 2 個動畫區塊的原因了。
在內部動畫區塊的完成處理常式中,我們會檢查 currentLabelIndex
屬性的數值。假使其數值仍然有效,我們便形成遞迴
( Recursion ),再度呼叫相同的函式,好讓下一個標籤能夠產生動畫。相反地,如果所有標籤都已經執行過動畫了,那麼便呼叫整個動畫第 2 部分的自訂函式( animateRefreshStep2()
)。
你肯定有注意到我們呼叫了 getNextColor()
函式(在第 1 個動畫區塊內)。我們先前曾經提到此函式;它會給我們正在執行動畫的標籤的文字顏色。我們很快就會看到此函式的實作。
現在,讓我們來到動畫的第 2 部分,並實作 animateRefreshStep2()
函式:
func animateRefreshStep2() {
UIView.animateWithDuration(0.35, delay: 0.0, options: UIViewAnimationOptions.CurveLinear, animations: { () -> Void in
self.labelsArray[0].transform = CGAffineTransformMakeScale(1.5, 1.5)
self.labelsArray[1].transform = CGAffineTransformMakeScale(1.5, 1.5)
self.labelsArray[2].transform = CGAffineTransformMakeScale(1.5, 1.5)
self.labelsArray[3].transform = CGAffineTransformMakeScale(1.5, 1.5)
self.labelsArray[4].transform = CGAffineTransformMakeScale(1.5, 1.5)
self.labelsArray[5].transform = CGAffineTransformMakeScale(1.5, 1.5)
self.labelsArray[6].transform = CGAffineTransformMakeScale(1.5, 1.5)
}, completion: { (finished) -> Void in
UIView.animateWithDuration(0.25, delay: 0.0, options: UIViewAnimationOptions.CurveLinear, animations: { () -> Void in
self.labelsArray[0].transform = CGAffineTransformIdentity
self.labelsArray[1].transform = CGAffineTransformIdentity
self.labelsArray[2].transform = CGAffineTransformIdentity
self.labelsArray[3].transform = CGAffineTransformIdentity
self.labelsArray[4].transform = CGAffineTransformIdentity
self.labelsArray[5].transform = CGAffineTransformIdentity
self.labelsArray[6].transform = CGAffineTransformIdentity
}, completion: { (finished) -> Void in
if self.refreshControl.refreshing {
self.currentLabelIndex = 0
self.animateRefreshStep1()
}
else {
self.isAnimating = false
self.currentLabelIndex = 0
for var i=0; i
再說明一次,我們總共使用了 2 個動畫區塊。在第 1 個區塊中,我們放大了所有的標籤。請留意,我們沒有辦法透過迴圈(例如使用 for
陳述式)來實現此目的。迴圈在執行時並不會理會動畫的時間長度,而且會在所有的標籤都放大之前提早很久就結束。
在完成時,我們會設定所有標籤的初始轉變,讓它們再度恢復成初始狀態。在內部動畫區塊的完成處理常式中,有一道 if
陳述式。在此判斷式裡面,假使更新作業仍在進行中,那麼便準備要重新執行整個動畫。只要將 currentLabelIndex
屬性設定為初始值( 0 )並且呼叫執行動畫的第 1 個自訂函式,即可達成此目的。我們將在下一個小節中處理結束更新作業的事宜。但是如果更新作業已經結束,透過變更 isAnimating
旗標的數值,我們可以指示動畫已經結束,另外我們當然還需要將動畫程序中的所有屬性(和視圖)都重新設定為初始值。這是必要的,這樣動畫在下次表格視圖被下拉時才能夠正確地啟動。
現在遇到一個問題是,自訂函式應該從哪裡開始。假使你仔細檢視本小節一開始的動畫圖片,將會發現動畫是在表格視圖拖曳時就開始。以程式設計來講,這裡的表格視圖是一種捲動視圖
( Scroll View )的子類別,因此我們感興趣的委派函式就是 scrollViewDidEndDecelerating(_:)
。此函式會在每次表格視圖的捲動結束時被呼叫到。在此函式中,首先我們會檢查更新作業是否正在進行中。如果是的話,我們便會檢查 isAnimating
旗標的數值,假使都沒有動畫正在進行中,我們便會透過呼叫先前實作的第 1 個函式來執行動畫。示範如下:
func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
if refreshControl.refreshing {
if !isAnimating {
animateRefreshStep1()
}
}
}
請留意,並不強制一定要使用上述的捲動視圖委派。一切端視你的自訂下拉更新元件要如何實作,或許你會想要使用其他的委派函式,例如 scrollViewDidScroll(_:)
。
這裡還有一件事情沒有提到,就是關於 getNextColor()
函式的實作。其程式碼如下:
func getNextColor() -> UIColor {
var colorsArray: Array = [UIColor.magentaColor(), UIColor.brownColor(), UIColor.yellowColor(), UIColor.redColor(), UIColor.greenColor(), UIColor.blueColor(), UIColor.orangeColor()]
if currentColorIndex == colorsArray.count {
currentColorIndex = 0
}
let returnColor = colorsArray[currentColorIndex]
++currentColorIndex
return returnColor
}
內容十分簡單吧。首先,我們準備一個陣列,裡頭放置了一些預先定義好的顏色(其順序是隨機的)。接著,我們確認 currentColorIndex
屬性的數值是否有效,如果不是的話,便將其值設定為初始值( 0 )。我們透過此屬性來得知所選的顏色,並且遞增其數值,這樣下次當此函式再被呼叫到時就會是不同的顏色了。最後返回所選的顏色。
現在你可以再次嘗試執行此 App 。下拉以便更新,然後觀察一下動畫。當然了,更新元件並不會消失,因為這個部分依然尚未實作。請盡情地測試下拉以便更新的動畫,並且依照你自已的需求來進行修改。
除了自訂動畫之外
為下拉更新元件建立自訂的動畫既新奇又有趣,但是我們應該要知道,使用者不會只是為了要觀看更新元件有多漂亮。他們之所以更新,是因為他們需要取得新的內容,當你在製作自訂的下拉更新機制時,這應該永遠都是你的首要任務。所以在講解完前面幾個小節的內容之後,下一步應該輪到實作實際的資料擷取程序了。
在本文中,我們顯然並不會擷取任何的資料,也不會更新表格視圖的內容。但是這項事實並無礙於套用我們先前所講述的下拉更新邏輯。所以緊接著我們要介紹的是如何建立名為 doSomething()
的新自訂函式(我所挑選的名稱真是諷刺意味十足,因為在本文中它實際上有做等於沒做)。在此函式中,我們將會實體化並觸發一個計時器( NSTimer
)物件,它在 4 秒鐘的週期之後將會發出訊號,指出更新作業已完成。在真實的 App 當中,你可千萬別這麼做喔。當擷取的資料都收到時,即意味著更新作業的結束。
讓我們先做最重要的事,請回到類別的開頭,加入下列這個(同時也是最後一個)宣告:
var timer: NSTimer!
現在,讓我們來「做點事情」( Do Something )吧:
func doSomething() {
timer = NSTimer.scheduledTimerWithTimeInterval(4.0, target: self, selector: "endOfWork", userInfo: nil, repeats: true)
}
4 秒鐘差不多就足夠讓我們把整個動畫看過一遍了。如同我們在上述函式的唯一那行程式碼中所看到的,在預先定義好的時間週期到期時將會呼叫 endOfWork()
函式。在此函式中,我們將會停止更新作業,並且停用計時器。
func endOfWork() {
refreshControl.endRefreshing()
timer.invalidate()
timer = nil
}
至此,我們幾乎做完所有的事情了。現在我們需要做的,就只是呼叫 doSomething()
函式而已。此動作應該要在動畫開始之前就進行,所以讓我們來修改一下捲動視圖的委派函式。如下所示:
func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
if refreshControl.refreshing {
if !isAnimating {
doSomething()
animateRefreshStep1()
}
}
}
我們的範例 App 終於大功告成了!最後再執行並測試一次看看吧!
結語
看吧,建立自訂的下拉更新元件一點也不難。你只需要有好的點子,然後轉換成圖形,這樣就足夠了。如同我在前面小節所講的,你的首要任務永遠都是擷取真實的資料,而非炫耀自訂的視覺特效。除此之外,請小心謹慎,同時也請不要為了顯示久一點而企圖在更新完資料之後又延遲隱藏更新元件。這麼做只會讓使用者產生不好的體驗,對你一點好處也沒有。毫無疑問地,假使你的 App 對於使用者而言非常好用的話,他們會有許多機會可以欣賞你的自訂作品,所以沒有道理強迫他們一定要盯著看。關於本文的範例 App ,其更新的內容非常單純,但是已經足夠讓我清楚展示我想要表達的重點。你一定可以理解,這是程式設計方面的考量,這麼做可以接受最大幅度的自訂與改良,而且最終的結果無疑也會因為你的 App 所要呈現的效果而不太一樣。自訂的下拉更新元件永遠都應該專為每個 App 而設計。最後,我誠摯地希望本文對你有所幫助。請盡情享受自訂下拉更新元件的樂趣吧!
供你參考,請從這裡下載本文的 Xcode 專案。
譯者簡介:陳佳新 – 奇步應用共同創辦人,開發自有 App 和網站之外,也承包各式案件。譯有多本電腦書籍,包括 O'Reilly 出版的 iOS 、 Android 、 Agile 和 Google Cloud 等主題,也在報紙上寫過小說。現與妻兒居住在故鄉彰化。歡迎造訪 https://chibuapp.com ,來信請寄到 [email protected] 。
原文:Building a Custom Pull To Refresh Control for Your iOS Apps