肇鑫的技术博客

业精于勤,荒于嬉

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下拉搜索的几种实现方式(二)