轉(zhuǎn)自我自己的 blog:Sources 開發(fā)日記二 (搜索頁面)
Code Reader 改名為 Sources
1.0 也已經(jīng)上架,App Store: http://itunes.apple.com/app/id1125732186。
同時 Sources 也在 Github 上開源了,地址是:https://github.com/vulgur/Sources。
這一篇寫搜索 Repo 功能的實現(xiàn)。
輸入 Repo name 關鍵字,返回搜索結(jié)果。說起來簡單,細節(jié)其實還不少。
架構(gòu) & 第三方庫
這個 App 我第一次嘗試使用 MVVM 架構(gòu)來實現(xiàn)。框架我選擇的是 Bond ,之所以沒選 ReactiveCocoa,原因很簡單:怎么學也學不會……RAC4 的源代碼我讀了三四遍,Demo 我也看了幾個,但是無奈資質(zhì)愚笨,敗在了如何使用 Action 和 UI binding 上面(RAC4 不像 RAC2,缺少了 UI binding 的 extension,需要自己實現(xiàn),不過他們正在考慮把 Rex 加入到 RAC 中)。選擇 Bond 的原因是看了這個教程 Bond Tutorial: Bindings in Swift,發(fā)現(xiàn)這個 framework 和 RAC 比較相似,但是使用起來更簡單明了 ,作者也說了這是一個輕量級的 binding 框架(媽蛋,寫這篇 blog 的時候發(fā)現(xiàn) Bond 作者推薦 ReactiveKit,說是更快更高更強……過兩天再折騰)
以下是項目中目前為止用到的第三方庫,因為項目是用 Swift 寫的,所以選用的庫也都是 Swift 寫的:
- Alamofire
- AlamofireObjectMapper
- ObjectMapper
- Kingfisher
- Bond
- EZLoadingActivity
Model
先說 Model。搜索結(jié)果是 Repo 的列表,而 Repo 又包含著 Owner,所以先寫這兩個 model。這兩個類的屬性就是參照 Github API 返回的 JSON 字段來設計。這里選用 ObjectMapper 作為 JSON 和 Model 對象的轉(zhuǎn)換器。
import ObjectMapper
class Owner: Mappable {
var name: String?
var ownerId: String?
var avatarURLString: String?
required init?(_ map: Map) {
}
init() {
}
// Mappable
func mapping(map: Map) {
name <- map["login"]
ownerId <- map["id"]
avatarURLString <- map["avatar_url"]
}
}
import ObjectMapper
class Repo: Mappable {
var repoId: String?
var name: String?
var fullName: String?
var owner: Owner?
var description: String?
var size: Int?
var starsCount: Int?
var watchersCount: Int?
var language: String?
var forksCount: Int?
var createdDate: String?
var pushedDate: String?
required init?(_ map: Map) {
}
// Mappable
func mapping(map: Map) {
repoId <- map["id"]
name <- map["name"]
fullName <- map["full_name"]
owner <- map["owner"]
description <- map["description"]
size <- map["size"]
starsCount <- map["stargazers_count"]
language <- map["language"]
forksCount <- map["forks"]
createdDate <- map["created_at"]
pushedDate <- map["pushed_at"]
}
}
這里注意一下,Repo 的 JSON 里面沒有 watchers,盡管搜索 Repo 的 API 的返回結(jié)果里面有個「watchers_count」的字段,但是這并不是 Github 中的 watchers,這只是一個過時的字段,取代它的新字段就是「stargazers_count」,也就是 stars,這兩個字段的值是一樣的。至于真正的 watchers,其實是在另一個 API 中,這個以后再說。
View Model
接下來是 ViewModel。SearchRepoViewModel
中的負責綁定的屬性是搜索時的各種參數(shù)以及搜索結(jié)果列表,另外還負責「搜索」和「加載更多」這兩個動作。網(wǎng)絡庫用的是著名的 Alamofire。
這里只貼加載下一頁的代碼片段,方法中的參數(shù)一個是加載完成后的處理(主要是刷新 tableView),一個是出錯時的處理(顯示 alert),這兩個 closure 都在 view controller 中傳入。
func loadMore(completion completion: ()->(), errorHandler: ((String) -> ())? = nil) {
currentPage += 1
let urlParams = [
"q": keyword,
"sort" : sortType.rawValue,
"page" : "\(currentPage)"
]
// Fetch Request
Alamofire.request(.GET, "https://api.github.com/search/repositories", parameters: urlParams)
.responseJSON { (response) in
switch response.result {
case .Success:
if let statusCode = response.response?.statusCode{
switch statusCode{
case 200..<299:
if let items = response.result.value!["items"], results = Mapper<Repo>().mapArray(items) {
self.repos.appendContentsOf(results)
completion()
}
default:
self.currentPage -= 1
if let message = response.result.value!["message"], errorHandler = errorHandler {
errorHandler(message as! String)
}
}
}
case .Failure(let error):
self.currentPage -= 1
if let errorHandler = errorHandler {
errorHandler(error.localizedDescription)
}
}
}
}
View
最后是 View。這部分的代碼最多,既包括 views 也包括 view controllers。搜索這部分有三個文件:
- SearchViewController.swift
- SearchRepoCell.swift
- SearchRepoCell.xib
搜索
關于 Cell 的兩個文件就不詳述了,沒什么特別的。重點說說 SearchViewController。
首先是輸入關鍵字進行搜索。UI 中用的是 UISearchBar
,而不是自定義的 UITextField
,所以在輸入完點擊鍵盤上的「搜索」按鈕的動作需要實現(xiàn) UISearchBarDelegate
的 searchBarSearchButtonClicked
這個方法:
extension SearchViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(searchBar: UISearchBar) {
print("Search for: ", searchBar.text!)
searchBar.endEditing(true)
viewModel.keyword = searchBar.text!
EZLoadingActivity.show("Searching...", disableUI: true)
viewModel.searchRepos(completion: {
self.tableView.reloadDataWithAutoSizingCells()
EZLoadingActivity.hide()
}, errorHandler: self.errorHandler)
}
}
點擊「搜索」后,首先是隱藏鍵盤,然后將搜索框中的文本賦給 view model,利用 EZLoadingActivity 顯示提示框,同時阻止其他 UI 操作,最后執(zhí)行 view model 中的搜索。這里傳給搜索方法的兩個 closure,第一個就是成功搜索后刷新 table view 并隱藏提示框,第二個是搜索出錯后的錯誤處理。
reloadDataWithAutoSizingCells
是我給 UITableView
增加的自定義的方法,為的是解決 table view 的一個 UI bug:就是 table view 第一次加載 Autolayout 的動態(tài)高度的 cell,會出現(xiàn)高度不正確的 bug。這個方法的實現(xiàn)是:
extension UITableView {
func reloadDataWithAutoSizingCells() {
self.reloadData()
self.setNeedsDisplay()
self.layoutIfNeeded()
self.reloadData()
}
}
處理錯誤的 errorHandler
執(zhí)行的代碼就是隱藏提示框并彈出一個 alert,alert 的內(nèi)容就是 API 中返回的錯誤信息。
errorHandler = { [unowned self] msg in
EZLoadingActivity.hide()
self.isLoading = false
let alertController = UIAlertController(title: "", message: msg, preferredStyle: .Alert)
let action = UIAlertAction(title: "Okay", style: .Default, handler: nil)
alertController.addAction(action)
self.presentViewController(alertController, animated: true, completion: nil)
}
條件搜索
Github 的搜索可以按照「best match」,「stars」,「forks」和「updated」這四個條件排序搜索,這個 App 我默認都是降序。
在搜索框下面有一個 UISegmentedControl
,四個 segment 就對應著四個搜索條件。代碼中先給這個 segmented control 添加一個 target,關聯(lián)的 action 如下:
func searchSortChanged(sender: UISegmentedControl) {
switch sender.selectedSegmentIndex {
case 0:
viewModel.sortType = .Best
case 1:
viewModel.sortType = .Stars
case 2:
viewModel.sortType = .Forks
case 3:
viewModel.sortType = .Updated
default:
viewModel.sortType = .Best
}
EZLoadingActivity.show("Loading", disableUI: true)
viewModel.searchRepos(completion: {
self.tableView.reloadDataWithAutoSizingCells()
let topIndexPath = NSIndexPath(forRow: 0, inSection: 0)
self.tableView .scrollToRowAtIndexPath(topIndexPath, atScrollPosition: UITableViewScrollPosition.Top, animated: true)
EZLoadingActivity.hide()
}, errorHandler: self.errorHandler)
}
加載更多
這個功能可以說是數(shù)據(jù)列表中的必備功能之一,也有多種實現(xiàn)方式。一開始我是通過 UITableViewDelegate
中的 tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath)
這個方法來實現(xiàn)的,即在即將顯示某個 cell (一般是 data source 中的倒數(shù)第X個)的時候加載下一頁的數(shù)據(jù)。這是 SO 上面很多答案都推薦的方式,不過我發(fā)現(xiàn)這個方式有個問題,就是向下滑動過那個觸發(fā)點 cell 后再向上滑的話,那么會再次執(zhí)行一次加載動作(后來發(fā)現(xiàn)其實加一個是否在加載中的判斷就可以了)。
最后我采用的方式是實現(xiàn) UIScrollViewDelegate
中的 scrollViewDidEndDecelerating(scrollView: UIScrollView)
。通過判斷 scroll view 是否快滾到底來加載下一頁數(shù)據(jù),代碼如下:
extension SearchViewController: UIScrollViewDelegate {
func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
let offset = scrollView.contentOffset.y - (scrollView.contentSize.height - scrollView.frame.size.height)
if (offset >= 0 && offset < 10 && isLoading == false) {
isLoading = true
viewModel.loadMore(completion:{
self.tableView.reloadDataWithAutoSizingCells()
self.isLoading = false
}, errorHandler: self.errorHandler)
}
}
}