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