美文网首页@IT·互联网iOS DeveloperSwift&Objective-C
Sources 开发日记二 (搜索页面)

Sources 开发日记二 (搜索页面)

作者: vulgur | 来源:发表于2016-07-03 14:49 被阅读143次

    转自我自己的 blog:Sources 开发日记二 (搜索页面)

    Code Reader 改名为 Sources

    1.0 也已经上架,App Store: http://itunes.apple.com/app/id1125732186

    同时 Sources 也在 Github 上开源了,地址是:https://github.com/vulgur/Sources

    这一篇写搜索 Repo 功能的实现。
    输入 Repo name 关键字,返回搜索结果。说起来简单,细节其实还不少。

    repo_search.gif

    架构 & 第三方库

    这个 App 我第一次尝试使用 MVVM 架构来实现。框架我选择的是 Bond ,之所以没选 ReactiveCocoa,原因很简单:怎么学也学不会……RAC4 的源代码我读了三四遍,Demo 我也看了几个,但是无奈资质愚笨,败在了如何使用 Action 和 UI binding 上面(RAC4 不像 RAC2,缺少了 UI binding 的 extension,需要自己实现,不过他们正在考虑把 Rex 加入到 RAC 中)。选择 Bond 的原因是看了这个教程 Bond Tutorial: Bindings in Swift,发现这个 framework 和 RAC 比较相似,但是使用起来更简单明了 ,作者也说了这是一个轻量级的 binding 框架(妈蛋,写这篇 blog 的时候发现 Bond 作者推荐 ReactiveKit,说是更快更高更强……过两天再折腾)

    以下是项目中目前为止用到的第三方库,因为项目是用 Swift 写的,所以选用的库也都是 Swift 写的:

    • Alamofire
    • AlamofireObjectMapper
    • ObjectMapper
    • Kingfisher
    • Bond
    • EZLoadingActivity

    Model

    先说 Model。搜索结果是 Repo 的列表,而 Repo 又包含着 Owner,所以先写这两个 model。这两个类的属性就是参照 Github API 返回的 JSON 字段来设计。这里选用 ObjectMapper 作为 JSON 和 Model 对象的转换器。

    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 的返回结果里面有个「watchers_count」的字段,但是这并不是 Github 中的 watchers,这只是一个过时的字段,取代它的新字段就是「stargazers_count」,也就是 stars,这两个字段的值是一样的。至于真正的 watchers,其实是在另一个 API 中,这个以后再说。

    View Model

    接下来是 ViewModel。SearchRepoViewModel 中的负责绑定的属性是搜索时的各种参数以及搜索结果列表,另外还负责「搜索」和「加载更多」这两个动作。网络库用的是著名的 Alamofire。
    这里只贴加载下一页的代码片段,方法中的参数一个是加载完成后的处理(主要是刷新 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,所以在输入完点击键盘上的「搜索」按钮的动作需要实现 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 操作,最后执行 view model 中的搜索。这里传给搜索方法的两个 closure,第一个就是成功搜索后刷新 table view 并隐藏提示框,第二个是搜索出错后的错误处理。

    reloadDataWithAutoSizingCells 是我给 UITableView 增加的自定义的方法,为的是解决 table view 的一个 UI bug:就是 table view 第一次加载 Autolayout 的动态高度的 cell,会出现高度不正确的 bug。这个方法的实现是:

    extension UITableView {
        func reloadDataWithAutoSizingCells() {
            self.reloadData()
            self.setNeedsDisplay()
            self.layoutIfNeeded()
            self.reloadData()
        }
    }
    

    处理错误的 errorHandler 执行的代码就是隐藏提示框并弹出一个 alert,alert 的内容就是 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,关联的 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)
        }
    
    

    加载更多

    这个功能可以说是数据列表中的必备功能之一,也有多种实现方式。一开始我是通过 UITableViewDelegate 中的 tableView(tableView: UITableView, willDisplayCell cell: UITableViewCell, forRowAtIndexPath indexPath: NSIndexPath) 这个方法来实现的,即在即将显示某个 cell (一般是 data source 中的倒数第X个)的时候加载下一页的数据。这是 SO 上面很多答案都推荐的方式,不过我发现这个方式有个问题,就是向下滑动过那个触发点 cell 后再向上滑的话,那么会再次执行一次加载动作(后来发现其实加一个是否在加载中的判断就可以了)。
    最后我采用的方式是实现 UIScrollViewDelegate 中的 scrollViewDidEndDecelerating(scrollView: UIScrollView)。通过判断 scroll view 是否快滚到底来加载下一页数据,代码如下:

    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)
            }
        }
    }
    

    相关文章

      网友评论

        本文标题:Sources 开发日记二 (搜索页面)

        本文链接:https://www.haomeiwen.com/subject/vrqcjttx.html