diff --git a/Sources/Action.swift b/Sources/Action.swift index 0fc7132af..1e047816e 100644 --- a/Sources/Action.swift +++ b/Sources/Action.swift @@ -12,7 +12,7 @@ import enum Result.NoError public final class Action { private let deinitToken: Lifetime.Token - private let executeClosure: (Input) -> SignalProducer + private let executeClosure: (_ state: Any, _ input: Input) -> SignalProducer private let eventsObserver: Signal, NoError>.Observer private let disabledErrorsObserver: Signal<(), NoError>.Observer @@ -49,43 +49,37 @@ public final class Action { /// Whether the action is currently executing. public let isExecuting: Property - private let _isExecuting: MutableProperty = MutableProperty(false) - /// Whether the action is currently enabled. - public var isEnabled: Property - - private let _isEnabled: MutableProperty = MutableProperty(false) - - /// Whether the instantiator of this action wants it to be enabled. - private let isUserEnabled: Property - - /// This queue is used for read-modify-write operations on the `_executing` - /// property. - private let executingQueue = DispatchQueue( - label: "org.reactivecocoa.ReactiveSwift.Action.executingQueue", - attributes: [] - ) + public let isEnabled: Property - /// Whether the action should be enabled for the given combination of user - /// enabledness and executing status. - private static func shouldBeEnabled(userEnabled: Bool, executing: Bool) -> Bool { - return userEnabled && !executing - } + private let state: MutableProperty - /// Initializes an action that will be conditionally enabled, and creates a - /// SignalProducer for each input. + /// Initializes an action that will be conditionally enabled based on the + /// value of `state`. Creates a `SignalProducer` for each input and the + /// current value of `state`. + /// + /// - note: `Action` guarantees that changes to `state` are observed in a + /// thread-safe way. Thus, the value passed to `isEnabled` will + /// always be identical to the value passed to `execute`, for each + /// application of the action. + /// + /// - note: This initializer should only be used if you need to provide + /// custom input can also influence whether the action is enabled. + /// The various convenience initializers should cover most use cases. /// /// - parameters: - /// - enabledIf: Boolean property that shows whether the action is - /// enabled. - /// - execute: A closure that returns the signal producer returned by - /// calling `apply(Input)` on the action. - public init(enabledIf property: P, _ execute: @escaping (Input) -> SignalProducer) where P.Value == Bool { + /// - state: A property that provides the current state of the action + /// whenever `apply()` is called. + /// - enabledIf: A predicate that, given the current value of `state`, + /// returns whether the action should be enabled. + /// - execute: A closure that returns the `SignalProducer` returned by + /// calling `apply(Input)` on the action, optionally using + /// the current value of `state`. + public init(state property: State, enabledIf isEnabled: @escaping (State.Value) -> Bool, _ execute: @escaping (State.Value, Input) -> SignalProducer) { deinitToken = Lifetime.Token() lifetime = Lifetime(deinitToken) - executeClosure = execute - isUserEnabled = Property(property) + executeClosure = { state, input in execute(state as! State.Value, input) } (events, eventsObserver) = Signal, NoError>.pipe() (disabledErrors, disabledErrorsObserver) = Signal<(), NoError>.pipe() @@ -94,12 +88,33 @@ public final class Action { errors = events.map { $0.error }.skipNil() completed = events.filter { $0.isCompleted }.map { _ in } - isEnabled = Property(_isEnabled) - isExecuting = Property(_isExecuting) + let initial = ActionState(value: property.value, isEnabled: { isEnabled($0 as! State.Value) }) + state = MutableProperty(initial) - _isEnabled <~ property.producer - .combineLatest(with: isExecuting.producer) - .map(Action.shouldBeEnabled) + property.signal + .take(during: state.lifetime) + .observeValues { [weak state] newValue in + state?.modify { + $0.value = newValue + } + } + + self.isEnabled = state.map { $0.isEnabled } + self.isExecuting = state.map { $0.isExecuting } + } + + /// Initializes an action that will be conditionally enabled, and creates a + /// `SignalProducer` for each input. + /// + /// - parameters: + /// - enabledIf: Boolean property that shows whether the action is + /// enabled. + /// - execute: A closure that returns the signal producer returned by + /// calling `apply(Input)` on the action. + public convenience init(enabledIf property: P, _ execute: @escaping (Input) -> SignalProducer) where P.Value == Bool { + self.init(state: property, enabledIf: { $0 }) { _, input in + execute(input) + } } /// Initializes an action that will be enabled by default, and creates a @@ -130,22 +145,22 @@ public final class Action { /// producer. public func apply(_ input: Input) -> SignalProducer> { return SignalProducer { observer, disposable in - var startedExecuting = false - - self.executingQueue.sync { - if self._isEnabled.value { - self._isExecuting.value = true - startedExecuting = true + let startingState = self.state.modify { state -> Any? in + if state.isEnabled { + state.isExecuting = true + return state.value + } else { + return nil } } - if !startedExecuting { + guard let state = startingState else { observer.send(error: .disabled) self.disabledErrorsObserver.send(value: ()) return } - self.executeClosure(input).startWithSignal { signal, signalDisposable in + self.executeClosure(state, input).startWithSignal { signal, signalDisposable in disposable += signalDisposable signal.observe { event in @@ -155,12 +170,39 @@ public final class Action { } disposable += { - self._isExecuting.value = false + self.state.modify { + $0.isExecuting = false + } } } } } +private struct ActionState { + var isExecuting: Bool = false + + var value: Any { + didSet { + userEnabled = userEnabledClosure(value) + } + } + + private var userEnabled: Bool + private let userEnabledClosure: (Any) -> Bool + + init(value: Any, isEnabled: @escaping (Any) -> Bool) { + self.value = value + self.userEnabled = isEnabled(value) + self.userEnabledClosure = isEnabled + } + + /// Whether the action should be enabled for the given combination of user + /// enabledness and executing status. + fileprivate var isEnabled: Bool { + return userEnabled && !isExecuting + } +} + public protocol ActionProtocol: BindingTargetProtocol { /// The type of argument to apply the action to. associatedtype Input @@ -170,6 +212,29 @@ public protocol ActionProtocol: BindingTargetProtocol { /// `NoError` can be used. associatedtype Error: Swift.Error + /// Initializes an action that will be conditionally enabled based on the + /// value of `state`. Creates a `SignalProducer` for each input and the + /// current value of `state`. + /// + /// - note: `Action` guarantees that changes to `state` are observed in a + /// thread-safe way. Thus, the value passed to `isEnabled` will + /// always be identical to the value passed to `execute`, for each + /// application of the action. + /// + /// - note: This initializer should only be used if you need to provide + /// custom input can also influence whether the action is enabled. + /// The various convenience initializers should cover most use cases. + /// + /// - parameters: + /// - state: A property that provides the current state of the action + /// whenever `apply()` is called. + /// - enabledIf: A predicate that, given the current value of `state`, + /// returns whether the action should be enabled. + /// - execute: A closure that returns the `SignalProducer` returned by + /// calling `apply(Input)` on the action, optionally using + /// the current value of `state`. + init(state property: State, enabledIf isEnabled: @escaping (State.Value) -> Bool, _ execute: @escaping (State.Value, Input) -> SignalProducer) + /// Whether the action is currently enabled. var isEnabled: Property { get } @@ -202,6 +267,36 @@ extension Action: ActionProtocol { } } +extension ActionProtocol where Input == Void { + /// Initializes an action that uses an `Optional` property for its input, + /// and is disabled whenever the input is `nil`. When executed, a `SignalProducer` + /// is created with the current value of the input. + /// + /// - parameters: + /// - input: An `Optional` property whose current value is used as input + /// whenever the action is executed. The action is disabled + /// whenever the value is `nil`. + /// - execute: A closure to return a new `SignalProducer` based on the + /// current value of `input`. + public init(input: P, _ execute: @escaping (T) -> SignalProducer) where P.Value == T? { + self.init(state: input, enabledIf: { $0 != nil }) { input, _ in + execute(input!) + } + } + + /// Initializes an action that uses a property for its input. When executed, + /// a `SignalProducer` is created with the current value of the input. + /// + /// - parameters: + /// - input: A property whose current value is used as input + /// whenever the action is executed. + /// - execute: A closure to return a new `SignalProducer` based on the + /// current value of `input`. + public init(input: P, _ execute: @escaping (T) -> SignalProducer) where P.Value == T { + self.init(input: input.map(Optional.some), execute) + } +} + /// The type of error that can occur from Action.apply, where `Error` is the /// type of error that can be generated by the specific Action instance. public enum ActionError: Swift.Error { diff --git a/Tests/ReactiveSwiftTests/ActionSpec.swift b/Tests/ReactiveSwiftTests/ActionSpec.swift index 722640fa5..652924709 100755 --- a/Tests/ReactiveSwiftTests/ActionSpec.swift +++ b/Tests/ReactiveSwiftTests/ActionSpec.swift @@ -222,5 +222,35 @@ class ActionSpec: QuickSpec { } } } + + describe("using a property as input") { + let echo: (Int) -> SignalProducer = SignalProducer.init(value:) + + it("executes the action with the property's current value") { + let input = MutableProperty(0) + let action = Action(input: input, echo) + + var values: [Int] = [] + action.values.observeValues { values.append($0) } + + input.value = 1 + action.apply().start() + input.value = 2 + action.apply().start() + input.value = 3 + action.apply().start() + + expect(values) == [1, 2, 3] + } + + it("is disabled if the property is nil") { + let input = MutableProperty(1) + let action = Action(input: input, echo) + + expect(action.isEnabled.value) == true + input.value = nil + expect(action.isEnabled.value) == false + } + } } }