From 23eab2310fa356a19e6caad9205f19eb30803dcb Mon Sep 17 00:00:00 2001 From: relikd Date: Fri, 5 Jun 2020 14:27:41 +0200 Subject: [PATCH] Search Hosts + search animations + reload table after filter manipulations --- AppCheck.xcodeproj/project.pbxproj | 4 + main/Base.lproj/Main.storyboard | 8 +- main/Common Classes/FilterPipeline.swift | 21 ++-- main/Common Classes/SearchBarManager.swift | 110 ++++++++++++++++++ .../Data Source/GroupedDomainDataSource.swift | 30 +++++ main/Extensions/TableView.swift | 13 +++ main/Requests/TVCDomains.swift | 86 ++++---------- main/Requests/TVCHosts.swift | 6 + 8 files changed, 205 insertions(+), 73 deletions(-) create mode 100644 main/Common Classes/SearchBarManager.swift diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index 19be3de..9245f03 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 543CDB2523EEE61900B7F323 /* GlassVPN.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 543CDB1D23EEE61900B7F323 /* GlassVPN.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 54448A2E2486464F00771C96 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2D2486464F00771C96 /* Array.swift */; }; 54448A30248647D900771C96 /* Time.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A2F248647D900771C96 /* Time.swift */; }; + 54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54448A3124899A4000771C96 /* SearchBarManager.swift */; }; 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 544C95252407B1C700AB89D0 /* SharedState.swift */; }; 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */; }; 545DDDCF243E6267003B6544 /* TutorialSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 545DDDCE243E6267003B6544 /* TutorialSheet.swift */; }; @@ -180,6 +181,7 @@ 543CDB2223EEE61900B7F323 /* GlassVPN.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GlassVPN.entitlements; sourceTree = ""; }; 54448A2D2486464F00771C96 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; 54448A2F248647D900771C96 /* Time.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Time.swift; sourceTree = ""; }; + 54448A3124899A4000771C96 /* SearchBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarManager.swift; sourceTree = ""; }; 544C95252407B1C700AB89D0 /* SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedState.swift; sourceTree = ""; }; 5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCRecordingDetails.swift; sourceTree = ""; }; 545DDDCE243E6267003B6544 /* TutorialSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSheet.swift; sourceTree = ""; }; @@ -415,6 +417,7 @@ 545DDDD024436983003B6544 /* QuickUI.swift */, 545DDDCE243E6267003B6544 /* TutorialSheet.swift */, 54D8B979246C9F2000EB2414 /* FilterPipeline.swift */, + 54448A3124899A4000771C96 /* SearchBarManager.swift */, ); path = "Common Classes"; sourceTree = ""; @@ -836,6 +839,7 @@ 54D8B97C2471A7E000EB2414 /* String.swift in Sources */, 54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */, 542E2A982404973F001462DC /* TBCMain.swift in Sources */, + 54448A3224899A4000771C96 /* SearchBarManager.swift in Sources */, 5458EBC0243A3F2200CFEB15 /* TVCRecordingDetails.swift in Sources */, 5412F8EE24571B8200A63D7A /* VCDateFilter.swift in Sources */, 545DDDD124436983003B6544 /* QuickUI.swift in Sources */, diff --git a/main/Base.lproj/Main.storyboard b/main/Base.lproj/Main.storyboard index 3338e3f..2cab9e7 100644 --- a/main/Base.lproj/Main.storyboard +++ b/main/Base.lproj/Main.storyboard @@ -342,7 +342,13 @@ - + + + + + + + diff --git a/main/Common Classes/FilterPipeline.swift b/main/Common Classes/FilterPipeline.swift index d1262ad..15ff5c0 100644 --- a/main/Common Classes/FilterPipeline.swift +++ b/main/Common Classes/FilterPipeline.swift @@ -100,6 +100,7 @@ class FilterPipeline { pipeline.append(newFilter) display?.apply(moreRestrictive: newFilter) } + if cellAnimations { delegate?.tableView.reloadData() } } /// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting. @@ -113,6 +114,7 @@ class FilterPipeline { resetFilters(startingAt: i) } } + if cellAnimations { delegate?.tableView.reloadData() } } /// Start filter evaluation on all entries from previous filter. @@ -120,19 +122,22 @@ class FilterPipeline { if let i = indexOfFilter(ident) { resetFilters(startingAt: i) } + if cellAnimations { delegate?.tableView.reloadData() } } /// Remove last `k` filters from the filter pipeline. Thus showing more entries from previous layers. - func popLastFilter(k: Int = 1) { - guard k > 0, k <= pipeline.count else { return } - pipeline.removeLast(k) - display?.reset(toLessRestrictive: pipeline.last) - } +// func popLastFilter(k: Int = 1) { +// guard k > 0, k <= pipeline.count else { return } +// pipeline.removeLast(k) +// display?.reset(toLessRestrictive: pipeline.last) +// if cellAnimations { delegate?.tableView.reloadData() } +// } /// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`. /// - Parameter predicate: Return `true` if first element should be sorted before second element. func setSorting(_ predicate: @escaping PipelineSorting.Predicate) { display = .init(predicate, pipe: self) + if cellAnimations { delegate?.tableView.reloadData() } } /// Re-built filter and display sorting order. @@ -171,13 +176,13 @@ class FilterPipeline { // MARK: data updates /// Disable individual cell updates (update, move, insert & remove actions) - func pauseCellAnimations(if condition: Bool) { - cellAnimations = delegate?.tableView.isFrontmost ?? false && !condition + func pauseCellAnimations(if condition: Bool = true) { + cellAnimations = !condition && delegate?.tableView.isFrontmost ?? false } /// Allow individual cell updates (update, move, insert & remove actions) if tableView `isFrontmost` /// - Parameter reloadTable: If `true` and cell animations are disabled, perform `tableView.reloadData()` - func continueCellAnimations(reloadTable: Bool = false) { + func continueCellAnimations(reloadTable: Bool = true) { if !cellAnimations { cellAnimations = true if reloadTable { delegate?.tableView.reloadData() } diff --git a/main/Common Classes/SearchBarManager.swift b/main/Common Classes/SearchBarManager.swift new file mode 100644 index 0000000..5a90aaa --- /dev/null +++ b/main/Common Classes/SearchBarManager.swift @@ -0,0 +1,110 @@ +import UIKit + +/// Assigns a `UISearchBar` to the `tableHeaderView` property of a `UITableView`. +class SearchBarManager: NSObject, UISearchBarDelegate { + + private weak var tableView: UITableView? + private let searchBar: UISearchBar + private(set) var active: Bool = false + + typealias OnChange = (String) -> Void + typealias OnHide = () -> Void + private var onChangeCallback: OnChange! + private var onHideCallback: OnHide? + + /// Prepare `UISearchBar` for user input + /// - Parameter tableView: The `tableHeaderView` property is used for display. + required init(on tableView: UITableView) { + self.tableView = tableView + searchBar = UISearchBar(frame: CGRect.init(x: 0, y: 0, width: 20, height: 10)) + searchBar.sizeToFit() // sets height, width is set by table view header + searchBar.showsCancelButton = true + searchBar.autocapitalizationType = .none + searchBar.autocorrectionType = .no + super.init() + searchBar.delegate = self + + UITextField.appearance(whenContainedInInstancesOf: [UISearchBar.self]) + .defaultTextAttributes = [.font: UIFont.preferredFont(forTextStyle: .body)] + } + + + // MARK: Show & Hide + + /// Insert search bar in `tableView` and call `reloadData()` after animation. + /// - Parameters: + /// - onHide: Code that will be executed once the search bar is dismissed. + /// - onChange: Code that will be executed every time the user changes the text (with 0.2s delay) + func show(onHide: OnHide? = nil, onChange: @escaping OnChange) { + onChangeCallback = onChange + onHideCallback = onHide + setSearchBarHidden(false) + } + + /// Remove search bar from `tableView` and call `reloadData()` after animation. + func hide() { + setSearchBarHidden(true) + } + + /// Internal method to insert or remove the `UISearchBar` as `tableHeaderView` + private func setSearchBarHidden(_ flag: Bool) { + active = !flag + searchBar.text = nil + guard let tv = tableView else { + hideAndRelease() + return + } + if active { + tv.scrollToTop(animated: false) + tv.tableHeaderView = searchBar + tv.frame.origin.y = -searchBar.frame.height + UIView.animate(withDuration: 0.3, animations: { + tv.frame.origin.y = 0 + }) { _ in + tv.reloadData() + self.searchBar.becomeFirstResponder() + } + } else { + searchBar.resignFirstResponder() + UIView.animate(withDuration: 0.3, animations: { + tv.frame.origin.y = -(tv.tableHeaderView?.frame.height ?? 0) + tv.scrollToTop(animated: false) // false to let UIView animate the change + }) { _ in + tv.frame.origin.y = 0 + self.hideAndRelease() + tv.reloadData() + } + } + } + + /// Call `OnHide` closure (if set), then release strong closure references. + private func hideAndRelease() { + tableView?.tableHeaderView = nil + onHideCallback?() + onHideCallback = nil + onChangeCallback = nil + } + + + // MARK: Search Bar Delegate + + func searchBarCancelButtonClicked(_ _: UISearchBar) { + setSearchBarHidden(true) + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } + + func searchBar(_ _: UISearchBar, textDidChange _: String) { + NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil) + perform(#selector(performSearch), with: nil, afterDelay: 0.2) + } + + /// Internal callback function for delayed text evaluation. + /// This way we can avoid unnecessary searches while user is typing. + @objc private func performSearch() { + onChangeCallback(searchBar.text ?? "") + tableView?.reloadData() + } +} diff --git a/main/Data Source/GroupedDomainDataSource.swift b/main/Data Source/GroupedDomainDataSource.swift index 04e9cb8..c3ea206 100644 --- a/main/Data Source/GroupedDomainDataSource.swift +++ b/main/Data Source/GroupedDomainDataSource.swift @@ -12,6 +12,7 @@ class GroupedDomainDataSource { private let parent: String? let pipeline: FilterPipeline + private lazy var search = SearchBarManager(on: pipeline.delegate!.tableView) init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) { parent = p @@ -179,6 +180,35 @@ extension GroupedDomainDataSource { } +// ################################ +// # +// # MARK: - Search +// # +// ################################ + +extension GroupedDomainDataSource { + func toggleSearch() { + if search.active { search.hide() } + else { + // Pause animations. Otherwise the `scrollToTop` animation is broken. + // This is due to `addFilter` calling `reloadData()` before `search.show()` can animate it. + pipeline.pauseCellAnimations() + var searchTerm = "" + pipeline.addFilter("search") { + $0.domain.lowercased().contains(searchTerm) + } + search.show(onHide: { [unowned self] in + self.pipeline.removeFilter(withId: "search") + }, onChange: { [unowned self] in + searchTerm = $0.lowercased() + self.pipeline.reloadFilter(withId: "search") + }) + pipeline.continueCellAnimations() + } + } +} + + // ########################## // # // # MARK: - Edit Row diff --git a/main/Extensions/TableView.swift b/main/Extensions/TableView.swift index 176b543..28b4e69 100644 --- a/main/Extensions/TableView.swift +++ b/main/Extensions/TableView.swift @@ -35,6 +35,19 @@ extension UITableView { func safeMoveRow(_ from: Int, to: Int) { isFrontmost ? moveRow(at: IndexPath(row: from), to: IndexPath(row: to)) : reloadData() } + + /// Scroll table to top (while respecting `contentInset`) + func scrollToTop(animated: Bool) { + let top: CGFloat + if #available(iOS 11.0, *) { + top = adjustedContentInset.top + } else { + top = contentInset.top + } + if contentOffset.y != -top { + setContentOffset(.init(x: 0, y: -top), animated: animated) + } + } } diff --git a/main/Requests/TVCDomains.swift b/main/Requests/TVCDomains.swift index 9965b5f..d9bc9ff 100644 --- a/main/Requests/TVCDomains.swift +++ b/main/Requests/TVCDomains.swift @@ -4,22 +4,11 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele lazy var source = GroupedDomainDataSource(withDelegate: self, parent: nil) - private var searchActive: Bool = false - private var searchTerm: String? - private let searchBar: UISearchBar = { - let x = UISearchBar(frame: CGRect.init(x: 0, y: 0, width: 20, height: 10)) - x.sizeToFit() - x.showsCancelButton = true - x.autocapitalizationType = .none - x.autocorrectionType = .no - return x - }() @IBOutlet private var filterButton: UIBarButtonItem! @IBOutlet private var filterButtonDetail: UIBarButtonItem! override func viewDidLoad() { super.viewDidLoad() - searchBar.delegate = self NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self) didChangeDateFilter() } @@ -39,62 +28,10 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele } - // MARK: - Table View Data Source - - override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "DomainCell")! - let entry = source[indexPath.row] - cell.textLabel?.text = entry.domain - cell.detailTextLabel?.text = entry.detailCellText - cell.imageView?.image = entry.options?.tableRowImage() - return cell - } - - func rowNeedsUpdate(_ row: Int) { - let entry = source[row] - let cell = tableView.cellForRow(at: IndexPath(row: row)) - cell?.detailTextLabel?.text = entry.detailCellText - cell?.imageView?.image = entry.options?.tableRowImage() - } - - // MARK: - Search @IBAction private func searchButtonTapped(_ sender: UIBarButtonItem) { - setSearch(hidden: searchActive) - } - - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - setSearch(hidden: true) - } - - private func setSearch(hidden: Bool) { - searchActive = !hidden - searchTerm = nil - searchBar.text = nil - tableView.tableHeaderView = hidden ? nil : searchBar - if searchActive { - source.pipeline.addFilter("search") { - $0.domain.lowercased().contains(self.searchTerm ?? "") - } - searchBar.becomeFirstResponder() - } else { - source.pipeline.removeFilter(withId: "search") - } - tableView.reloadData() - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(performSearch), object: nil) - perform(#selector(performSearch), with: nil, afterDelay: 0.2) - } - - @objc private func performSearch() { - searchTerm = searchBar.text?.lowercased() ?? "" - source.pipeline.reloadFilter(withId: "search") - tableView.reloadData() + source.toggleSearch() } @@ -124,4 +61,25 @@ class TVCDomains: UITableViewController, UISearchBarDelegate, FilterPipelineDele self.filterButton.image = UIImage(named: "filter-clear") } } + + + // MARK: - Table View Data Source + + override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "DomainCell")! + let entry = source[indexPath.row] + cell.textLabel?.text = entry.domain + cell.detailTextLabel?.text = entry.detailCellText + cell.imageView?.image = entry.options?.tableRowImage() + return cell + } + + func rowNeedsUpdate(_ row: Int) { + let entry = source[row] + let cell = tableView.cellForRow(at: IndexPath(row: row)) + cell?.detailTextLabel?.text = entry.detailCellText + cell?.imageView?.image = entry.options?.tableRowImage() + } } diff --git a/main/Requests/TVCHosts.swift b/main/Requests/TVCHosts.swift index 6413e33..ca6ec5e 100644 --- a/main/Requests/TVCHosts.swift +++ b/main/Requests/TVCHosts.swift @@ -20,6 +20,12 @@ class TVCHosts: UITableViewController, FilterPipelineDelegate { } } + // MARK: - Search + + @IBAction private func searchButtonTapped(_ sender: UIBarButtonItem) { + source.toggleSearch() + } + // MARK: - Table View Data Source override func tableView(_ _: UITableView, numberOfRowsInSection _: Int) -> Int { source.numberOfRows }