本文譯自 Enum-Driven TableView Development,原做者是 Keegan Rushios
材料下載git
UITableView 是 iOS 開發裏最基本的東西,一個簡單而又整潔的控件。但 UITableView 的背後還隱藏了不少複雜性:在正確的時間顯示等待小菊花、處理 error、等待服務回調並在獲得結果的時候顯示結果。github
在這篇教程裏,你會學習如何用枚舉來驅動 TableView 的開發以便應對上述問題。swift
爲了更好地學習這門技術,你須要重構一個現有的 app,這個 app 叫作 Chirper。在這個過程當中,你會學習以下內容:api
ViewController
的狀態。本教程須要你對
UITableView
和 Swift 枚舉(enum)有所瞭解。不然能夠先參考 iOS 和 Swift tutorials。數組
須要咱們重構的 Chirper app 顯示了一列鳥類叫聲,這個列表支持搜索,其數據來自 xeno-canto public API。網絡
若是在 app 內搜索某種鳥類,它會爲你顯示一列匹配搜索關鍵詞的錄音。點擊每行的按鈕就能夠播放對應的錄音。app
使用教程頂端或底端的材料下載 按鈕來下載初始項目。下載好以後,在 Xcode 裏打開這個初始項目。ide
一個設計良好的 table view 有四種不一樣狀態:單元測試
填充是最多見的狀態,但其餘狀態也同樣關鍵。應該始終讓用戶瞭解 app 的狀態,也就是說在加載狀態的時候顯示等待小菊花、在沒有數據的時候提醒用戶該如何操做以及在出錯的時候顯示友好的錯誤消息。
先打開 MainViewController.swift 看一下代碼。這個 view controller 基於屬性狀態作了一些很重要的事:
isLoading
設置爲 true
時顯示 loading indicator。error
不爲 nil
時告訴用戶出錯了。recordings
數組爲 nil
或空數組,view 會顯示消息提示用戶搜索其餘關鍵詞。tableView.tableFooterView
設置爲正確的 view。在修改代碼時不只要將上面這些東西牢記於心,將來給 app 增長功能時這個模式還會變得更加複雜。
若是在 MainViewController.swift 裏搜索,你會發如今這個文件中並無出現 state。
state 就在那裏,但定義不明確。欠缺定義的狀態會讓人費解,這段代碼是在作什麼?屬性變化以後應該如何迴應?
若是 isLoading
是 true
,app 就應該顯示加載狀態。若是 error
不是 nil,app 就應該顯示錯誤狀態。但若是這兩種狀況同時存在呢?app 此時就會處於無效狀態。 MainViewController
的狀態定義不清晰,會致使其在無效或不肯定狀態時出現 bug。
MainViewController
須要更好地管理狀態,管理狀態的方式應該是:
下面咱們會重構 MainViewController
,用 enum
來管理狀態。
在 MainViewController.swift 裏,把下面這段代碼添加到類聲明的上方:
enum State {
case loading
case populated([Recording])
case empty
case error(Error)
}
複製代碼
這個枚舉能夠清晰地定義 view controller 的狀態。下面給 MainViewController
添加一個屬性來設置狀態:
var state = State.loading
複製代碼
構建並運行 app,看看是否還能正常運行。因爲咱們尚未對行爲進行修改,因此應該還和原來一摸同樣。
咱們要作的第一個改動就是移除 isLoading
屬性,變爲使用狀態枚舉。在 loadRecordings()
裏面,isLoading
屬性被設置爲 true
,tableView.tableFooterView
被設置爲 loading view。在 loadRecordings
的開頭移除這兩行:
isLoading = true
tableView.tableFooterView = loadingView
複製代碼
將其替換爲:
state = .loading
複製代碼
而後,移除 fetchRecordings
回調 block 中的 self.isLoading = false
。loadRecordings()
應該以下所示:
@objc func loadRecordings() {
state = .loading
recordings = []
tableView.reloadData()
let query = searchController.searchBar.text
networkingService.fetchRecordings(matching: query, page: 1) { [weak self] response in
guard let self = self else {
return
}
self.searchController.searchBar.endEditing(true)
self.update(response: response)
}
}
複製代碼
如今能夠移除 MainViewController 的 isLoading
屬性了,後面不會再用到。
構建並運行 app,應該以下所示:
雖然咱們設置了 state
屬性,但尚未用到它。tableView.tableFooterView
須要反映當前狀態。在 MainViewController
裏建立一個叫作setFooterView()
的新方法。
func setFooterView() {
switch state {
case .loading:
tableView.tableFooterView = loadingView
default:
break
}
}
複製代碼
如今回到 loadRecordings()
,在設置 state 爲 .loading
的後面添加以下代碼:
setFooterView()
複製代碼
構建並運行 app。
如今若是將 state 更改成 loading,serFooterView()
就會被調用,等待小菊花也會顯示出來。幹得不錯!
loadRecordings()
從 NetworkingService
抓取了 recordings。它會得到 networkingService.fetchRecordings()
的 response 而且調用 update(response:)
來更新 app 狀態。
在 update(response:)
裏面,若是 response 有 error,就把 errorLabel
設置爲 error 的 description。tableFooterView
也會被設置爲 errorView
,其中包含了 errorLabel
。在 update(response:)
裏面找到下面兩行:
errorLabel.text = error.localizedDescription
tableView.tableFooterView = errorView
複製代碼
替換爲:
state = .error(error)
setFooterView()
複製代碼
在 setFooterView()
裏,爲 error
狀態添加一個新的 case:
case .error(let error):
errorLabel.text = error.localizedDescription
tableView.tableFooterView = errorView
複製代碼
view controller 再也不須要 error: Error?
屬性了,如今能夠移除掉它。還須要在 update(response:)
裏面移除對 error
屬性的引用:
error = response.error
複製代碼
移除掉上面那行以後,構建並運行 app。
能夠看到,加載狀態仍然在正常顯示。但怎麼測試錯誤狀態呢?最簡單的方式是斷開設備與網絡的鏈接;若是你是在 Mac 上運行模擬器,就斷開 Mac 與網絡的鏈接。再次嘗試加載數據,就會看到以下界面:
在 update(response:)
的開頭有一長串 if-else
。咱們須要把它整理一下,將 update(response:)
替換爲以下內容:
func update(response: RecordingsResult) {
if let error = response.error {
state = .error(error)
setFooterView()
tableView.reloadData()
return
}
recordings = response.recordings
tableView.reloadData()
}
複製代碼
雖然這樣破壞了填充和空白狀態,但不用擔憂,咱們立刻就會修復它們!
在 if let error = response.error
block 的下方添加以下代碼:
guard let newRecordings = response.recordings,
!newRecordings.isEmpty else {
state = .empty
setFooterView()
tableView.reloadData()
return
}
複製代碼
在更新狀態時不要忘記調用 setFooterView()
和 tableView.reloadData()
,不然就看不到變更了。
下面找到 update(response:)
裏這行代碼:
recordings = response.recordings
複製代碼
將其替換爲:
state = .populated(newRecordings)
setFooterView()
複製代碼
這樣就重構了 update(response:)
——根據 view controller 的 state 屬性進行操做。
下面咱們須要根據當前狀態設置正確的 table footer view。給 setFooterView()
中的 switch 添加以下兩個 case:
case .empty:
tableView.tableFooterView = emptyView
case .populated:
tableView.tableFooterView = nil
複製代碼
如今再也不須要 default
case 了,移除掉它。
構建並運行 app,看看有什麼變化:
app 如今不顯示數據了。view controller 的 recordings
屬性用於填充 table view,但這個屬性並無被設置。如今 table view 須要從 state
屬性了獲取數據。在 State
enum 的聲明中添加下面這個計算屬性:
var currentRecordings: [Recording] {
switch self {
case .populated(let recordings):
return recordings
default:
return []
}
}
複製代碼
可使用這個屬性來填充 table view。若是 state 是 populated
,就使用 populated 的 recordings,不然就返回空數組。
在 tableView(_:numberOfRowsInSection:)
,移除下面這行:
return recordings?.count ?? 0
複製代碼
將其替換爲:
return state.currentRecordings.count
複製代碼
接下來,在 tableView(_:cellForRowAt:)
中移除這一塊代碼:
if let recordings = recordings {
cell.load(recording: recordings[indexPath.row])
}
複製代碼
將其替換爲:
cell.load(recording: state.currentRecordings[indexPath.row])
複製代碼
不須要再用可選型了!
MainViewController
的 recordings
屬性也用不到了,刪掉它以及 loadRecordings()
中對它的引用。
構建並運行 app。
全部狀態如今都應該能夠正常運行了。咱們移除了 isLoading
、error
、以及 recordings
屬性,替換爲惟一的定義清晰的 state
屬性。幹得漂亮!
如今咱們已經從 view controller 中移除了定義不明確的屬性,能夠根據 state 屬性來輕易辨別視圖的行爲。同時,也不會出現既是加載狀態又是錯誤狀態的狀況了——也就意味着不會再出現無效狀態。
可是,仍然有一個問題存在。在更新 state 屬性的時候,必須要記得調用 setFooterView()
和 tableView.reloadData()
。不然 view 沒法更新並反映當前狀態。若是在 state 被改變時能夠自動刷新多好?
這種狀況很是適合使用屬性觀察者 didSet
。屬性觀察者用於響應屬性值的變動。若是每次 state
屬性被設置後都但願 reload table view 並設置 footer view,就須要添加一個 didSet
屬性觀察者。
把 var state = State.loading
替換爲以下代碼:
var state = State.loading {
didSet {
setFooterView()
tableView.reloadData()
}
}
複製代碼
state
的值被改變後,didSet
屬性觀察期就會啓動。它會調用 setFooterView()
和 tableView.reloadData()
來更新 view。
移除 setFooterView()
和 tableView.reloadData()
的其餘全部調用(各四個)。能夠在 loadRecordings()
和 update(response:)
中找到它們。它們不會再被用到了。
構建並運行 app,查看是否正常:
使用 app 進行搜索時,雖然 API 會返回不少結果,但一次並不會返回所有結果。 例如,使用 Chirper 搜索某種常見的鳥(好比 parrot),通常會返回不少結果:
可是不對啊,只有 50 條鸚鵡的記錄?
xeno-canto API 的限制是每次 500 條。這個項目在 NetworkingService.swift
中將其限制爲 50 條,以便示範。
即使接收了前 500 條數據,後面的結果怎麼獲取呢?這個 API 經過分頁(pagination) 來實現這一點。
當咱們在 NetworkingService
中調用 xeno-canto API 時,URL 以下所示:
http://www.xeno-canto.org/api/2/recordings?query=parrot
複製代碼
如上調用返回的結果被限制爲前 500 條數據,也被稱爲第一頁,包含了 1-500 條數據。接下來的 500 條結果被稱做第二頁。利用 query 參數來指定想要的頁碼:
http://www.xeno-canto.org/api/2/recordings?query=parrot&page=2
複製代碼
注意最後的 &page=2
;這一小段代碼會告訴 API 咱們想要第二頁(包含 501-1000 條數據)。
看一下 MainViewController.loadRecordings()
,在它調用 networkingService.fetchRecordings()
時 page
參數被寫死爲 1
。咱們須要以下操做:
paging
的新狀態。networkingService.fetchRecordings
的 response 表示還有更多頁結果,就把 state 設置爲 .paging
。.paging
就加載下一頁結果。用戶滑動到底部時,app 會抓取更多結果。這樣就給人一種無限滾動列表的感受——就像社交軟件那樣。酷吧?
先給 state enum 添加一個新 case:
case paging([Recording], next: Int)
複製代碼
它須要追蹤用於顯示的 recordings 數組,和 .populated
狀態同樣。還須要追蹤 API 須要抓取的下一頁頁碼。
嘗試構建並運行項目,你會發現編譯不經過。setFooterView
裏面的 switch 語句須要是詳盡的,也就是包含每一種 case,而且不包含 default
case。這樣的好處是確保添加新狀態時能即時更新 switch 語句。將以下代碼添加到 switch 語句:
case .paging:
tableView.tableFooterView = loadingView
複製代碼
若是 app 處於 paging 狀態,就會在 table view 的末尾顯示 loading indicator。
然而 state 的計算屬性 currentRecordings
還不夠詳盡,給其 currentRecordings 裏面的 switch 語句添加一個新 case:
case .paging(let recordings, _):
return recordings
複製代碼
在 update(response:)
裏面,將 state = .populated(newRecordings
替換爲以下代碼:
if response.hasMorePages {
state = .paging(newRecordings, next: response.nextPage)
} else {
state = .populated(newRecordings)
}
複製代碼
response.hasMorePages
會判斷 API 擁有的總頁數是否小於當前頁碼。若是還有頁面須要抓取,就將 state 設置爲 .paging
。若是當前頁就是最後一頁或惟一一頁,就將 state 設置爲 .populated
。
構建並運行 app:
若是搜索結果有多頁,app 就會在底部顯示 loading indicator。但若是搜索結果只有一頁,就會和以前同樣獲得 .populated
狀態,沒有 loading indicator。
能夠看到有多個待加載頁面時,app 並不會去加載它們。如今咱們要修復這個問題。
咱們但願在用戶快要滑到列表底部時,app 可以開始加載下一頁。首先,建立一個空方法,叫作 loadPage
:
func loadPage(_ page: Int) {
}
複製代碼
後面若是但願從 NetworkingService
加載某特定頁的結果,就須要調用這個方法。
還記得 loadRecordings()
是如何默認加載第一頁的嗎?把 loadRecordings
裏面全部代碼移動到 loadPage(_:)
中,除了第一行(把 state 設置爲 .loading
)。
下面更新 fetchRecordings(matching: query, page: 1)
以便使用 page 參數,以下所示:
networkingService.fetchRecordings(matching: query, page: page)
複製代碼
loadRecordings()
如今看起來少了點什麼,改一下讓它調用 loadPage(_:)
,將 page 指定爲 1:
@objc func loadRecordings() {
state = .loading
loadPage(1)
}
複製代碼
構建並運行 app:
若是什麼都沒有發生,那就對咯!
把下面代碼添加到 tableView(_: cellForRowAt:)
裏 return
語句的前面。
if case .paging(_, let nextPage) = state,
indexPath.row == state.currentRecordings.count - 1 {
loadPage(nextPage)
}
複製代碼
若是當前 state 是 .paging
,而且當前要顯示的行數和 currentRecordings
數據的最後一個結果索引相同,就加載下一頁。
構建並運行 app:
Exciting!loading indicator 進入 view 的時候,app 就會抓取下一頁數據。但目前並非把數據附加上去——而是把當前記錄替換爲新加載的記錄。
在 update(response:)
裏,newRecordings
數組目前被用於 view 的新狀態。在 if response.hasMorePages
語句前面,添加以下代碼:
var allRecordings = state.currentRecordings
allRecordings.append(contentsOf: newRecordings)
複製代碼
獲取當前記錄數組,而後把新紀錄附加到該數組上。如今更新一下 if response.hasMorePages
語句,用 allRecordings
替代 newRecordings
:
if response.hasMorePages {
state = .paging(allRecordings, next: response.nextPage)
} else {
state = .populated(allRecordings)
}
複製代碼
可見有了 state 枚舉的幫助,修改變得輕鬆愜意。構建並運行 app 能夠看到區別:
若是但願下載最終完成的項目,使用教程頂部或底部的材料下載按鈕。
在這篇教程裏你重構了 app,用更加清晰的方式來處理複雜度。用簡單、清晰的 Swift 枚舉替換了一大堆容易出錯、定義不明確的狀態。咱們甚至添加了一個新功能來測試咱們用枚舉驅動的 table view:分頁。
在重構代碼時記得要進行測試,以便確保沒有對某些功能形成破壞,最好是單元測試。若是想進一步學習,能夠看看這篇教程 iOS Unit Testing and UI Testing。
如今你已經學習瞭如何在 app 中使用分頁 API,下面你還能夠學習如何構建一個實際的 API。Server Side Swift with Vapor 視頻課程是一個不錯的起點。
喜歡這篇教程嗎?但願對你之後 app 的狀態管理有所幫助!若是有任何問題或想法,歡迎在下方留言。