Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df2fe421e | ||
|
|
b4b89f8bb4 | ||
|
|
db41e68f35 | ||
|
|
5acd9bbcc6 | ||
|
|
23eab2310f | ||
|
|
80829ad015 | ||
|
|
661bf5d30a |
@@ -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 = "<group>"; };
|
||||
54448A2D2486464F00771C96 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
|
||||
54448A2F248647D900771C96 /* Time.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Time.swift; sourceTree = "<group>"; };
|
||||
54448A3124899A4000771C96 /* SearchBarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarManager.swift; sourceTree = "<group>"; };
|
||||
544C95252407B1C700AB89D0 /* SharedState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedState.swift; sourceTree = "<group>"; };
|
||||
5458EBBF243A3F2200CFEB15 /* TVCRecordingDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCRecordingDetails.swift; sourceTree = "<group>"; };
|
||||
545DDDCE243E6267003B6544 /* TutorialSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialSheet.swift; sourceTree = "<group>"; };
|
||||
@@ -415,6 +417,7 @@
|
||||
545DDDD024436983003B6544 /* QuickUI.swift */,
|
||||
545DDDCE243E6267003B6544 /* TutorialSheet.swift */,
|
||||
54D8B979246C9F2000EB2414 /* FilterPipeline.swift */,
|
||||
54448A3124899A4000771C96 /* SearchBarManager.swift */,
|
||||
);
|
||||
path = "Common Classes";
|
||||
sourceTree = "<group>";
|
||||
@@ -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 */,
|
||||
@@ -1095,7 +1099,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
INFOPLIST_FILE = main/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -1114,7 +1118,7 @@
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = main/main.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
INFOPLIST_FILE = main/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
@@ -1133,7 +1137,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
INFOPLIST_FILE = GlassVPN/Info.plist;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
||||
@@ -1151,7 +1155,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = GlassVPN/GlassVPN.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
INFOPLIST_FILE = GlassVPN/Info.plist;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "de.uni-bamberg.psi.AppCheck.VPN";
|
||||
|
||||
@@ -342,7 +342,13 @@
|
||||
<outlet property="delegate" destination="WcC-nb-Vf5" id="sBd-BW-Wg6"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<navigationItem key="navigationItem" title="Hosts" prompt="com.app.Example" id="TvD-8U-F05"/>
|
||||
<navigationItem key="navigationItem" title="Hosts" prompt="com.app.Example" id="TvD-8U-F05">
|
||||
<barButtonItem key="rightBarButtonItem" systemItem="search" id="cBL-dP-ig1">
|
||||
<connections>
|
||||
<action selector="searchButtonTapped:" destination="WcC-nb-Vf5" id="QFl-Me-lc6"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Gdi-Xi-JUL" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
|
||||
@@ -24,7 +24,7 @@ class FilterPipeline<T> {
|
||||
}
|
||||
|
||||
/// Set a new `dataSource` query and immediately apply all filters and sorting.
|
||||
/// - Note: You must call `reload(fromSource:)` manually!
|
||||
/// - Note: You must call `reload(fromSource:whenDone:)` manually!
|
||||
/// - Note: Always use `[unowned self]`
|
||||
func setDataSource(query: @escaping DataSourceQuery) {
|
||||
sourceQuery = query
|
||||
@@ -49,14 +49,8 @@ class FilterPipeline<T> {
|
||||
return (i, dataSource[i])
|
||||
}
|
||||
|
||||
/// Search and return list of `dataSource` elements that match the given `predicate`.
|
||||
/// - Returns: Sorted list of indices and objects in `dataSource`.
|
||||
/// - Complexity: O(*m* + *n*), where *n* is the length of the `dataSource` and *m* is the number of matches.
|
||||
// func dataSourceAll(where predicate: ((T) -> Bool)) -> [(index: Int, object: T)] {
|
||||
// dataSource.enumerated().compactMap { predicate($1) ? ($0, $1) : nil }
|
||||
// }
|
||||
|
||||
/// Re-query data source and re-built filter and display sorting order.
|
||||
/// - Note: Will call `reloadData()` before `whenDone` closure is executed. But only if `cellAnimations` are enabled.
|
||||
/// - Parameter fromSource: If `false` only re-built filter and sort order
|
||||
func reload(fromSource: Bool, whenDone: @escaping () -> Void) {
|
||||
DispatchQueue.global().async {
|
||||
@@ -65,14 +59,14 @@ class FilterPipeline<T> {
|
||||
}
|
||||
self.resetFilters()
|
||||
DispatchQueue.main.sync {
|
||||
self.delegate?.tableView.reloadData()
|
||||
self.reloadTableCells()
|
||||
whenDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the index set of either the last filter layer, or `dataSource` if no filter is set yet.
|
||||
fileprivate func lastFilterLayerIndices() -> [Int] {
|
||||
/// Returns the index set of either the last filter layer, or `dataSource` if no filter is set.
|
||||
fileprivate func lastLayerIndices() -> [Int] {
|
||||
pipeline.last?.selection ?? dataSource.indices.arr()
|
||||
}
|
||||
|
||||
@@ -86,6 +80,7 @@ class FilterPipeline<T> {
|
||||
|
||||
/// Add new filter layer. Each layer is applied upon the previous layer. Therefore, each filter
|
||||
/// can only restrict the display further. A filter cannot introduce previously removed elements.
|
||||
/// - Note: Will call `reloadData()` if `cellAnimations` are enabled.
|
||||
/// - Parameters:
|
||||
/// - identifier: Use this id to find the filter again. For reload and remove operations.
|
||||
/// - otherId: If `nil` or non-existent the new filter will be appended at the end.
|
||||
@@ -96,55 +91,54 @@ class FilterPipeline<T> {
|
||||
pipeline.insert(newFilter, at: i)
|
||||
resetFilters(startingAt: i)
|
||||
} else {
|
||||
newFilter.reset(to: dataSource, previous: pipeline.last)
|
||||
newFilter.reset(to: dataSource, previous: lastLayerIndices())
|
||||
pipeline.append(newFilter)
|
||||
display?.apply(moreRestrictive: newFilter)
|
||||
display?.apply(moreRestrictive: newFilter.selection)
|
||||
}
|
||||
reloadTableCells()
|
||||
}
|
||||
|
||||
/// Find and remove filter with given identifier. Will automatically update remaining filters and display sorting.
|
||||
/// - Note: Will call `reloadData()` if `cellAnimations` are enabled.
|
||||
func removeFilter(withId ident: String) {
|
||||
if let i = indexOfFilter(ident) {
|
||||
pipeline.remove(at: i)
|
||||
if i == pipeline.count {
|
||||
// only if we don't reset other layers we can assure `toLessRestrictive`
|
||||
display?.reset(toLessRestrictive: pipeline.last)
|
||||
} else {
|
||||
resetFilters(startingAt: i)
|
||||
}
|
||||
guard let i = indexOfFilter(ident) else { return }
|
||||
pipeline.remove(at: i)
|
||||
if i == pipeline.count {
|
||||
// only if we don't reset other layers we can assure `toLessRestrictive`
|
||||
display?.reset(toLessRestrictive: lastLayerIndices())
|
||||
} else {
|
||||
resetFilters(startingAt: i)
|
||||
}
|
||||
reloadTableCells()
|
||||
}
|
||||
|
||||
/// Start filter evaluation on all entries from previous filter.
|
||||
/// - Note: Will call `reloadData()` if `cellAnimations` are enabled.
|
||||
func reloadFilter(withId ident: String) {
|
||||
if let i = indexOfFilter(ident) {
|
||||
resetFilters(startingAt: i)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
guard let i = indexOfFilter(ident) else { return }
|
||||
resetFilters(startingAt: i)
|
||||
reloadTableCells()
|
||||
}
|
||||
|
||||
/// Sets the sort and display order. You should set the `delegate` to automatically update your `tableView`.
|
||||
/// - Note: Will call `reloadData()` if `cellAnimations` are enabled.
|
||||
/// - Parameter predicate: Return `true` if first element should be sorted before second element.
|
||||
func setSorting(_ predicate: @escaping PipelineSorting<T>.Predicate) {
|
||||
display = .init(predicate, pipe: self)
|
||||
reloadTableCells()
|
||||
}
|
||||
|
||||
/// Re-built filter and display sorting order.
|
||||
/// - Parameter index: Must be: `index <= pipeline.count`
|
||||
private func resetFilters(startingAt index: Int = 0) {
|
||||
for i in index..<pipeline.count {
|
||||
pipeline[i].reset(to: dataSource, previous: (i>0) ? pipeline[i-1] : nil)
|
||||
pipeline[i].reset(to: dataSource, previous: (i>0)
|
||||
? pipeline[i-1].selection : dataSource.indices.arr())
|
||||
}
|
||||
// Reset is NOT less-restrictive because filters are dynamic
|
||||
// Calling reset on a filter twice may yield different results
|
||||
// E.g. if filter uses variables outside of scope (current time, search term)
|
||||
display?.reset()
|
||||
display?.reset(to: lastLayerIndices())
|
||||
}
|
||||
|
||||
/// Push object through filter pipeline to check whether it survives all filters.
|
||||
@@ -171,19 +165,24 @@ class FilterPipeline<T> {
|
||||
// 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() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload table but only if `cellAnimations` is enabled.
|
||||
func reloadTableCells() {
|
||||
if cellAnimations { delegate?.tableView.reloadData() }
|
||||
}
|
||||
|
||||
/// Add new element to the original `dataSource` and immediately apply filter and sorting.
|
||||
/// - Complexity: O((*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter.
|
||||
func addNew(_ obj: T) {
|
||||
@@ -204,7 +203,10 @@ class FilterPipeline<T> {
|
||||
/// - Complexity: O(*n* + (*m*+1) log *n*), where *m* is the number of filters and *n* the number of elements in each filter / projection.
|
||||
func update(_ obj: T, at index: Int) {
|
||||
let status = processPipeline(with: obj, at: index)
|
||||
guard status.changed else { return }
|
||||
guard status.changed else {
|
||||
dataSource[index] = obj // we need to update anyway
|
||||
return
|
||||
}
|
||||
let oldPos = display.deleteOld(index)
|
||||
dataSource[index] = obj
|
||||
guard status.display else {
|
||||
@@ -212,16 +214,17 @@ class FilterPipeline<T> {
|
||||
return
|
||||
}
|
||||
let newPos = display.insertNew(index, previousIndex: oldPos)
|
||||
guard cellAnimations else { return }
|
||||
if oldPos == -1 {
|
||||
delegate?.tableView.safeInsertRow(newPos, with: .left)
|
||||
} else {
|
||||
if oldPos == newPos {
|
||||
delegate?.tableView.safeReloadRow(oldPos)
|
||||
if cellAnimations {
|
||||
if oldPos == -1 {
|
||||
delegate?.tableView.safeInsertRow(newPos, with: .left)
|
||||
} else {
|
||||
delegate?.tableView.safeMoveRow(oldPos, to: newPos)
|
||||
if delegate?.tableView.isFrontmost ?? false {
|
||||
delegate?.rowNeedsUpdate(newPos)
|
||||
if oldPos == newPos {
|
||||
delegate?.tableView.safeReloadRow(oldPos)
|
||||
} else {
|
||||
delegate?.tableView.safeMoveRow(oldPos, to: newPos)
|
||||
if delegate?.tableView.isFrontmost ?? false {
|
||||
delegate?.rowNeedsUpdate(newPos)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,10 +263,9 @@ class PipelineFilter<T> {
|
||||
shouldPersist = predicate
|
||||
}
|
||||
|
||||
/// Reset selection indices by copying the indices from the previous filter or using
|
||||
/// the indices of the data source if no previous filter is present.
|
||||
fileprivate func reset(to dataSource: [T], previous filter: PipelineFilter<T>? = nil) {
|
||||
selection = (filter != nil) ? filter!.selection : dataSource.indices.arr()
|
||||
/// Reset `selection` by copying the indices and applying the filter function
|
||||
fileprivate func reset(to dataSource: [T], previous filterIndices: [Int]) {
|
||||
selection = filterIndices
|
||||
selection.removeAll { !shouldPersist(dataSource[$0]) }
|
||||
}
|
||||
|
||||
@@ -294,14 +296,6 @@ class PipelineFilter<T> {
|
||||
selection.binTreeRemove(index, compare: (<))
|
||||
}
|
||||
|
||||
/// Find `selection` index for corresponding `dataSource` index
|
||||
/// - Parameter index: Index of object in original `dataSource`
|
||||
/// - Returns: Index in `selection` or `nil` if element does not exist.
|
||||
/// - Complexity: O(log *n*), where *n* is the length of the `selection`.
|
||||
fileprivate func index(ofDataSource index: Int) -> Int? {
|
||||
selection.binTreeIndex(of: index, compare: (<), mustExist: true)
|
||||
}
|
||||
|
||||
/// Perform filter check and update internal `selection` indices.
|
||||
/// - Parameters:
|
||||
/// - obj: Object that was inserted or updated.
|
||||
@@ -310,7 +304,7 @@ class PipelineFilter<T> {
|
||||
/// `idx` contains the selection filter index or `nil` if the value should be removed.
|
||||
/// - Complexity: O(log *n*), where *n* is the length of the `selection`.
|
||||
fileprivate func update(_ obj: T, at index: Int) -> (keep: Bool, idx: Int?) {
|
||||
let currentIndex = self.index(ofDataSource: index)
|
||||
let currentIndex = selection.binTreeIndex(of: index, compare: (<), mustExist: true)
|
||||
if shouldPersist(obj) {
|
||||
return (true, currentIndex ?? selection.binTreeInsert(index, compare: (<)))
|
||||
}
|
||||
@@ -347,7 +341,6 @@ class PipelineSorting<T> {
|
||||
|
||||
private(set) var projection: [Int] = []
|
||||
private let comperator: (Int, Int) -> Bool // links to pipeline.dataSource
|
||||
private let previousLayerIndices: () -> [Int] // links to pipeline
|
||||
|
||||
/// Create a fresh, already sorted, display order projection.
|
||||
/// - Parameter predicate: Return `true` if first element should be sorted before second element.
|
||||
@@ -355,34 +348,28 @@ class PipelineSorting<T> {
|
||||
comperator = { [unowned pipe] in
|
||||
predicate(pipe.dataSource[$0], pipe.dataSource[$1])
|
||||
}
|
||||
previousLayerIndices = { [unowned pipe] in
|
||||
pipe.lastFilterLayerIndices()
|
||||
}
|
||||
reset()
|
||||
reset(to: pipe.lastLayerIndices())
|
||||
}
|
||||
|
||||
/// Apply a new layer of filtering. Every layer can only restrict the display even further.
|
||||
/// Replace current `projection` with new filter indices and apply sorting.
|
||||
/// - Complexity: O(*n* log *n*), where *n* is the length of the `filter`.
|
||||
fileprivate func reset(to filterIndices: [Int]) {
|
||||
projection = filterIndices.sorted(by: comperator)
|
||||
}
|
||||
|
||||
/// After adding a new layer of filtering the new layer can only restrict the display even further.
|
||||
/// Therefore, indices that were removed in the last layer will be removed from the projection too.
|
||||
/// - Complexity: O(*m* log *n*), where *m* is the length of the `projection` and *n* the length of the `filter`.
|
||||
fileprivate func apply(moreRestrictive filter: PipelineFilter<T>) {
|
||||
projection.removeAll { filter.index(ofDataSource: $0) == nil }
|
||||
fileprivate func apply(moreRestrictive filterIndices: [Int]) {
|
||||
projection.removeAll { !filterIndices.binTreeExists($0, compare: (<)) }
|
||||
}
|
||||
|
||||
/// Remove a layer of filtering. Previous layers are less restrictive and contain more indices.
|
||||
/// After removing a layer of filtering the previous layers are less restrictive and thus contain more indices.
|
||||
/// Therefore, the difference between both index sets will be inserted into the projection.
|
||||
/// - Parameter filter: If `nil`, reset to last filter layer or `dataSource`
|
||||
/// - Complexity:
|
||||
/// * O(*m* log *n*), if `filter != nil`.
|
||||
/// Where *n* is the length of the `projection` and *m* is the difference between both layers.
|
||||
/// * O(*n* log *n*), if `filter == nil`.
|
||||
/// Where *n* is the length of the previous layer (or `dataSource`).
|
||||
fileprivate func reset(toLessRestrictive filter: PipelineFilter<T>? = nil) {
|
||||
if let indices = filter?.selection.difference(toSubset: projection.sorted(), compare: (<)) {
|
||||
for idx in indices {
|
||||
insertNew(idx)
|
||||
}
|
||||
} else {
|
||||
projection = previousLayerIndices().sorted(by: comperator)
|
||||
/// - Complexity: O(*m* log *n*), where *m* is the difference to the previous layer and *n* is the length of the `projection`.
|
||||
fileprivate func reset(toLessRestrictive filterIndices: [Int]) {
|
||||
for x in filterIndices.difference(toSubset: projection.sorted(), compare: (<)) {
|
||||
insertNew(x)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
110
main/Common Classes/SearchBarManager.swift
Normal file
110
main/Common Classes/SearchBarManager.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -109,9 +109,9 @@ extension SQLiteDatabase {
|
||||
return (before > after) ? nil : (before, after)
|
||||
}
|
||||
|
||||
/// `DELETE FROM heap; DELETE FROM cache;`
|
||||
/// `DELETE FROM cache; DELETE FROM heap;`
|
||||
func dnsLogsDeleteAll() throws {
|
||||
try? run(sql: "DELETE FROM heap; DELETE FROM cache;")
|
||||
try? run(sql: "DELETE FROM cache; DELETE FROM heap;")
|
||||
vacuum()
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class GroupedDomainDataSource {
|
||||
|
||||
private let parent: String?
|
||||
let pipeline: FilterPipeline<GroupedDomain>
|
||||
private lazy var search = SearchBarManager(on: pipeline.delegate!.tableView)
|
||||
|
||||
init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) {
|
||||
parent = p
|
||||
@@ -55,6 +56,7 @@ class GroupedDomainDataSource {
|
||||
sync.continue()
|
||||
} else {
|
||||
pipeline.reload(fromSource: true, whenDone: {
|
||||
sync.syncNow() // sync outstanding entries in cache
|
||||
sync.continue()
|
||||
refreshControl?.endRefreshing()
|
||||
})
|
||||
@@ -179,6 +181,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
class SyncUpdate {
|
||||
private var lastSync: TimeInterval = 0
|
||||
private var timer: Timer!
|
||||
private var paused: Int = 1 // first start() will decrement
|
||||
private(set) var tsEarliest: Timestamp
|
||||
@@ -9,47 +10,65 @@ class SyncUpdate {
|
||||
tsEarliest = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
||||
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
|
||||
timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self)
|
||||
syncNow() // because timer will only fire after interval
|
||||
}
|
||||
|
||||
@objc private func periodicUpdate() { if paused == 0 { syncNow() } }
|
||||
|
||||
@objc private func didChangeDateFilter() {
|
||||
let lastXFilter = Pref.DateFilter.lastXMinTimestamp() ?? 0
|
||||
let before = tsEarliest
|
||||
tsEarliest = lastXFilter
|
||||
if before < lastXFilter {
|
||||
DispatchQueue.global().async {
|
||||
if let excess = AppDB?.dnsLogsRowRange(between: before, and: lastXFilter) {
|
||||
NotifySyncRemove.postAsyncMain(excess)
|
||||
}
|
||||
}
|
||||
} else if before > lastXFilter {
|
||||
DispatchQueue.global().async {
|
||||
if let missing = AppDB?.dnsLogsRowRange(between: lastXFilter, and: before) {
|
||||
NotifySyncInsert.postAsyncMain(missing)
|
||||
}
|
||||
}
|
||||
DispatchQueue.global().async {
|
||||
self.set(newEarliest: Pref.DateFilter.lastXMinTimestamp() ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// This will immediately resume timer updates, ignoring previous `pause()` requests.
|
||||
func start() { paused = 0 }
|
||||
|
||||
/// All calls must be balanced with `continue()` calls.
|
||||
/// Can be nested within other `pause-continue` pairs.
|
||||
/// - Warning: An execution branch that results in unbalanced pairs will completely disable updates!
|
||||
func pause() { paused += 1 }
|
||||
|
||||
/// Must be balanced with a `pause()` call. A `continue()` without a `pause()` is a `nop`.
|
||||
/// - Note: Internally the sync timer keeps running. The `pause` will simply ignore execution during that time.
|
||||
func `continue`() { if paused > 0 { paused -= 1 } }
|
||||
|
||||
/// Persist logs from cache and notify all observers. (`NotifySyncInsert`)
|
||||
/// Determine rows of outdated entries that should be removed and notify observers as well. (`NotifySyncRemove`)
|
||||
/// - Note: This method is rate limited. Sync will be performed at most once per second.
|
||||
/// - Note: This method returns immediatelly. Syncing is done in a background thread.
|
||||
func syncNow() {
|
||||
self.pause() // reduce concurrent load
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard (now - lastSync) > 1 else { return } // rate limiting
|
||||
lastSync = now
|
||||
|
||||
if let inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
|
||||
NotifySyncInsert.post(inserted)
|
||||
}
|
||||
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp(), tsEarliest < lastXFilter {
|
||||
if let removed = AppDB?.dnsLogsRowRange(between: tsEarliest, and: lastXFilter) {
|
||||
NotifySyncRemove.post(removed)
|
||||
DispatchQueue.global().async {
|
||||
self.pause() // reduce concurrent load
|
||||
|
||||
if let inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
|
||||
NotifySyncInsert.postAsyncMain(inserted)
|
||||
}
|
||||
tsEarliest = lastXFilter
|
||||
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp() {
|
||||
self.set(newEarliest: lastXFilter)
|
||||
}
|
||||
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||
|
||||
self.continue()
|
||||
}
|
||||
// TODO: periodic hard delete old logs (will reset rowids!)
|
||||
|
||||
self.continue()
|
||||
}
|
||||
|
||||
/// - Warning: Always call from a background thread!
|
||||
private func set(newEarliest: Timestamp) {
|
||||
let current = tsEarliest
|
||||
tsEarliest = newEarliest
|
||||
if current < newEarliest {
|
||||
if let excess = AppDB?.dnsLogsRowRange(between: current, and: newEarliest) {
|
||||
NotifySyncRemove.postAsyncMain(excess)
|
||||
}
|
||||
} else if current > newEarliest {
|
||||
if let missing = AppDB?.dnsLogsRowRange(between: newEarliest, and: current) {
|
||||
NotifySyncInsert.postAsyncMain(missing)
|
||||
}
|
||||
} // else: nothing changed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ extension Array {
|
||||
result.append(lhs)
|
||||
}
|
||||
}
|
||||
result.append(contentsOf: iter)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ class TVCHostDetails: UITableViewController {
|
||||
self?.dataSource = AppDB?.timesForDomain(self?.fullDomain ?? "", since: sync.tsEarliest) ?? []
|
||||
DispatchQueue.main.sync {
|
||||
self?.tableView.reloadData()
|
||||
sync.syncNow() // sync outstanding entries in cache
|
||||
refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user