肇鑫的技术博客

业精于勤,荒于嬉

iOS下拉搜索的几种实现方式(二)

上文iOS下拉搜索的几种实现方式(一)提供了普通视图控制器下拉搜索的几种方法。但如果我们是在导航控制器中使用搜索,就变得简单了。因为我们可以直接使用系统提供的方案。

import UIKit

class TableViewController: UITableViewController {
    var array = (0...99).map { String($0) }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        customizeNavigationController()
    }
    
    private func customizeNavigationController() {
        self.navigationItem.leftBarButtonItem = editButtonItem
        self.navigationItem.title = "Pull to Search"
        self.navigationController?.navigationBar.prefersLargeTitles = true
        
        self.navigationItem.searchController = {
            let sc = UISearchController(searchResultsController: nil)
            sc.searchResultsUpdater = self
            sc.hidesNavigationBarDuringPresentation = true
            sc.obscuresBackgroundDuringPresentation = false
            
            sc.searchBar.delegate = self
            sc.searchBar.searchBarStyle = .minimal
            
            return sc
        }()
    }
    
    // MARK: - Table view data source
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return array.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = array[indexPath.row]
        
        return cell
    }
}

// MARK: -
extension TableViewController:UISearchBarDelegate {
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        tableView.tableHeaderView = nil
    }
}

// MARK: -
extension TableViewController:UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        array = (0...99).map({ String($0) })
            .filter({
                guard let text = self.navigationItem.searchController?.searchBar.text, !text.isEmpty else {
                    return true
                }
                
                return $0.contains(text)
            })
        
        tableView.reloadData()
    }
}

相关

iOS下拉搜索的几种实现方式(一)

iOS下拉搜索的几种实现方式(一)

iOS的设置,顶部有一个隐藏的下拉搜索。下拉到一定程度就会弹出来一个搜索框,如果力度不够,还会缩回去。我们要实现的就是类似这样的功能。

方法一:UIRefreshControl

UIRefreshControl是与UIScrollView绑定的一个类,当下拉时,会出现一个转圈圈的进度条,如果力度够大,就会执行指定的功能。优点:实现方式最简单。缺点:显示效果是动画效果,不是根据用户的下拉的进度对应的。此外,显示进度条会让用户感觉是网络应用,存在延迟,但是实际上其实是本地应用。

import UIKit

class TableViewController: UITableViewController {
    var array = (0...99).map { String($0) }
    var standardOffset = CGPoint.zero
    
    lazy var searchController:UISearchController = {
        let sc = UISearchController(searchResultsController: nil)
        sc.searchResultsUpdater = self
        sc.hidesNavigationBarDuringPresentation = false
        sc.obscuresBackgroundDuringPresentation = false
        
        sc.searchBar.delegate = self
        sc.searchBar.searchBarStyle = .minimal
        
        return sc
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        addRefreshControl()
    }
    
    private func addRefreshControl() {
        tableView.refreshControl = {
            let rc = UIRefreshControl()
            rc.addTarget(self, action: #selector(showSearchBar), for: .valueChanged)
            
            return rc
        }()
    }
    
    private func removeRefreshControl() {
        tableView.refreshControl = nil
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        standardOffset = tableView.contentOffset
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        standardOffset = .zero
    }
    
    @objc private func showSearchBar() {
        UIView.animate(withDuration: 0.4) {
            defer {
                DispatchQueue.main.async {
                    self.tableView.refreshControl?.endRefreshing()
                    self.removeRefreshControl()
                }
            }

            guard self.tableView.tableHeaderView == nil else { return }
            
            self.tableView.tableHeaderView = self.searchController.searchBar
        }
    }

    private func hideSearchBar() {
        UIView.animate(withDuration: 0.4, animations: {
            self.tableView.tableHeaderView = nil
            self.addRefreshControl()
        })
    }
    
    // MARK: - Table view data source
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return array.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = array[indexPath.row]
        
        return cell
    }
}

// MARK: -
extension TableViewController:UISearchBarDelegate {
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        tableView.tableHeaderView = nil
    }
}

// MARK: -
extension TableViewController:UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        array = (0...99).map({ String($0) })
            .filter({
                guard let text = self.searchController.searchBar.text, !text.isEmpty else {
                    return true
                }
                
                return $0.contains(text)
            })
        
        tableView.reloadData()
    }
}

// MARK: - UIScrollViewDelegate
extension TableViewController {
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if scrollView.contentOffset.y > standardOffset.y {
            hideSearchBar()
        }
    }
}

方法二:键值观察

使用键值观察可以根据屏幕滚动的方向来判断是向上还是向下,从而计算是否应该添加搜索栏。这种方法的缺点是,键值观察发送的数据特别快,如果不进行的特别处理,界面会感觉卡。特别的,相比于方法三,没法在键值观察的同时更新UITableView

import UIKit

class TableViewController: UITableViewController {
    var array = (0...99).map { String($0) }
    var standardOffset = CGPoint.zero
    
    var observer:NSKeyValueObservation? = nil
    
    lazy var searchController:UISearchController = {
        let sc = UISearchController(searchResultsController: nil)
        sc.searchResultsUpdater = self
        sc.hidesNavigationBarDuringPresentation = false
        sc.obscuresBackgroundDuringPresentation = false
        
        sc.searchBar.delegate = self
        sc.searchBar.searchBarStyle = .minimal
        
        return sc
    }()
    
    // 必须实现取值,因为searcBar是引用,大小后面会变化。
    lazy var searchBarWidth = self.searchController.searchBar.bounds.width
    lazy var searchBarHeight = self.searchController.searchBar.bounds.height
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // 获得初始值
        standardOffset = tableView.contentOffset
        registerObserver()
    }
    
    private func registerObserver() {
        observer = tableView.observe(\UITableView.contentOffset, options: .new) { (_, change) in
            guard let new = change.newValue else { return }
            
            DispatchQueue.main.async {
                self.updateUI(new: new)
            }
        }
    }
    
    private func updateUI(new:CGPoint) {
        let searchBar = self.searchController.searchBar
        
        // 如果存在搜索关键词,则搜索栏不发生变化
        guard let text = searchBar.text, text.isEmpty else { return }
        // 进度条到了尽头,会有反弹效果,此时无需计算
        guard !self.tableView.isDecelerating else { return }
        let deltaHeight = self.standardOffset.y - new.y
        
        if deltaHeight < 0 { // 上拉
            let height = (self.tableView.tableHeaderView?.bounds.height ?? 0) + deltaHeight
            let rect = CGRect(x: 0, y: 0, width: 0, height: max(height, 0))
            self.tableView.tableHeaderView?.bounds = rect
            
            if height < 0 {
                self.tableView.tableHeaderView = nil
            }
        } else if deltaHeight > 0 { // 下拉
            if self.tableView.tableHeaderView == nil {
                self.tableView.tableHeaderView = searchBar
            }
            
            let height = (self.tableView.tableHeaderView?.bounds.height ?? 0) + deltaHeight
            let rect = CGRect(x: 0, y: 0, width: self.searchBarWidth, height: min(height, self.searchBarHeight))
            self.tableView.tableHeaderView?.bounds = rect
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        observer?.invalidate()
        standardOffset = .zero
    }
    
    // MARK: - Table view data source
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return array.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = array[indexPath.row]
        
        return cell
    }
}

// MARK: -
extension TableViewController:UISearchBarDelegate {
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        tableView.tableHeaderView = nil
    }
}

// MARK: -
extension TableViewController:UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        array = (0...99).map({ String($0) })
            .filter({
                guard let text = self.searchController.searchBar.text, !text.isEmpty else {
                    return true
                }
                
                return $0.contains(text)
            })
        
        tableView.reloadData()
    }
}

// MARK: - UIScrollViewDelegate
extension TableViewController {
    /// 如果搜索栏显示内容达到一半,则全部显示;否则取消显示。
    /// - Parameter scrollView: tableView
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if let height = tableView.tableHeaderView?.bounds.height {
            if height >= self.searchBarHeight / 2 {
                let rect = CGRect(x: 0, y: 0, width: self.searchController.searchBar.bounds.width, height: self.searchBarHeight)
                tableView.tableHeaderView?.bounds = rect
            } else {
                tableView.tableHeaderView = nil
            }
            
            self.tableView.reloadData()
        }
    }
}

方法三:使用Combine

与方法二相比,因为使用了Combine,可以忽略过多的数据。缺点:使用过多的self.tableView.reloadData()可能会造成性能损失。如果有更好的办法,欢迎告知。

import UIKit
import Combine

class TableViewController: UITableViewController {
    var array = (0...99).map { String($0) }
    var standardOffset = CGPoint.zero
    
    // Combine Subject
    var offset = CurrentValueSubject<CGPoint, Never>(.zero)
    var offsetObserver:Cancellable? = nil
    
    lazy var searchController:UISearchController = {
        let sc = UISearchController(searchResultsController: nil)
        sc.searchResultsUpdater = self
        sc.hidesNavigationBarDuringPresentation = false
        sc.obscuresBackgroundDuringPresentation = false
        
        sc.searchBar.delegate = self
        sc.searchBar.searchBarStyle = .minimal
        
        return sc
    }()
    
    // 必须实现取值,因为searcBar是引用,大小后面会变化。
    lazy var searchBarWidth = self.searchController.searchBar.bounds.width
    lazy var searchBarHeight = self.searchController.searchBar.bounds.height
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        // 获得初始值
        standardOffset = tableView.contentOffset
        registerObserverWithCombine()
    }
    
    private func registerObserverWithCombine() {
        // 最高每0.03秒获取一次数据(33帧/秒),因为报送的数据太多,都处理显示会卡。
        offsetObserver = offset.debounce(for: .seconds(0.03), scheduler: RunLoop.main)
            .sink { self.updateUI(new: $0) }
    }
    
    /// 结尾必须使用`self.tableView.reloadData()`效果才会顺滑。但是如果表格复杂的话,可能会影响性能。
    private func updateUI(new:CGPoint) {
        let searchBar = self.searchController.searchBar
        
        // 如果存在搜索关键词,则搜索栏不发生变化
        guard let text = searchBar.text, text.isEmpty else { return }
        // 进度条到了尽头,会有反弹效果,此时无需计算
        guard !self.tableView.isDecelerating else { return }
        let deltaHeight = self.standardOffset.y - new.y
        
        if deltaHeight < 0 { // 上拉
            let height = (self.tableView.tableHeaderView?.bounds.height ?? 0) + deltaHeight
            let rect = CGRect(x: 0, y: 0, width: 0, height: max(height, 0))
            self.tableView.tableHeaderView?.bounds = rect
            
            if height < 0 {
                self.tableView.tableHeaderView = nil
            }
            
            self.tableView.reloadData()
        } else if deltaHeight > 0 { // 下拉
            if self.tableView.tableHeaderView == nil {
                self.tableView.tableHeaderView = searchBar
            }
            
            let height = (self.tableView.tableHeaderView?.bounds.height ?? 0) + deltaHeight
            let rect = CGRect(x: 0, y: 0, width: self.searchBarWidth, height: min(height, self.searchBarHeight))
            self.tableView.tableHeaderView?.bounds = rect
            
            self.tableView.reloadData()
        }
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        offsetObserver?.cancel()
        offsetObserver = nil
        standardOffset = .zero
    }
    
    // MARK: - Table view data source
    
    override func numberOfSections(in tableView: UITableView) -> Int {
        return 1
    }
    
    override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return array.count
    }
    
    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = array[indexPath.row]
        
        return cell
    }
}

// MARK: -
extension TableViewController:UISearchBarDelegate {
    func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
        tableView.tableHeaderView = nil
    }
}

// MARK: -
extension TableViewController:UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        array = (0...99).map({ String($0) })
            .filter({
                guard let text = self.searchController.searchBar.text, !text.isEmpty else {
                    return true
                }
                
                return $0.contains(text)
            })
        
        tableView.reloadData()
    }
}

// MARK: - UIScrollViewDelegate
extension TableViewController {
    /// 如果搜索栏显示内容达到一半,则全部显示;否则取消显示。
    /// - Parameter scrollView: tableView
    override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
        if let height = tableView.tableHeaderView?.bounds.height {
            guard height < self.searchBarHeight else { return }
            
            if height >= self.searchBarHeight / 2 {
                let rect = CGRect(x: 0, y: 0, width: self.searchController.searchBar.bounds.width, height: self.searchBarHeight)
                tableView.tableHeaderView?.bounds = rect
            } else {
                tableView.tableHeaderView = nil
            }
            
            self.tableView.reloadData()
        }
    }
    
    /// 更新`Combine`所需的数据
    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        offset.value = scrollView.contentOffset
    }
}

相关

iOS下拉搜索的几种实现方式(二)

Realm模型升级(一)

所谓模型升级,是指数据库存储的数据,随着需求的变化,产生了格式的改变。这个时候,由于数据库里有数据,直接修改模型并运行程序,程序就会崩溃,原因是模型发生了变化。

Realm本身提供了模型升级的功能。官方文档的实例也简单易懂。

https://realm.io/docs/swift/latest#migrations

不过,今天我遇到一个特殊情况,发现需要在升级的时候使用List对象,是不能直接转换的,摸索之后找到解决方案。先看问题。

问题

已有代码

class Item:Object {
    @objc dynamic var title = ""
    let records = List<Record>()
    
    override class func primaryKey() -> String? {
        return "title"
    }
}

class Record:Object {
    @objc dynamic var id:Int = 1
    @objc dynamic var addedDate:Date = Date()
}

新需求需要在Item中添加一个新的属性@objc dynamic var addedDate:Date = Date()

class Item:Object {
    @objc dynamic var title = ""
    @objc dynamic var addedDate:Date = Date()
    let records = List<Record>()
    
    override class func primaryKey() -> String? {
        return "title"
    }
}

class Record:Object {
    @objc dynamic var id:Int = 1
    @objc dynamic var addedDate:Date = Date()
}

升级代码如下:

// update realm
let config = Realm.Configuration(
    // Set the new schema version. This must be greater than the previously used
    // version (if you've never set a schema version before, the version is 0).
    schemaVersion: 1,
    
    // Set the block which will be called automatically when opening a Realm with
    // a schema version lower than the one set above
    migrationBlock: { migration, oldSchemaVersion in
        // We haven’t migrated anything yet, so oldSchemaVersion == 0
        if (oldSchemaVersion < 1) {
            // Nothing to do!
            // Realm will automatically detect new properties and removed properties
            // And will update the schema on disk automatically
            
            migration.enumerateObjects(ofType: Item.className()) { oldObject, newObject in
                newObject!["addedDate"] = (oldObject!["records"] as! List<Record>).first?.addedDate ?? Date()
            }
        }
})

// Tell Realm to use this new configuration object for the default Realm
Realm.Configuration.defaultConfiguration = config

// Now that we've told Realm how to handle the schema change, opening the file
// will automatically perform the migration
let _ = try! Realm()

运行时程序出错,提示不能将List<DynamicObject>转换为List<Record>

解决

经过查找资料发现,需要将其转化为List<MigrationObject。代码如下。究其原因,应该是Realm实例还没有创建,任何Realm的对象都是不可用的。(RecordObject子类)

// update realm
let config = Realm.Configuration(
    // Set the new schema version. This must be greater than the previously used
    // version (if you've never set a schema version before, the version is 0).
    schemaVersion: 1,
    
    // Set the block which will be called automatically when opening a Realm with
    // a schema version lower than the one set above
    migrationBlock: { migration, oldSchemaVersion in
        // We haven’t migrated anything yet, so oldSchemaVersion == 0
        if (oldSchemaVersion < 1) {
            // Nothing to do!
            // Realm will automatically detect new properties and removed properties
            // And will update the schema on disk automatically
            
            migration.enumerateObjects(ofType: Item.className()) { oldObject, newObject in
                newObject!["addedDate"] = (oldObject!["records"] as? List<MigrationObject>)?.first?["addedDate"] as? Date ?? Date()
            }
        }
})

// Tell Realm to use this new configuration object for the default Realm
Realm.Configuration.defaultConfiguration = config

// Now that we've told Realm how to handle the schema change, opening the file
// will automatically perform the migration
let _ = try! Realm()

相关文章

Realm模型升级(二)