Sources 開發(fā)日記二 (搜索頁面)

轉(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é)其實還不少。

repo_search.gif

架構(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) UISearchBarDelegatesearchBarSearchButtonClicked 這個方法:

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)
        }
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,406評論 6 538
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,034評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,413評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,449評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,165評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,559評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,606評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,781評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,327評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,084評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,278評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,849評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,495評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,927評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,172評論 1 291
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,010評論 3 396
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,241評論 2 375

推薦閱讀更多精彩內(nèi)容