Refactoring II.

- Filter by date range
- SyncUpdate tasks run fully asynchronous in background
- Move tableView manipulations into FilterPipelineDelegate
- Move SyncUpdate notification into SyncUpdateDelegate
- Fix: sync cache before persisting a recording
- Restructuring GroupedDomainDataSource
- Performance: db logs queries use rowids instead of timestamps
- Add 'now' button to DatePickerAlert
This commit is contained in:
relikd
2020-06-17 00:27:22 +02:00
parent 0a53898797
commit e947ad6d4d
20 changed files with 644 additions and 386 deletions

View File

@@ -1,9 +1,7 @@
import Foundation
enum DomainFilter {
static private var data: [String: FilterOptions] = {
AppDB?.loadFilters() ?? [:]
}()
static private var data = AppDB?.loadFilters() ?? [:]
/// Get filter with given `domain` name
@inline(__always) static subscript(_ domain: String) -> FilterOptions? {
@@ -12,10 +10,10 @@ enum DomainFilter {
/// Update local memory object by loading values from persistent db.
/// - Note: Will trigger `NotifyDNSFilterChanged` notification.
static func reload() {
data = AppDB?.loadFilters() ?? [:]
NotifyDNSFilterChanged.post()
}
// static func reload() {
// data = AppDB?.loadFilters() ?? [:]
// NotifyDNSFilterChanged.post()
// }
/// Get list of domains (sorted by name) which do contain the given filter
static func list(where matching: FilterOptions) -> [String] {

View File

@@ -1,54 +1,52 @@
import UIKit
protocol GroupedDomainDataSourceDelegate: UITableViewController {
/// Currently only called when a row is moved and the `tableView` is frontmost.
func groupedDomainDataSource(needsUpdate row: Int)
}
// ##########################
// #
// # MARK: DataSource
// #
// ##########################
class GroupedDomainDataSource {
class GroupedDomainDataSource: FilterPipelineDelegate, SyncUpdateDelegate {
private var tsLatest: Timestamp = 0
private let parent: String?
let pipeline: FilterPipeline<GroupedDomain>
private lazy var search = SearchBarManager(on: pipeline.delegate!.tableView)
let parent: String?
private let pipeline = FilterPipeline<GroupedDomain>()
private lazy var search = SearchBarManager(on: delegate!.tableView)
private var currentOrder: DateFilterOrderBy = .Date
private var orderAsc = false
init(withDelegate tvc: FilterPipelineDelegate, parent p: String?) {
parent = p
pipeline = .init(withDelegate: tvc)
pipeline.setDataSource { [unowned self] in self.dataSourceCallback() }
/// Will init `sync.allowPullToRefresh()` on `tableView.refreshControl` as well.
weak var delegate: GroupedDomainDataSourceDelegate? {
willSet { if #available(iOS 10.0, *), newValue !== delegate {
sync.allowPullToRefresh(onTVC: newValue, forObserver: self)
}}}
/// - Note: Will call `tableview.reloadData()`
init(withParent: String?) {
parent = withParent
pipeline.delegate = self
resetSortingOrder(force: true)
if #available(iOS 10.0, *) {
tvc.tableView.refreshControl = UIRefreshControl(call: #selector(reloadFromSource), on: self)
}
NotifyLogHistoryReset.observe(call: #selector(reloadFromSource), on: self)
NotifyDNSFilterChanged.observe(call: #selector(didChangeDomainFilter), on: self)
NotifySortOrderChanged.observe(call: #selector(didChangeSortOrder), on: self)
NotifySyncInsert.observe(call: #selector(syncInsert), on: self)
NotifySyncRemove.observe(call: #selector(syncRemove), on: self)
sync.addObserver(self) // calls syncUpdate(reset:)
}
/// Callback fired only when pipeline resets data source
private func dataSourceCallback() -> [GroupedDomain] {
guard let db = AppDB else { return [] }
let earliest = sync.tsEarliest
tsLatest = earliest
var log = db.dnsLogsGrouped(since: earliest, parentDomain: parent) ?? []
for (i, val) in log.enumerated() {
log[i].options = DomainFilter[val.domain]
tsLatest = max(tsLatest, val.lastModified)
}
return log
/// Callback fired when user changes date filter settings. (`NotifySortOrderChanged` notification)
@objc private func didChangeSortOrder(_ notification: Notification) {
resetSortingOrder()
}
/// Read user defaults and apply new sorting order. Either by setting a new or reversing the current.
/// - Parameter force: If `true` set new sorting even if the type does not differ.
private func resetSortingOrder(force: Bool = false) {
let orderDidChange = (orderAsc =? Pref.DateFilter.OrderAsc)
if currentOrder =? Pref.DateFilter.OrderBy || force {
let orderDidChange = (orderAsc <-? Pref.DateFilter.OrderAsc)
if currentOrder <-? Pref.DateFilter.OrderBy || force {
switch currentOrder {
case .Date:
pipeline.setSorting { [unowned self] in
@@ -68,64 +66,49 @@ class GroupedDomainDataSource {
}
}
/// Pause recurring background updates to force reload `dataSource`.
/// Callback fired on user action `pull-to-refresh`, or another background task triggered `NotifyLogHistoryReset`.
/// - Parameter sender: May be either `UIRefreshControl` or `Notification`
/// (optional: pass single domain as the notification object).
@objc func reloadFromSource(sender: Any? = nil) {
weak var refreshControl = sender as? UIRefreshControl
let notification = sender as? Notification
sync.pause()
if let affectedDomain = notification?.object as? String {
partiallyReloadFromSource(affectedDomain)
sync.continue()
} else {
pipeline.reload(fromSource: true, whenDone: {
sync.syncNow() // sync outstanding entries in cache
sync.continue()
refreshControl?.endRefreshing()
})
}
}
/// Callback fired when user edits list of `blocked` or `ignored` domains in settings. (`NotifyDNSFilterChanged` notification)
@objc private func didChangeDomainFilter(_ notification: Notification) {
guard let domain = notification.object as? String else {
reloadFromSource()
return
preconditionFailure("Domain independent filter reset not implemented") // `syncUpdate(reset:)` async!
}
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == domain }) {
var y = obj
y.options = DomainFilter[domain]
pipeline.update(y, at: i)
if let x = pipeline.dataSourceGet(where: { $0.domain == domain }) {
var obj = x.object
obj.options = DomainFilter[domain]
pipeline.update(obj, at: x.index)
}
}
/// Callback fired when user changes date filter settings. (`NotifySortOrderChanged` notification)
@objc private func didChangeSortOrder(_ notification: Notification) {
resetSortingOrder()
}
// MARK: Table View Data Source
@inline(__always) var numberOfRows: Int { get { pipeline.displayObjectCount() } }
@inline(__always) subscript(_ row: Int) -> GroupedDomain { pipeline.displayObject(at: row) }
}
// ################################
// #
// # MARK: - Partial Update
// #
// ################################
extension GroupedDomainDataSource {
func syncUpdate(_: SyncUpdate, reset rows: SQLiteRowRange) {
var logs = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) ?? []
for (i, val) in logs.enumerated() {
logs[i].options = DomainFilter[val.domain]
}
pipeline.reset(dataSource: logs)
}
// MARK: partial updates
/// Callback fired when background sync added new entries to the list. (`NotifySyncInsert` notification)
@objc private func syncInsert(_ notification: Notification) {
sync.pause()
defer { sync.continue() }
let range = notification.object as! SQLiteRowRange
guard let latest = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent) else {
func syncUpdate(_: SyncUpdate, insert rows: SQLiteRowRange) {
guard let latest = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent) else {
assertionFailure("NotifySyncInsert fired with empty range")
return
}
pipeline.pauseCellAnimations(if: latest.count > 14)
cellAnimationsGroup(if: latest.count > 14)
for x in latest {
if let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) {
pipeline.update(obj + x, at: i)
@@ -134,21 +117,16 @@ class GroupedDomainDataSource {
y.options = DomainFilter[x.domain]
pipeline.addNew(y)
}
tsLatest = max(tsLatest, x.lastModified)
}
pipeline.continueCellAnimations(reloadTable: true)
cellAnimationsCommit()
}
/// Callback fired when background sync removed old entries from the list. (`NotifySyncRemove` notification)
@objc private func syncRemove(_ notification: Notification) {
sync.pause()
defer { sync.continue() }
let range = notification.object as! SQLiteRowRange
guard let outdated = AppDB?.dnsLogsGrouped(range: range, parentDomain: parent),
func syncUpdate(_: SyncUpdate, remove rows: SQLiteRowRange) {
guard let outdated = AppDB?.dnsLogsGrouped(range: rows, parentDomain: parent),
outdated.count > 0 else {
return
}
pipeline.pauseCellAnimations(if: outdated.count > 14)
cellAnimationsGroup(if: outdated.count > 14)
var listOfDeletes: [Int] = []
for x in outdated {
guard let (i, obj) = pipeline.dataSourceGet(where: { $0.domain == x.domain }) else {
@@ -162,34 +140,10 @@ class GroupedDomainDataSource {
}
}
pipeline.remove(indices: listOfDeletes.sorted())
pipeline.continueCellAnimations(reloadTable: true)
}
}
// ################################
// #
// # MARK: - Delete History
// #
// ################################
extension GroupedDomainDataSource {
/// Callback fired when user performs row edit -> delete action
func deleteHistory(domain: String, since ts: Timestamp) {
let flag = (parent != nil)
DispatchQueue.global().async {
guard let db = AppDB, db.dnsLogsDelete(domain, strict: flag, since: ts) > 0 else {
return // nothing has changed
}
db.vacuum()
NotifyLogHistoryReset.postAsyncMain(domain) // calls partiallyReloadFromSource(:)
}
cellAnimationsCommit()
}
/// Reload a single data source entry. Callback fired by `reloadFromSource()`
/// Only useful if `affectedFQDN` currently exists in `dataSource`. Can either update or remove entry.
private func partiallyReloadFromSource(_ affectedFQDN: String) {
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedFQDN: String) {
let affectedParent = affectedFQDN.extractDomain()
guard parent == nil || parent == affectedParent else {
return // does not affect current table
@@ -199,8 +153,8 @@ extension GroupedDomainDataSource {
// can only happen if delete sheet is open while background sync removed the element
return
}
if var updated = AppDB?.dnsLogsGrouped(since: sync.tsEarliest, upto: tsLatest,
matchingDomain: affected, parentDomain: parent)?.first {
if var updated = AppDB?.dnsLogsGrouped(range: sender.rows, matchingDomain: affected,
parentDomain: parent)?.first {
assert(old.object.domain == updated.domain)
updated.options = DomainFilter[updated.domain]
pipeline.update(updated, at: old.index)
@@ -211,6 +165,57 @@ extension GroupedDomainDataSource {
}
// #################################
// #
// # MARK: - Cell Animations
// #
// #################################
extension GroupedDomainDataSource {
/// Sets `pipeline.delegate = nil` to disable individual cell animations (update, insert, delete & move).
private func cellAnimationsGroup(if condition: Bool = true) {
if condition { pipeline.delegate = nil }
if pipeline.delegate != nil {
onMain { if !$0.isFrontmost { self.pipeline.delegate = nil } }
}
}
/// No-Op if cell animations are enabled already.
/// Else, set `pipeline.delegate = self` and perform `reloadData()`.
private func cellAnimationsCommit() {
if pipeline.delegate == nil {
pipeline.delegate = self
onMain { $0.reloadData() }
}
}
/// Perform table view manipulations on main thread.
/// Set `delegate = nil` to disable `tableView` animations.
private func onMain(_ closure: (UITableView) -> Void) {
if Thread.isMainThread {
if let tv = delegate?.tableView { closure(tv) }
} else {
DispatchQueue.main.sync {
if let tv = delegate?.tableView { closure(tv) }
}
}
}
// TODO: Collect animations and post them in a single animations block.
// This will require enormous work to translate then into a final set.
func filterPipelineDidReset() { onMain { $0.reloadData() } }
func filterPipeline(delete rows: [Int]) { onMain { $0.safeDeleteRows(rows) } }
func filterPipeline(insert row: Int) { onMain { $0.safeInsertRow(row, with: .left) } }
func filterPipeline(update row: Int) { onMain { $0.safeReloadRow(row) } }
func filterPipeline(move oldRow: Int, to newRow: Int) {
onMain {
$0.safeMoveRow(oldRow, to: newRow)
if $0.isFrontmost { // onMain ensures delegate is set
delegate!.groupedDomainDataSource(needsUpdate: newRow)
}
}
}
}
// ################################
// #
// # MARK: - Search
@@ -221,9 +226,9 @@ extension GroupedDomainDataSource {
func toggleSearch() {
if search.active { search.hide() }
else {
// Pause animations. Otherwise the `scrollToTop` animation is broken.
// Begin animations group. Otherwise the `scrollToTop` animation is broken.
// This is due to `addFilter` calling `reloadData()` before `search.show()` can animate it.
pipeline.pauseCellAnimations()
cellAnimationsGroup()
var searchTerm = ""
pipeline.addFilter("search") {
$0.domain.lowercased().contains(searchTerm)
@@ -234,7 +239,7 @@ extension GroupedDomainDataSource {
searchTerm = $0.lowercased()
self.pipeline.reloadFilter(withId: "search")
})
pipeline.continueCellAnimations()
cellAnimationsCommit()
}
}
}
@@ -246,8 +251,8 @@ extension GroupedDomainDataSource {
// #
// ##########################
protocol GroupedDomainEditRow : EditableRows, FilterPipelineDelegate {
var source: GroupedDomainDataSource { get set }
protocol GroupedDomainEditRow : UIViewController, EditableRows {
var source: GroupedDomainDataSource { get }
}
extension GroupedDomainEditRow {
@@ -274,8 +279,10 @@ extension GroupedDomainEditRow {
case .ignore: showFilterSheet(entry, .ignored)
case .block: showFilterSheet(entry, .blocked)
case .delete:
AlertDeleteLogs(entry.domain, latest: entry.lastModified) {
self.source.deleteHistory(domain: entry.domain, since: $0)
let name = entry.domain
let flag = (source.parent != nil)
AlertDeleteLogs(name, latest: entry.lastModified) {
TheGreatDestroyer.deleteLogs(domain: name, since: $0, strict: flag)
}.presentIn(self)
}
return true

View File

@@ -15,8 +15,9 @@ enum RecordingsDB {
/// Copy log entries from generic `heap` table to recording specific `recLog` table
static func persist(_ r: Recording) {
sync.syncNow() // persist changes in cache before copying recording details
AppDB?.recordingLogsPersist(r)
sync.syncNow { // persist changes in cache before copying recording details
AppDB?.recordingLogsPersist(r)
}
}
/// Get list of domains that occured during the recording

View File

@@ -1,26 +1,63 @@
import Foundation
import UIKit
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
private var filterType: DateFilterKind
private var range: SQLiteRowRange? // written in reloadRangeFromDB()
/// Returns invalid range `(-1,-1)` if collection contains no rows
var rows: SQLiteRowRange { get { range ?? (-1,-1) } }
private(set) var tsEarliest: Timestamp // as set per user, not actual earliest
private(set) var tsLatest: Timestamp // as set per user, not actual latest
init(periodic interval: TimeInterval) {
tsEarliest = Pref.DateFilter.lastXMinTimestamp() ?? 0
(filterType, tsEarliest, tsLatest) = Pref.DateFilter.restrictions()
reloadRangeFromDB()
NotifyDateFilterChanged.observe(call: #selector(didChangeDateFilter), on: self)
timer = Timer.repeating(interval, call: #selector(periodicUpdate), on: self)
syncNow() // because timer will only fire after interval
}
/// Callback fired every `7` seconds.
@objc private func periodicUpdate() { if paused == 0 { syncNow() } }
/// Callback fired when user changes `DateFilter` on root tableView controller
@objc private func didChangeDateFilter() {
self.pause()
let filter = Pref.DateFilter.restrictions()
filterType = filter.type
DispatchQueue.global().async {
self.set(newEarliest: Pref.DateFilter.lastXMinTimestamp() ?? 0)
// Not necessary, but improve execution order (delete then insert).
if self.tsEarliest <= filter.earliest {
self.update(newEarliest: filter.earliest)
self.update(newLatest: filter.latest)
} else {
self.update(newLatest: filter.latest)
self.update(newEarliest: filter.earliest)
}
self.continue()
}
}
/// - Warning: Always call from a background thread!
func needsReloadDB(domain: String? = nil) {
assert(!Thread.isMainThread)
reloadRangeFromDB()
if let dom = domain {
notifyObservers { $0.syncUpdate(self, partialRemove: dom) }
} else {
notifyObservers { $0.syncUpdate(self, reset: rows) }
}
}
// MARK: - Sync Now
/// This will immediately resume timer updates, ignoring previous `pause()` requests.
func start() { paused = 0 }
@@ -37,38 +74,188 @@ class SyncUpdate {
/// 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() {
/// - Parameter block: **Always** called on a background thread!
func syncNow(whenDone block: (() -> Void)? = nil) {
let now = Date().timeIntervalSince1970
guard (now - lastSync) > 1 else { return } // rate limiting
guard (now - lastSync) > 1 else { // rate limiting
if let b = block { DispatchQueue.global().async { b() } }
return
}
lastSync = now
self.pause() // reduce concurrent load
DispatchQueue.global().async {
self.pause() // reduce concurrent load
if let inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
NotifySyncInsert.postAsyncMain(inserted)
}
if let lastXFilter = Pref.DateFilter.lastXMinTimestamp() {
self.set(newEarliest: lastXFilter)
}
// TODO: periodic hard delete old logs (will reset rowids!)
self.internalSync()
block?()
self.continue()
}
}
/// Called by `syncNow()`. Split to a separate func to reduce `self.` cluttering
private func internalSync() {
assert(!Thread.isMainThread)
// Always persist logs ...
if let inserted = AppDB?.dnsLogsPersist() { // move cache -> heap
if filterType == .ABRange {
// ... even if we filter a few later
publishInsert(front: false, tsEarliest, tsLatest + 1, scope: inserted)
} else {
safeSetRange(end: inserted)
notifyObservers { $0.syncUpdate(self, insert: inserted) }
}
}
if filterType == .LastXMin {
update(newEarliest: Timestamp.past(minutes: Pref.DateFilter.LastXMin))
}
// TODO: periodic hard delete old logs (will reset rowids!)
}
// MARK: - Internal
private func reloadRangeFromDB() {
// `nil` is not SQLiteRowRange(0,0) aka. full collection.
// `nil` means invalid range. e.g. ts restriction too high or empty db.
range = AppDB?.dnsLogsRowRange(between: tsEarliest, and: tsLatest + 1)
}
/// Helper to always set range in case there was none before. Otherwise only update `start`.
private func safeSetRange(start r: SQLiteRowRange) {
range == nil ? (range = r) : (range!.start = r.start)
}
/// Helper to always set range in case there was none before. Otherwise only update `end`.
private func safeSetRange(end r: SQLiteRowRange) {
range == nil ? (range = r) : (range!.end = r.end)
}
/// Update internal `tsEarliest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids.
/// - 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)
private func update(newEarliest: Timestamp) {
if let (old, new) = tsEarliest <-/ newEarliest {
if new < old {
publishInsert(front: true, new, (tsLatest == -1 ? old : min(old, tsLatest + 1)), scope: (0, range?.start ?? 0))
} else if range != nil {
publishRemove(front: true, old, (tsLatest == -1 ? new : min(new, tsLatest + 1)), scope: range!)
}
} else if current > newEarliest {
if let missing = AppDB?.dnsLogsRowRange(between: newEarliest, and: current) {
NotifySyncInsert.postAsyncMain(missing)
}
}
/// Update internal `tsLatest`, then post `NotifySyncInsert` or `NotifySyncRemove` notification with row ids.
/// - Warning: Always call from a background thread!
private func update(newLatest: Timestamp) {
if let (old, new) = tsLatest <-/ newLatest {
// +1: include upper end because `dnsLogsRowRange` selects `ts < X`
if (old < new || new == -1), old != -1 {
publishInsert(front: false, max(old + 1, tsEarliest), new + 1, scope: (range?.end ?? 0, 0))
} else if range != nil {
// FIXME: removing latest entries will invalidate "last changed" label
publishRemove(front: false, max(new + 1, tsEarliest), old + 1, scope: range!)
}
} // else: nothing changed
}
}
/// - Warning: Always call from a background thread!
private func publishInsert(front: Bool, _ ts1: Timestamp, _ ts2: Timestamp, scope: SQLiteRowRange) {
if let r = AppDB?.dnsLogsRowRange(between: ts1, and: ts2, within: scope) {
front ? safeSetRange(start: r) : safeSetRange(end: r)
notifyObservers { $0.syncUpdate(self, insert: r) }
}
}
/// - Warning: `range` must not be `nil`!
/// - Warning: Always call from a background thread!
private func publishRemove(front: Bool, _ ts1: Timestamp, _ ts2: Timestamp, scope: SQLiteRowRange) {
assert(range != nil)
if let r = AppDB?.dnsLogsRowRange(between: ts1, and: ts2, within: scope) {
front ? (range!.start = r.end + 1) : (range!.end = r.start - 1)
if range!.start > range!.end { range = nil }
notifyObservers { $0.syncUpdate(self, remove: r) }
}
}
// MARK: - Observer List
private var observers: [WeakObserver] = []
/// Add `delegate` to observer list and immediatelly call `syncUpdate(reset:)` (on background thread).
func addObserver(_ delegate: SyncUpdateDelegate) {
observers.removeAll { $0.target == nil }
observers.append(.init(target: delegate))
DispatchQueue.global().async {
delegate.syncUpdate(self, reset: self.rows)
}
}
/// - Warning: Always call from a background thread!
private func notifyObservers(_ block: (SyncUpdateDelegate) -> Void) {
assert(!Thread.isMainThread)
self.pause()
for o in observers where o.target != nil { block(o.target!) }
self.continue()
}
}
/// Wrapper class for `SyncUpdateDelegate` that supports weak references
private struct WeakObserver {
weak var target: SyncUpdateDelegate?
weak var pullToRefresh: UIRefreshControl?
}
protocol SyncUpdateDelegate : AnyObject {
/// `SyncUpdate` has unpredictable changes. Reload your `dataSource`.
/// - Warning: This function will **always** be called from a background thread.
func syncUpdate(_ sender: SyncUpdate, reset rows: SQLiteRowRange)
/// `SyncUpdate` added new `rows` to database. Sync changes to your `dataSource`.
/// - Warning: This function will **always** be called from a background thread.
func syncUpdate(_ sender: SyncUpdate, insert rows: SQLiteRowRange)
/// `SyncUpdate` outdated some `rows` in database. Sync changes to your `dataSource`.
/// - Warning: This function will **always** be called from a background thread.
func syncUpdate(_ sender: SyncUpdate, remove rows: SQLiteRowRange)
/// Background process did delete some entries in database that match `affectedDomain`.
/// Update or remove entries from your `dataSource`.
/// - Warning: This function will **always** be called from a background thread.
func syncUpdate(_ sender: SyncUpdate, partialRemove affectedDomain: String)
}
// MARK: - Pull-To-Refresh
@available(iOS 10.0, *)
extension SyncUpdate {
/// Add Pull-To-Refresh control to `tableViewController`. On action notify `observer.syncUpdate(reset:)`
/// - Warning: Must be called after `addObserver()` such that `observer` exists in list of observers.
func allowPullToRefresh(onTVC tableViewController: UITableViewController?, forObserver: SyncUpdateDelegate) {
guard let i = observers.firstIndex(where: { $0.target === forObserver }) else {
assertionFailure("You must add the observer before enabling Pull-To-Refresh!")
return
}
// remove previous
observers[i].pullToRefresh?.removeTarget(self, action: #selector(pullToRefresh), for: .valueChanged)
observers[i].pullToRefresh = nil
if let tvc = tableViewController {
let rc = UIRefreshControl()
rc.addTarget(self, action: #selector(pullToRefresh), for: .valueChanged)
tvc.tableView.refreshControl = rc
observers[i].pullToRefresh = rc
}
}
/// Pull-To-Refresh callback method. Find observer with corresponding `RefreshControl` and notify `syncUpdate(reset:)`
@objc private func pullToRefresh(sender: UIRefreshControl) {
guard let x = observers.first(where: { $0.pullToRefresh === sender }) else {
assertionFailure("Should never happen. RefreshControl removed from table view while keeping it active somewhere else.")
return
}
syncNow {
x.target?.syncUpdate(self, reset: self.rows)
DispatchQueue.main.sync {
sender.endRefreshing()
}
}
}
}