Skip to content

Commit d4fb070

Browse files
authored
Merge pull request #22 from ReactiveCocoa/as-action-input-property
Add initialisers to create an Action from an input property
2 parents f66e647 + cda3ed4 commit d4fb070

File tree

2 files changed

+169
-44
lines changed

2 files changed

+169
-44
lines changed

Sources/Action.swift

Lines changed: 139 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import enum Result.NoError
1212
public final class Action<Input, Output, Error: Swift.Error> {
1313
private let deinitToken: Lifetime.Token
1414

15-
private let executeClosure: (Input) -> SignalProducer<Output, Error>
15+
private let executeClosure: (_ state: Any, _ input: Input) -> SignalProducer<Output, Error>
1616
private let eventsObserver: Signal<Event<Output, Error>, NoError>.Observer
1717
private let disabledErrorsObserver: Signal<(), NoError>.Observer
1818

@@ -49,43 +49,37 @@ public final class Action<Input, Output, Error: Swift.Error> {
4949
/// Whether the action is currently executing.
5050
public let isExecuting: Property<Bool>
5151

52-
private let _isExecuting: MutableProperty<Bool> = MutableProperty(false)
53-
5452
/// Whether the action is currently enabled.
55-
public var isEnabled: Property<Bool>
56-
57-
private let _isEnabled: MutableProperty<Bool> = MutableProperty(false)
58-
59-
/// Whether the instantiator of this action wants it to be enabled.
60-
private let isUserEnabled: Property<Bool>
61-
62-
/// This queue is used for read-modify-write operations on the `_executing`
63-
/// property.
64-
private let executingQueue = DispatchQueue(
65-
label: "org.reactivecocoa.ReactiveSwift.Action.executingQueue",
66-
attributes: []
67-
)
53+
public let isEnabled: Property<Bool>
6854

69-
/// Whether the action should be enabled for the given combination of user
70-
/// enabledness and executing status.
71-
private static func shouldBeEnabled(userEnabled: Bool, executing: Bool) -> Bool {
72-
return userEnabled && !executing
73-
}
55+
private let state: MutableProperty<ActionState>
7456

75-
/// Initializes an action that will be conditionally enabled, and creates a
76-
/// SignalProducer for each input.
57+
/// Initializes an action that will be conditionally enabled based on the
58+
/// value of `state`. Creates a `SignalProducer` for each input and the
59+
/// current value of `state`.
60+
///
61+
/// - note: `Action` guarantees that changes to `state` are observed in a
62+
/// thread-safe way. Thus, the value passed to `isEnabled` will
63+
/// always be identical to the value passed to `execute`, for each
64+
/// application of the action.
65+
///
66+
/// - note: This initializer should only be used if you need to provide
67+
/// custom input can also influence whether the action is enabled.
68+
/// The various convenience initializers should cover most use cases.
7769
///
7870
/// - parameters:
79-
/// - enabledIf: Boolean property that shows whether the action is
80-
/// enabled.
81-
/// - execute: A closure that returns the signal producer returned by
82-
/// calling `apply(Input)` on the action.
83-
public init<P: PropertyProtocol>(enabledIf property: P, _ execute: @escaping (Input) -> SignalProducer<Output, Error>) where P.Value == Bool {
71+
/// - state: A property that provides the current state of the action
72+
/// whenever `apply()` is called.
73+
/// - enabledIf: A predicate that, given the current value of `state`,
74+
/// returns whether the action should be enabled.
75+
/// - execute: A closure that returns the `SignalProducer` returned by
76+
/// calling `apply(Input)` on the action, optionally using
77+
/// the current value of `state`.
78+
public init<State: PropertyProtocol>(state property: State, enabledIf isEnabled: @escaping (State.Value) -> Bool, _ execute: @escaping (State.Value, Input) -> SignalProducer<Output, Error>) {
8479
deinitToken = Lifetime.Token()
8580
lifetime = Lifetime(deinitToken)
8681

87-
executeClosure = execute
88-
isUserEnabled = Property(property)
82+
executeClosure = { state, input in execute(state as! State.Value, input) }
8983

9084
(events, eventsObserver) = Signal<Event<Output, Error>, NoError>.pipe()
9185
(disabledErrors, disabledErrorsObserver) = Signal<(), NoError>.pipe()
@@ -94,12 +88,33 @@ public final class Action<Input, Output, Error: Swift.Error> {
9488
errors = events.map { $0.error }.skipNil()
9589
completed = events.filter { $0.isCompleted }.map { _ in }
9690

97-
isEnabled = Property(_isEnabled)
98-
isExecuting = Property(_isExecuting)
91+
let initial = ActionState(value: property.value, isEnabled: { isEnabled($0 as! State.Value) })
92+
state = MutableProperty(initial)
9993

100-
_isEnabled <~ property.producer
101-
.combineLatest(with: isExecuting.producer)
102-
.map(Action.shouldBeEnabled)
94+
property.signal
95+
.take(during: state.lifetime)
96+
.observeValues { [weak state] newValue in
97+
state?.modify {
98+
$0.value = newValue
99+
}
100+
}
101+
102+
self.isEnabled = state.map { $0.isEnabled }
103+
self.isExecuting = state.map { $0.isExecuting }
104+
}
105+
106+
/// Initializes an action that will be conditionally enabled, and creates a
107+
/// `SignalProducer` for each input.
108+
///
109+
/// - parameters:
110+
/// - enabledIf: Boolean property that shows whether the action is
111+
/// enabled.
112+
/// - execute: A closure that returns the signal producer returned by
113+
/// calling `apply(Input)` on the action.
114+
public convenience init<P: PropertyProtocol>(enabledIf property: P, _ execute: @escaping (Input) -> SignalProducer<Output, Error>) where P.Value == Bool {
115+
self.init(state: property, enabledIf: { $0 }) { _, input in
116+
execute(input)
117+
}
103118
}
104119

105120
/// Initializes an action that will be enabled by default, and creates a
@@ -130,22 +145,22 @@ public final class Action<Input, Output, Error: Swift.Error> {
130145
/// producer.
131146
public func apply(_ input: Input) -> SignalProducer<Output, ActionError<Error>> {
132147
return SignalProducer { observer, disposable in
133-
var startedExecuting = false
134-
135-
self.executingQueue.sync {
136-
if self._isEnabled.value {
137-
self._isExecuting.value = true
138-
startedExecuting = true
148+
let startingState = self.state.modify { state -> Any? in
149+
if state.isEnabled {
150+
state.isExecuting = true
151+
return state.value
152+
} else {
153+
return nil
139154
}
140155
}
141156

142-
if !startedExecuting {
157+
guard let state = startingState else {
143158
observer.send(error: .disabled)
144159
self.disabledErrorsObserver.send(value: ())
145160
return
146161
}
147162

148-
self.executeClosure(input).startWithSignal { signal, signalDisposable in
163+
self.executeClosure(state, input).startWithSignal { signal, signalDisposable in
149164
disposable += signalDisposable
150165

151166
signal.observe { event in
@@ -155,12 +170,39 @@ public final class Action<Input, Output, Error: Swift.Error> {
155170
}
156171

157172
disposable += {
158-
self._isExecuting.value = false
173+
self.state.modify {
174+
$0.isExecuting = false
175+
}
159176
}
160177
}
161178
}
162179
}
163180

181+
private struct ActionState {
182+
var isExecuting: Bool = false
183+
184+
var value: Any {
185+
didSet {
186+
userEnabled = userEnabledClosure(value)
187+
}
188+
}
189+
190+
private var userEnabled: Bool
191+
private let userEnabledClosure: (Any) -> Bool
192+
193+
init(value: Any, isEnabled: @escaping (Any) -> Bool) {
194+
self.value = value
195+
self.userEnabled = isEnabled(value)
196+
self.userEnabledClosure = isEnabled
197+
}
198+
199+
/// Whether the action should be enabled for the given combination of user
200+
/// enabledness and executing status.
201+
fileprivate var isEnabled: Bool {
202+
return userEnabled && !isExecuting
203+
}
204+
}
205+
164206
public protocol ActionProtocol: BindingTargetProtocol {
165207
/// The type of argument to apply the action to.
166208
associatedtype Input
@@ -170,6 +212,29 @@ public protocol ActionProtocol: BindingTargetProtocol {
170212
/// `NoError` can be used.
171213
associatedtype Error: Swift.Error
172214

215+
/// Initializes an action that will be conditionally enabled based on the
216+
/// value of `state`. Creates a `SignalProducer` for each input and the
217+
/// current value of `state`.
218+
///
219+
/// - note: `Action` guarantees that changes to `state` are observed in a
220+
/// thread-safe way. Thus, the value passed to `isEnabled` will
221+
/// always be identical to the value passed to `execute`, for each
222+
/// application of the action.
223+
///
224+
/// - note: This initializer should only be used if you need to provide
225+
/// custom input can also influence whether the action is enabled.
226+
/// The various convenience initializers should cover most use cases.
227+
///
228+
/// - parameters:
229+
/// - state: A property that provides the current state of the action
230+
/// whenever `apply()` is called.
231+
/// - enabledIf: A predicate that, given the current value of `state`,
232+
/// returns whether the action should be enabled.
233+
/// - execute: A closure that returns the `SignalProducer` returned by
234+
/// calling `apply(Input)` on the action, optionally using
235+
/// the current value of `state`.
236+
init<State: PropertyProtocol>(state property: State, enabledIf isEnabled: @escaping (State.Value) -> Bool, _ execute: @escaping (State.Value, Input) -> SignalProducer<Output, Error>)
237+
173238
/// Whether the action is currently enabled.
174239
var isEnabled: Property<Bool> { get }
175240

@@ -202,6 +267,36 @@ extension Action: ActionProtocol {
202267
}
203268
}
204269

270+
extension ActionProtocol where Input == Void {
271+
/// Initializes an action that uses an `Optional` property for its input,
272+
/// and is disabled whenever the input is `nil`. When executed, a `SignalProducer`
273+
/// is created with the current value of the input.
274+
///
275+
/// - parameters:
276+
/// - input: An `Optional` property whose current value is used as input
277+
/// whenever the action is executed. The action is disabled
278+
/// whenever the value is `nil`.
279+
/// - execute: A closure to return a new `SignalProducer` based on the
280+
/// current value of `input`.
281+
public init<P: PropertyProtocol, T>(input: P, _ execute: @escaping (T) -> SignalProducer<Output, Error>) where P.Value == T? {
282+
self.init(state: input, enabledIf: { $0 != nil }) { input, _ in
283+
execute(input!)
284+
}
285+
}
286+
287+
/// Initializes an action that uses a property for its input. When executed,
288+
/// a `SignalProducer` is created with the current value of the input.
289+
///
290+
/// - parameters:
291+
/// - input: A property whose current value is used as input
292+
/// whenever the action is executed.
293+
/// - execute: A closure to return a new `SignalProducer` based on the
294+
/// current value of `input`.
295+
public init<P: PropertyProtocol, T>(input: P, _ execute: @escaping (T) -> SignalProducer<Output, Error>) where P.Value == T {
296+
self.init(input: input.map(Optional.some), execute)
297+
}
298+
}
299+
205300
/// The type of error that can occur from Action.apply, where `Error` is the
206301
/// type of error that can be generated by the specific Action instance.
207302
public enum ActionError<Error: Swift.Error>: Swift.Error {

Tests/ReactiveSwiftTests/ActionSpec.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,5 +222,35 @@ class ActionSpec: QuickSpec {
222222
}
223223
}
224224
}
225+
226+
describe("using a property as input") {
227+
let echo: (Int) -> SignalProducer<Int, NoError> = SignalProducer.init(value:)
228+
229+
it("executes the action with the property's current value") {
230+
let input = MutableProperty(0)
231+
let action = Action(input: input, echo)
232+
233+
var values: [Int] = []
234+
action.values.observeValues { values.append($0) }
235+
236+
input.value = 1
237+
action.apply().start()
238+
input.value = 2
239+
action.apply().start()
240+
input.value = 3
241+
action.apply().start()
242+
243+
expect(values) == [1, 2, 3]
244+
}
245+
246+
it("is disabled if the property is nil") {
247+
let input = MutableProperty<Int?>(1)
248+
let action = Action(input: input, echo)
249+
250+
expect(action.isEnabled.value) == true
251+
input.value = nil
252+
expect(action.isEnabled.value) == false
253+
}
254+
}
225255
}
226256
}

0 commit comments

Comments
 (0)