Skip to content

Commit

Permalink
feat(searchbox): query debouncing (#297)
Browse files Browse the repository at this point in the history
  • Loading branch information
VladislavFitz authored Aug 16, 2023
1 parent a4d5223 commit 0c3a7fd
Show file tree
Hide file tree
Showing 8 changed files with 115 additions and 29 deletions.
2 changes: 1 addition & 1 deletion Cartfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
github "apple/swift-log" ~> 1.4
github "algolia/algoliasearch-client-swift" ~> 8.17
github "algolia/algoliasearch-client-swift" ~> 8.18
github "algolia/instantsearch-telemetry-native" ~> 0.1.3
2 changes: 1 addition & 1 deletion Examples/Examples/DemoListViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ final class DemoListViewController<Demo: DemoProtocol & Codable>: UITableViewCon
navigationItem.searchController = searchController
tableView.register(UITableViewCell.self, forCellReuseIdentifier: cellIdentifier)
hitsInteractor.onResultsUpdated.subscribe(with: self) { viewController, results in
let demos = (try? results.extractHits() as [Demo]) ?? []
let demos = (try? results.extractHits(jsonDecoder: JSONDecoder()) as [Demo]) ?? []
viewController.updateDemos(demos)
}
}
Expand Down
4 changes: 2 additions & 2 deletions InstantSearch.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Pod::Spec.new do |s|

s.subspec "Insights" do |ss|
ss.source_files = 'Sources/InstantSearchInsights/**/*.{swift}'
ss.dependency 'AlgoliaSearchClient', '~> 8.17'
ss.dependency 'AlgoliaSearchClient', '~> 8.18'
ss.ios.deployment_target = '9.0'
ss.osx.deployment_target = '10.10'
ss.watchos.deployment_target = '2.0'
Expand All @@ -24,7 +24,7 @@ Pod::Spec.new do |s|

s.subspec "Core" do |ss|
ss.source_files = 'Sources/InstantSearchCore/**/*.{swift}'
ss.dependency 'AlgoliaSearchClient', '~> 8.17'
ss.dependency 'AlgoliaSearchClient', '~> 8.18'
ss.dependency 'InstantSearch/Insights'
ss.dependency 'InstantSearchTelemetry', '~> 0.1.3'
ss.ios.deployment_target = '9.0'
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ let package = Package(
dependencies: [
.package(name: "AlgoliaSearchClient",
url: "https://github.com/algolia/algoliasearch-client-swift",
from: "8.17.0"),
from: "8.18.2"),
.package(name: "InstantSearchTelemetry",
url: "https://github.com/algolia/instantsearch-telemetry-native",
from: "0.1.3")
Expand Down
64 changes: 64 additions & 0 deletions Sources/InstantSearchCore/Common/Debouncer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import Foundation

/// `Debouncer` helps in reducing the frequency of execution for a given set of operations.
/// It ensures that a function does not get called until after a certain amount of time has passed
/// since the last time it was called. This is especially useful for actions that get triggered often,
/// like UI input events, to avoid unnecessary work and improve performance.
///
/// Type parameter `T` is the type of the value you're monitoring. It should be `Equatable` to check
/// for changes.
///
/// Usage:
///
/// ```swift
/// let debouncer = Debouncer<String>(delay: 0.5)
/// searchBar.onChange = { searchText in
/// debouncer.debounce(value: searchText) { debouncedSearchText in
/// // perform an action with debouncedSearchText
/// }
/// }
/// ```
class Debouncer<T: Equatable> {
/// The delay, in seconds, after which the action should be taken.
private let delay: TimeInterval
/// Keeps track of the last value passed to the `debounce` function.
private var lastValue: T?
/// The work item that's scheduled to run after the debounce delay.
private var workItem: DispatchWorkItem?
/// The execute queue of the completion block.
private var queue: DispatchQueue

/// Initializes a new debouncer with the given delay.
///
/// - Parameter delay: The amount of time to wait before executing the action.
/// - Parameter queue: The execute queue of the completion block.
init(delay: TimeInterval,
queue: DispatchQueue = .main) {
self.delay = delay
self.queue = queue
}

/// Debounces the execution of a function.
///
/// If this method is called again with the same value before the delay has elapsed,
/// the previously scheduled work will be canceled, effectively "debouncing" the calls.
///
/// - Parameters:
/// - value: The new value to check against the previous one.
/// - completion: The completion block to execute after the delay, if the value has changed.
func debounce(value: T, completion: @escaping (T) -> Void) {
// Cancel the previously scheduled work item if it hasn't run yet
workItem?.cancel()
// If the new value is different from the last one, schedule the completion block to run after the delay
if lastValue != value {
lastValue = value

workItem = DispatchWorkItem { [weak self] in
self?.lastValue = nil
completion(value)
}

DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem!)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,32 @@ public extension SearchBoxInteractor {
/// Controller interfacing with a concrete query input view
public let controller: Controller

/// Debouncer that manages the debounce delay for successive query inputs to optimize performance.
private let queryDebouncer: Debouncer<String?>

/**
- Parameters:
- interactor: Business logic component that handles textual query input
- controller: Controller interfacing with a concrete query input view
- interactor: Business logic component that handles textual query input
- controller: Controller interfacing with a concrete query input view
- debounceInterval: The delay (in seconds) after which the query input is processed, allowing for debounced input. Default value is 100ms (0.1 seconds).
*/
public init(interactor: SearchBoxInteractor,
controller: Controller) {
controller: Controller,
debounceInterval: TimeInterval = 0.1) {
self.interactor = interactor
self.controller = controller
self.queryDebouncer = Debouncer(delay: debounceInterval)
}

public func connect() {
interactor.onQueryChanged.subscribePast(with: controller) { controller, query in
controller.setQuery(query)
}.onQueue(.main)

controller.onQueryChanged = { [weak interactor] in
interactor?.query = $0
controller.onQueryChanged = { [weak interactor, queryDebouncer] query in
queryDebouncer.debounce(value: query) { query in
interactor?.query = query
}
}

controller.onQuerySubmitted = { [weak interactor] in
Expand All @@ -52,10 +60,10 @@ public extension SearchBoxInteractor {

public extension SearchBoxInteractor {
/**
Establishes a connection with a controller
- Parameters:
- controller: Controller interfacing with a concrete query input view
- Returns: Established connection
Establishes a connection with a controller
- Parameters:
- controller: Controller interfacing with a concrete query input view
- Returns: Established connection
*/
@discardableResult func connectController<Controller: SearchBoxController>(_ controller: Controller) -> ControllerConnection<Controller> {
let connection = ControllerConnection(interactor: self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,32 @@ import InstantSearchTelemetry
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public class SearchBoxObservableController: ObservableObject, SearchBoxController {
/// Textual query
@Published public var query: String {
didSet {
onQueryChanged?(query)
}
}
@Published public var query: String

public var onQueryChanged: ((String?) -> Void)?

public var onQuerySubmitted: ((String?) -> Void)?

private var querySubscription: AnyCancellable?

public func setQuery(_ query: String?) {
guard query != self.query else { return }
self.query = query ?? ""
}

public init(query: String = "") {
self.query = query
InstantSearchTelemetry.shared.traceDeclarative(type: .searchBox)
querySubscription = $query.removeDuplicates().sink { [weak self] value in
self?.onQueryChanged?(value)
}
}

/// Trigger query submit event
public func submit() {
onQuerySubmitted?(query)
}

}

/// QueryInputController implementation adapted for usage with SwiftUI views
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ class SearchBoxControllerConnectionTests: XCTestCase {
let presetQuery = "q1"
interactor.query = presetQuery

let connection = SearchBoxInteractor.ControllerConnection(interactor: interactor, controller: controller)
let connection = SearchBoxInteractor.ControllerConnection(interactor: interactor,
controller: controller,
debounceInterval: 0)

connection.connect()

Expand Down Expand Up @@ -99,23 +101,32 @@ class SearchBoxControllerConnectionTester {
XCTAssertEqual(controller.query, presetQuery, file: file, line: line)

controller.query = "q2"

if isConnected {
XCTAssertEqual(interactor.query, "q2", file: file, line: line)
} else {
XCTAssertNil(interactor.query, file: file, line: line)

let queryChangedExpectation = source.expectation(description: "query changed")
queryChangedExpectation.isInverted = !isConnected
let subscription = interactor.onQueryChanged.subscribe(with: self) { _, query in
if isConnected {
XCTAssertEqual(query, "q2", file: file, line: line)
} else {
XCTAssertNil(query, file: file, line: line)
}
queryChangedExpectation.fulfill()
}

source.waitForExpectations(timeout: 2)
subscription.cancel()

controller.query = "q3"

let querySubmittedExpectation = source.expectation(description: "query submitted")
querySubmittedExpectation.isInverted = !isConnected

interactor.onQuerySubmitted.subscribe(with: self) { _, query in
XCTAssertEqual(query, "q3", file: file, line: line)
if isConnected {
XCTAssertEqual(query, "q3", file: file, line: line)
} else {
XCTAssertNil(query, file: file, line: line)
}
querySubmittedExpectation.fulfill()
}

controller.submitQuery()

source.waitForExpectations(timeout: 2, handler: nil)
Expand Down

0 comments on commit 0c3a7fd

Please sign in to comment.