Skip to content

Commit

Permalink
State machine as properties.
Browse files Browse the repository at this point in the history
  • Loading branch information
andersio committed Oct 19, 2017
1 parent 70596cf commit c9b8450
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 39 deletions.
22 changes: 7 additions & 15 deletions Example/PaginationViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,23 +89,15 @@ final class PaginationViewModel {
Feedbacks.retryPagingFeedback()
]
let initialState = State.initial
let stateProducer = SignalProducer.system(initial: initialState,
reduce: State.reduce,
feedbacks: feedbacks)
.observe(on: QueueScheduler.main)
let stateProperty = Property(initial: initialState,
reduce: State.reduce,
feedbacks: feedbacks)

let stateProperty = Property<State>(initial: initialState, then: stateProducer)
self.movies = Property<[Movie]>(initial: [],
then: stateProperty.producer.filterMap { $0.newMovies })

self.movies = Property<[Movie]>.init(initial: [], then: stateProperty.signal.filterMap {
$0.newMovies
})

self.errors = Property<NSError?>.init(initial: nil, then: stateProperty.producer.map {
$0.lastError
})
self.refreshing = stateProperty.map {
$0.isRefreshing
}
self.errors = stateProperty.map { $0.lastError }
self.refreshing = stateProperty.map { $0.isRefreshing }
self.nearBottomObserver = nearBottomObserver
self.retryObserver = retryObserver
}
Expand Down
10 changes: 5 additions & 5 deletions Example/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ViewController: UIViewController {
}

final class ViewModel {
private let state: Property<Int>
let counter: Property<String>

init(increment: Signal<Void, NoError>, decrement: Signal<Void, NoError>) {
Expand All @@ -56,12 +57,11 @@ final class ViewModel {
return decrement.map { _ in Event.decrement }
}

let state = SignalProducer.system(initial: 0,
reduce: IncrementReducer.reduce,
feedbacks: incrementFeedback, decrementFeedback)
.map(String.init)
self.state = Property(initial: 0,
reduce: IncrementReducer.reduce,
feedbacks: incrementFeedback, decrementFeedback)

self.counter = Property(initial: "", then: state)
self.counter = state.map(String.init)

}
}
Expand Down
8 changes: 8 additions & 0 deletions ReactiveFeedback.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
25E1D2381F56091A00D90192 /* PaginationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E1D2371F56091A00D90192 /* PaginationViewController.swift */; };
9A4CCB0B1F95D5CA00ACF758 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A4CCB0C1F95D5CA00ACF758 /* Nimble.framework */; };
9A4CCB0D1F95D5D500ACF758 /* Nimble.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 9A4CCB0C1F95D5CA00ACF758 /* Nimble.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
9AD5D42D1F97375E00E6AE5A /* Property+System.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD5D42C1F97375E00E6AE5A /* Property+System.swift */; };
9AD5D42F1F97450F00E6AE5A /* StateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD5D42E1F97450F00E6AE5A /* StateMachine.swift */; };
9AE181B91F95A71B00A07551 /* ReactiveFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AE181B81F95A71B00A07551 /* ReactiveFeedbackTests.swift */; };
9AE181BB1F95A71B00A07551 /* ReactiveFeedback.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 25CC87AE1F92855300A6EBFC /* ReactiveFeedback.framework */; };
9AE181C21F95A77500A07551 /* ReactiveSwift.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = 9AFA212A1F95135B001DBF7C /* ReactiveSwift.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
Expand Down Expand Up @@ -99,6 +101,8 @@
25E1D2351F54A20F00D90192 /* Feedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feedback.swift; sourceTree = "<group>"; };
25E1D2371F56091A00D90192 /* PaginationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaginationViewController.swift; sourceTree = "<group>"; };
9A4CCB0C1F95D5CA00ACF758 /* Nimble.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Nimble.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9AD5D42C1F97375E00E6AE5A /* Property+System.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Property+System.swift"; sourceTree = "<group>"; };
9AD5D42E1F97450F00E6AE5A /* StateMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateMachine.swift; sourceTree = "<group>"; };
9AE181B61F95A71B00A07551 /* ReactiveFeedbackTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReactiveFeedbackTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9AE181B81F95A71B00A07551 /* ReactiveFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactiveFeedbackTests.swift; sourceTree = "<group>"; };
9AE181BA1F95A71B00A07551 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -148,7 +152,9 @@
25CC87AF1F92855300A6EBFC /* ReactiveFeedback */ = {
isa = PBXGroup;
children = (
9AD5D42E1F97450F00E6AE5A /* StateMachine.swift */,
25E1D2331F54951100D90192 /* SignalProducer+System.swift */,
9AD5D42C1F97375E00E6AE5A /* Property+System.swift */,
25CC87B11F92855300A6EBFC /* Info.plist */,
25E1D2351F54A20F00D90192 /* Feedback.swift */,
);
Expand Down Expand Up @@ -358,7 +364,9 @@
buildActionMask = 2147483647;
files = (
25CC87BC1F92859800A6EBFC /* Feedback.swift in Sources */,
9AD5D42D1F97375E00E6AE5A /* Property+System.swift in Sources */,
25CC87BB1F92859800A6EBFC /* SignalProducer+System.swift in Sources */,
9AD5D42F1F97450F00E6AE5A /* StateMachine.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
24 changes: 24 additions & 0 deletions ReactiveFeedback/Property+System.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import ReactiveSwift

extension Property {
public convenience init<Event>(
initial: Value,
reduce: @escaping (Value, Event) -> Value,
feedbacks: [Feedback<Value, Event>]
) {
let state = MutableProperty(initial)
StateMachine.bootstrap(state: state.producer,
process: { [weak state] event in state?.modify { $0 = reduce($0, event) } },
feedbacks: feedbacks,
lifetime: state.lifetime)
self.init(capturing: state)
}

public convenience init<Event>(
initial: Value,
reduce: @escaping (Value, Event) -> Value,
feedbacks: Feedback<Value, Event>...
) {
self.init(initial: initial, reduce: reduce, feedbacks: feedbacks)
}
}
30 changes: 12 additions & 18 deletions ReactiveFeedback/SignalProducer+System.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,22 @@ import enum Result.NoError
extension SignalProducer where Error == NoError {
public static func system<Event>(
initial: Value,
scheduler: Scheduler = QueueScheduler.main,
reduce: @escaping (Value, Event) -> Value,
feedbacks: [Feedback<Value, Event>]
) -> SignalProducer<Value, NoError> {
return SignalProducer.deferred {
let (state, observer) = Signal<Value, NoError>.pipe()
return SignalProducer { observer, lifetime in
let (events, eventsObserver) = Signal<Event, NoError>.pipe()
lifetime += AnyDisposable(eventsObserver.sendInterrupted)

let events = feedbacks.map { feedback in
return feedback.events(scheduler, state)
}

return SignalProducer<Event, NoError>(Signal.merge(events))
.scan(initial, reduce)
.prefix(value: initial)
.on(value: observer.send(value:))
StateMachine.bootstrap(
state: SignalProducer<Event, NoError>(events)
.scan(initial, reduce)
.prefix(value: initial)
.on(value: observer.send(value:)),
process: eventsObserver.send(value:),
feedbacks: feedbacks,
lifetime: lifetime
)
}
}

Expand All @@ -31,10 +32,3 @@ extension SignalProducer where Error == NoError {
return system(initial: initial, reduce: reduce, feedbacks: feedbacks)
}
}

extension SignalProducerProtocol {
static func deferred(_ signalProducerFactory: @escaping () -> SignalProducer<Value, Error>) -> SignalProducer<Value, Error> {
return SignalProducer<Void, Error>(value: ())
.flatMap(.merge, signalProducerFactory)
}
}
73 changes: 73 additions & 0 deletions ReactiveFeedback/StateMachine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import ReactiveSwift
import Result

/// An `EventBuffer` manages the deferred processing of recursive events.
///
/// When a given event is processed by a reducer, a new state would be computed together
/// with new feedbacks, which could potentially yield a new event synchronously. Since
/// the ReactiveSwift primitives do not support value recursion, the state machine would
/// need to specifically cater for this scenario so as to be synchronous by default.
private struct EventBuffer<Event> {
var isTransitioning: Bool
var events: ContiguousArray<Event>

/// Dequeue the first event in the queue, or complete the transitioning if the queue
/// is empty.
mutating func completeOrDequeue() -> Event? {
assert(isTransitioning)
guard events.isEmpty
else { return events.removeFirst() }
isTransitioning = false
return nil
}
}

internal enum StateMachine {
internal static func bootstrap<State, Event>(
state: SignalProducer<State, NoError>,
process: @escaping (Event) -> Void,
feedbacks: [Feedback<State, Event>],
lifetime: Lifetime
) {
// Treat initialisation as an implicit event, and buffer any event yielded during
// the initialisation.
let buffer = Atomic(EventBuffer<Event>(isTransitioning: true, events: []))

state.startWithSignal { state, interruptHandle in
lifetime += interruptHandle

let events = feedbacks.map { feedback in
return feedback.events(ImmediateScheduler(), state)
}

lifetime += Signal.merge(events)
.observeValues { event in
let shouldProceed: Bool = buffer.modify { buffer in
guard buffer.isTransitioning else {
// This event is not recursively sent during the processing
// of another event.
buffer.isTransitioning = true
return true
}

buffer.events.append(event)
return false
}

guard shouldProceed else { return }

process(event)

// Drain any event recursively yielded during `process(event)` above.
while let event = buffer.modify({ $0.completeOrDequeue() }) {
process(event)
}
}
}

// Drain any event yielded by the initial state.
while let event = buffer.modify({ $0.completeOrDequeue() }) {
process(event)
}
}
}
50 changes: 49 additions & 1 deletion ReactiveFeedbackTests/ReactiveFeedbackTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,55 @@ import XCTest
import ReactiveSwift
import Result
import Nimble
import ReactiveFeedback

class ReactiveFeedbackTests: XCTestCase {
func testPlaceholder() {}
func test_reduce_with_two_immediate_feedback_loops() {
var feedbackACount = 0
var feedbackBCount = 0

let system = SignalProducer<String, NoError>.system(
initial: "initial",
reduce: { (state: String, event: String) in
return state + event
},
feedbacks: [
Feedback<String, String>(
predicate: { $0 == "initial" || $0.hasSuffix("_b") },
effects: { state in
return SignalProducer(value: "_a")
.on(value: { _ in feedbackACount += 1 })
}
),
Feedback<String, String>(
predicate: { $0.hasSuffix("_a") },
effects: { state in
return SignalProducer(value: "_b")
.on(value: { _ in feedbackBCount += 1 })
}
)
]
)

var results: [String]?

waitUntil { done in
system
.take(first: 5)
.collect()
.on(disposed: done)
.startWithValues { results = $0 }
}

expect(results) == [
"initial",
"initial_a",
"initial_a_b",
"initial_a_b_a",
"initial_a_b_a_b",
]

expect(feedbackACount) == 2
expect(feedbackBCount) == 2
}
}

0 comments on commit c9b8450

Please sign in to comment.