Skip to content

Commit f8c7b1c

Browse files
authored
Merge pull request #15 from sideeffect-io/feature/debounced-binding
supporting: add debounce and distinct ability to Binding
2 parents ae6c210 + d742706 commit f8c7b1c

8 files changed

Lines changed: 213 additions & 7 deletions

File tree

Sources/AsyncStateMachine/AsyncStateMachine.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77

88
public final class AsyncStateMachine<S, E, O>: AsyncSequence, Sendable
99
where S: DSLCompatible & Sendable, E: DSLCompatible & Sendable, O: DSLCompatible {
10+
public typealias StateSequence =
11+
AsyncOnEachSequence<AsyncSerialSequence<AsyncCompactScanSequence<AsyncOnEachSequence<AsyncBufferedChannel<E>>, S>>>
1012
public typealias Element = S
11-
public typealias AsyncIterator =
12-
AsyncOnEachSequence<AsyncSerialSequence<AsyncCompactScanSequence<AsyncOnEachSequence<AsyncBufferedChannel<E>>, S>>>.Iterator
13+
public typealias AsyncIterator = StateSequence.Iterator
1314

1415
let initialState: S
1516
let eventChannel: AsyncBufferedChannel<E>
16-
let stateSequence: AsyncOnEachSequence<AsyncSerialSequence<AsyncCompactScanSequence<AsyncOnEachSequence<AsyncBufferedChannel<E>>, S>>>
17+
let stateSequence: StateSequence
1718
let deinitBlock: @Sendable () -> Void
1819

1920
public convenience init(

Sources/Supporting/AsyncBufferedChannel.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,13 @@ where Element: Sendable {
106106
}
107107
}
108108

109-
@Sendable func send(_ element: Element) {
109+
@Sendable
110+
func send(_ element: Element) {
110111
self.send(.element(element))
111112
}
112113

113-
@Sendable func finish() {
114+
@Sendable
115+
func finish() {
114116
self.send(.termination)
115117
}
116118

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//
2+
// Binding+Debounce.swift
3+
//
4+
//
5+
// Created by Thibault Wittemberg on 20/08/2022.
6+
//
7+
8+
extension DispatchTimeInterval {
9+
var nanoseconds: UInt64 {
10+
switch self {
11+
case .nanoseconds(let value): return UInt64(value)
12+
case .microseconds(let value): return UInt64(value) * 1000
13+
case .milliseconds(let value): return UInt64(value) * 1_000_000
14+
case .seconds(let value): return UInt64(value) * 1_000_000_000
15+
case .never: return .zero
16+
@unknown default: return .zero
17+
}
18+
}
19+
}
20+
21+
#if canImport(SwiftUI)
22+
import SwiftUI
23+
24+
extension Binding {
25+
struct DueValue {
26+
let value: Value
27+
let dueTime: DispatchTime
28+
}
29+
30+
public func debounce(for dueTime: DispatchTimeInterval) -> Self {
31+
let lastKnownValue = ManagedCriticalState<DueValue?>(nil)
32+
let debounceInProgress = ManagedCriticalState<Bool>(false)
33+
34+
return Binding {
35+
self.wrappedValue
36+
} set: { value in
37+
if debounceInProgress.criticalState {
38+
let newValue = DueValue(value: value, dueTime: DispatchTime.now().advanced(by: dueTime))
39+
lastKnownValue.apply(criticalState: newValue)
40+
} else {
41+
debounceInProgress.apply(criticalState: true)
42+
Task {
43+
var timeToSleep = dueTime.nanoseconds
44+
var currentValue = value
45+
46+
repeat {
47+
lastKnownValue.apply(criticalState: nil)
48+
49+
try? await Task.sleep(nanoseconds: timeToSleep)
50+
51+
if let lastKnownValue = lastKnownValue.criticalState {
52+
timeToSleep = DispatchTime.now().distance(to: lastKnownValue.dueTime).nanoseconds
53+
currentValue = lastKnownValue.value
54+
}
55+
} while lastKnownValue.criticalState != nil
56+
debounceInProgress.apply(criticalState: false)
57+
self.wrappedValue = currentValue
58+
}
59+
}
60+
}
61+
}
62+
}
63+
#endif
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// Binding+Distinct.swift
3+
//
4+
//
5+
// Created by Thibault Wittemberg on 21/08/2022.
6+
//
7+
8+
#if canImport(SwiftUI)
9+
import SwiftUI
10+
11+
public extension Binding where Value: Equatable {
12+
func distinct() -> Self {
13+
return Binding {
14+
self.wrappedValue
15+
} set: { value in
16+
guard value != self.wrappedValue else { return }
17+
self.wrappedValue = value
18+
}
19+
}
20+
}
21+
#endif

Sources/Supporting/Inject.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Created by Thibault WITTEMBERG on 25/06/2022.
66
//
77

8-
// swiftlint:disable identifier_name void_return function_parameter_count
8+
// swiftlint:disable identifier_name function_parameter_count
99
public func inject<A, R>(
1010
dep a: A,
1111
in block: @Sendable @escaping (A) async -> R

Sources/Supporting/ThrowingInject.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// Created by Thibault Wittemberg on 18/08/2022.
66
//
77

8-
// swiftlint:disable identifier_name void_return function_parameter_count
8+
// swiftlint:disable identifier_name function_parameter_count
99
public func inject<A, R>(
1010
dep a: A,
1111
in block: @Sendable @escaping (A) async throws -> R
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//
2+
// Binding+DebounceTests.swift
3+
//
4+
//
5+
// Created by Thibault Wittemberg on 21/08/2022.
6+
//
7+
8+
#if canImport(SwiftUI)
9+
import SwiftUI
10+
@testable import AsyncStateMachine
11+
import XCTest
12+
13+
final class Binding_DebounceTests: XCTestCase {
14+
func test_debounce_filters_out_values_according_to_dueTime() {
15+
let lastValueSet = expectation(description: "The last value has been set in the binding")
16+
17+
// | 500 1000 1500 2000 2500 |
18+
// | | | | | | | | | | | | |
19+
// timeline 0 1 2 3 4567 8
20+
// debounced 2 3 7 8
21+
22+
let events = [
23+
(0, DispatchTimeInterval.milliseconds(0)),
24+
(1, DispatchTimeInterval.milliseconds(250)),
25+
(2, DispatchTimeInterval.milliseconds(500)),
26+
(3, DispatchTimeInterval.milliseconds(1_000)),
27+
(4, DispatchTimeInterval.milliseconds(2_000)),
28+
(5, DispatchTimeInterval.milliseconds(2_025)),
29+
(6, DispatchTimeInterval.milliseconds(2_050)),
30+
(7, DispatchTimeInterval.milliseconds(2_075)),
31+
(8, DispatchTimeInterval.milliseconds(2_750))
32+
]
33+
34+
var received = [Int]()
35+
36+
// Given
37+
let sut = Binding {
38+
1
39+
} set: { value in
40+
received.append(value)
41+
if value == 8 {
42+
lastValueSet.fulfill()
43+
}
44+
}.debounce(for: .milliseconds(300))
45+
46+
let now = DispatchTime.now()
47+
48+
// When
49+
for event in events {
50+
DispatchQueue.global().asyncAfter(deadline: now.advanced(by: event.1)) {
51+
sut.wrappedValue = event.0
52+
}
53+
}
54+
55+
wait(for: [lastValueSet], timeout: 10)
56+
57+
// Then
58+
XCTAssertEqual(received, [2, 3, 7, 8])
59+
}
60+
61+
func test_nanoseconds_converts_values() {
62+
// Given
63+
let expected = [
64+
(DispatchTimeInterval.never, UInt64(0)),
65+
(DispatchTimeInterval.seconds(2), 2_000_000_000),
66+
(DispatchTimeInterval.milliseconds(3), 3_000_000),
67+
(DispatchTimeInterval.microseconds(6), 6_000),
68+
(DispatchTimeInterval.nanoseconds(9), 9)
69+
]
70+
71+
for expect in expected {
72+
// When
73+
// Then
74+
XCTAssertEqual(expect.0.nanoseconds, expect.1)
75+
}
76+
}
77+
}
78+
#endif
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// Binding+DistinctTests.swift
3+
//
4+
//
5+
// Created by Thibault Wittemberg on 21/08/2022.
6+
//
7+
8+
#if canImport(SwiftUI)
9+
import SwiftUI
10+
@testable import AsyncStateMachine
11+
import XCTest
12+
13+
final class Binding_DistinctTests: XCTestCase {
14+
func test_distinct_calls_sets_when_input_is_different() {
15+
var value = 1
16+
var setHasBeenCalled = false
17+
18+
// Given
19+
let sut = Binding<Int> {
20+
value
21+
} set: { newValue in
22+
setHasBeenCalled = true
23+
value = newValue
24+
}.distinct()
25+
26+
// When
27+
sut.wrappedValue = 1
28+
29+
// Then
30+
XCTAssertEqual(value, 1)
31+
XCTAssertFalse(setHasBeenCalled)
32+
33+
// When
34+
sut.wrappedValue = 2
35+
36+
// Then
37+
XCTAssertEqual(value, 2)
38+
XCTAssertTrue(setHasBeenCalled)
39+
}
40+
}
41+
#endif

0 commit comments

Comments
 (0)