Skip to content

Commit

Permalink
Merge pull request #42 from CombineCommunity/feature/use-replay-subject
Browse files Browse the repository at this point in the history
Feature/use replay subject
  • Loading branch information
twittemb authored Jun 1, 2021
2 parents f2d4483 + a774dfa commit c9cb61f
Show file tree
Hide file tree
Showing 34 changed files with 275 additions and 186 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
**v0.4.0 - Bane**:

- replace CurrentValueSubject by a ReplaySubject in System.stream

**v0.3.0 - Tyranus**:

- Feedback: introduce the "on:" keyword to explicitly declare the type of state that concerns the side effect
Expand Down
32 changes: 10 additions & 22 deletions Examples/Examples.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
objects = {

/* Begin PBXBuildFile section */
1A7057E7266167540070FD5D /* Feedbacks in Frameworks */ = {isa = PBXBuildFile; productRef = 1A7057E6266167540070FD5D /* Feedbacks */; };
1A7057E9266167950070FD5D /* FeedbacksTest in Frameworks */ = {isa = PBXBuildFile; productRef = 1A7057E8266167950070FD5D /* FeedbacksTest */; };
741C474025EC5CDB00F1231B /* Counter+TransitionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 741C473F25EC5CDB00F1231B /* Counter+TransitionsTests.swift */; };
741C475C25EC5DE100F1231B /* Feedbacks in Frameworks */ = {isa = PBXBuildFile; productRef = 741C475B25EC5DE100F1231B /* Feedbacks */; };
741C476625EC5E4C00F1231B /* FeedbacksTest in Frameworks */ = {isa = PBXBuildFile; productRef = 741C476525EC5E4C00F1231B /* FeedbacksTest */; };
742FEE2425B388DA00575CB2 /* GifList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE2325B388DA00575CB2 /* GifList.swift */; };
742FEE2825B38B1A00575CB2 /* GifList+States.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE2725B38B1A00575CB2 /* GifList+States.swift */; };
742FEE2E25B38EEE00575CB2 /* GifOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 742FEE2D25B38EEE00575CB2 /* GifOverview.swift */; };
Expand Down Expand Up @@ -66,6 +66,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
1A5E6478266166CA00F576A9 /* Feedbacks */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Feedbacks; path = ..; sourceTree = "<group>"; };
741C473D25EC5CDB00F1231B /* ExamplesTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExamplesTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
741C473F25EC5CDB00F1231B /* Counter+TransitionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Counter+TransitionsTests.swift"; sourceTree = "<group>"; };
741C474125EC5CDB00F1231B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -121,15 +122,15 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
741C476625EC5E4C00F1231B /* FeedbacksTest in Frameworks */,
1A7057E9266167950070FD5D /* FeedbacksTest in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
7471829D25AE7B0B0098E83E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
741C475C25EC5DE100F1231B /* Feedbacks in Frameworks */,
1A7057E7266167540070FD5D /* Feedbacks in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -297,6 +298,7 @@
7471829725AE7B0B0098E83E = {
isa = PBXGroup;
children = (
1A5E6478266166CA00F576A9 /* Feedbacks */,
747182A225AE7B0B0098E83E /* Examples */,
741C473E25EC5CDB00F1231B /* ExamplesTests */,
747182A125AE7B0B0098E83E /* Products */,
Expand Down Expand Up @@ -400,7 +402,7 @@
);
name = ExamplesTests;
packageProductDependencies = (
741C476525EC5E4C00F1231B /* FeedbacksTest */,
1A7057E8266167950070FD5D /* FeedbacksTest */,
);
productName = ExamplesTests;
productReference = 741C473D25EC5CDB00F1231B /* ExamplesTests.xctest */;
Expand All @@ -420,7 +422,7 @@
);
name = Examples;
packageProductDependencies = (
741C475B25EC5DE100F1231B /* Feedbacks */,
1A7057E6266167540070FD5D /* Feedbacks */,
);
productName = Examples;
productReference = 747182A025AE7B0B0098E83E /* Examples.app */;
Expand Down Expand Up @@ -454,7 +456,6 @@
);
mainGroup = 7471829725AE7B0B0098E83E;
packageReferences = (
741C475A25EC5DE100F1231B /* XCRemoteSwiftPackageReference "Feedbacks" */,
);
productRefGroup = 747182A125AE7B0B0098E83E /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -789,26 +790,13 @@
};
/* End XCConfigurationList section */

/* Begin XCRemoteSwiftPackageReference section */
741C475A25EC5DE100F1231B /* XCRemoteSwiftPackageReference "Feedbacks" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "[email protected]:CombineCommunity/Feedbacks.git";
requirement = {
kind = exactVersion;
version = 0.3.0;
};
};
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
741C475B25EC5DE100F1231B /* Feedbacks */ = {
1A7057E6266167540070FD5D /* Feedbacks */ = {
isa = XCSwiftPackageProductDependency;
package = 741C475A25EC5DE100F1231B /* XCRemoteSwiftPackageReference "Feedbacks" */;
productName = Feedbacks;
};
741C476525EC5E4C00F1231B /* FeedbacksTest */ = {
1A7057E8266167950070FD5D /* FeedbacksTest */ = {
isa = XCSwiftPackageProductDependency;
package = 741C475A25EC5DE100F1231B /* XCRemoteSwiftPackageReference "Feedbacks" */;
productName = FeedbacksTest;
};
/* End XCSwiftPackageProductDependency section */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,21 @@
"object": {
"pins": [
{
"package": "Feedbacks",
"repositoryURL": "git@github.com:CombineCommunity/Feedbacks.git",
"package": "combine-schedulers",
"repositoryURL": "https://github.com/pointfreeco/combine-schedulers.git",
"state": {
"branch": null,
"revision": "9d8ba9b4ada327b1f88401b822454770a6b65263",
"version": "0.3.0"
"revision": "ff42ec9061d864de7982162011321d3df5080c10",
"version": "0.1.2"
}
},
{
"package": "CombineExt",
"repositoryURL": "https://github.com/CombineCommunity/CombineExt.git",
"state": {
"branch": null,
"revision": "5b8a0c0f178527f9204200505c5fefa6847e528f",
"version": "1.3.0"
}
}
]
Expand Down
3 changes: 2 additions & 1 deletion Examples/Examples/CounterApp/System/CounterApp+System.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Thibault Wittemberg on 2021-01-12.
//

import Dispatch
import Feedbacks

// define a namespace for this app's system
Expand Down Expand Up @@ -55,5 +56,5 @@ extension CounterApp.System {
On(CounterApp.Events.Decrease.self, transitionTo: CounterApp.States.Decreasing(counter: state.counter.decrease(), isPaused: false))
}
}
}
}.execute(on: DispatchQueue(label: "Counter Queue"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,7 @@ extension GifDetail.System {

Feedbacks {
Feedback(on: GifDetail.States.Loading.self, strategy: .cancelOnNewState, perform: loadSideEffect)
.execute(on: DispatchQueue(label: "Load Gif Queue"))

Feedback(on: GifDetail.States.TogglingFavorite.self, strategy: .cancelOnNewState, perform: toggleFavoriteSideEffect)
.execute(on: DispatchQueue(label: "Toggle Favorite Queue"))
}
.onStateReceived {
print("GifDetail: New state has been received: \($0)")
Expand Down Expand Up @@ -64,6 +61,6 @@ extension GifDetail.System {
On(GifDetail.Events.LoadingHasFailed.self, transitionTo: GifDetail.States.Failed())
}
}
}
}.execute(on: DispatchQueue(label: "Load Gif Queue"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ extension GifList.System {

Feedbacks {
Feedback(on: GifList.States.Loading.self , strategy: .cancelOnNewState, perform: loadSideEffect)
.execute(on: DispatchQueue(label: "Load Gifs Queue"))
}
.onStateReceived {
print("GifList: New state has been received: \($0)")
Expand Down Expand Up @@ -64,6 +63,6 @@ extension GifList.System {
On(GifList.Events.Refresh.self, transitionTo: GifList.States.Loading())
}
}
}
}.execute(on: DispatchQueue(label: "Load Gifs Queue"))
}
}
9 changes: 9 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
"revision": "ff42ec9061d864de7982162011321d3df5080c10",
"version": "0.1.2"
}
},
{
"package": "CombineExt",
"repositoryURL": "https://github.com/CombineCommunity/CombineExt.git",
"state": {
"branch": null,
"revision": "5b8a0c0f178527f9204200505c5fefa6847e528f",
"version": "1.3.0"
}
}
]
},
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ let package = Package(
dependencies: [
// Dependencies declare other packages that this package depends on.
.package(url: "https://github.com/pointfreeco/combine-schedulers.git", .exact(Version("0.1.2"))),
.package(url: "https://github.com/CombineCommunity/CombineExt.git", .exact(Version("1.3.0")))
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "Feedbacks",
dependencies: [],
dependencies: [.product(name: "CombineExt", package: "CombineExt")],
path: "Sources/Feedbacks"),
.testTarget(
name: "FeedbacksTests",
Expand Down
56 changes: 51 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ In our example, one feedback takes care of increasing the volume and the other i

## Scheduling

Threading is very important to make a nice responsive application. A Scheduler is the Combine way of handling threading by switching portions of reactive streams on dispatch queues, or operation queues or RunLoops.
Threading is very important to make a nice responsive application. A Scheduler is the Combine way of handling threading by switching portions of reactive streams on dispatch queues, operation queues or RunLoops.

The declarative syntax of Feedbacks allows to alter the behavior of side effects by simply applying modifiers (like you would do with SwiftUI to change the frame for instance). Modifying the scheduling of a side effect is as simple as calling the `.execute(on:)` modifier.
The declarative syntax of Feedbacks allows to alter the behavior of a System by simply applying modifiers (like you would do with SwiftUI to change the frame for instance). Modifying the scheduling of a side effect is as simple as calling the `.execute(on:)` modifier.

```swift
Feedbacks {
Expand Down Expand Up @@ -128,6 +128,52 @@ Feedbacks {

Both side effects will be executed on the background queue.

It is also applicable to the transitions:

```swift
Transitions {
From(VolumeState.self) { state in
On(IncreaseEvent.self, transitionTo: VolumeState(value: state.value + 1))
On(DecreaseEvent.self, transitionTo: VolumeState(value: state.value - 1))
}
}.execute(on: DispatchQueue(label: "A background queue"))
```

or to the whole system:

```swift
System {
InitialState {
VolumeState(value: 10)
}

Feedbacks {
Feedback(on: VolumeState.self, strategy: .continueOnNewState) { state -> AnyPublisher<Event, Never> in
if state.value >= targetedVolume {
return Empty().eraseToAnyPublisher()
}

return Just(IncreaseEvent()).eraseToAnyPublisher()
}

Feedback(on: VolumeState.self, strategy: .continueOnNewState) { state -> AnyPublisher<Event, Never> in
if state.value <= targetedVolume {
return Empty().eraseToAnyPublisher()
}

return Just(DecreaseEvent()).eraseToAnyPublisher()
}
}

Transitions {
From(VolumeState.self) { state in
On(IncreaseEvent.self, transitionTo: VolumeState(value: state.value + 1))
On(DecreaseEvent.self, transitionTo: VolumeState(value: state.value - 1))
}
}
}.execute(on: DispatchQueue(label: "A background queue"))
```

## Lifecycle

There are typical cases where a side effect consist of an asynchronous operation (like a network call). What happens if the very same side effect is called repeatedly, not waiting for the previous ones to end? Are the operations stacked? Are they cancelled when a new one is performed?
Expand Down Expand Up @@ -196,9 +242,9 @@ Here is a list of the supported modifiers:
| Modifier | Action | Can be applied to |
| -------------- | -------------- | -------------- |
| `.disable(disabled:)`| The target won't be executed as long as the `disabled` condition is true | <ul align="left"><li>Transition</li><li>Transitions</li><li>Feedback</li></ul> |
| `.execute(on:)`| The target will be executed on the scheduler | <ul align="left"><li>Feedbacks</li><li>Feedback</li></ul> |
| `.onStateReceived(perform:)`| Execute the `perform` closure each time a new state is given as an input | <ul align="left"><li>Feedbacks</li><li>Feedback</li></ul> |
| `.onEventEmitted(perform:)`| Execute the `perform` closure each time a new event is emitted | <ul align="left"><li>Feedbacks</li><li>Feedback</li></ul> |
| `.execute(on:)`| The target will be executed on the scheduler | <ul align="left"><li>Transitions</li><li>Feedback</li><li>Feedbacks</li><li>System</li></ul> |
| `.onStateReceived(perform:)`| Execute the `perform` closure each time a new state is given as an input | <ul align="left"><li>Feedback</li><li>Feedbacks</li></ul> |
| `.onEventEmitted(perform:)`| Execute the `perform` closure each time a new event is emitted | <ul align="left"><li>Feedback</li><li>Feedbacks</li></ul> |
| `.attach(to:)`| Refer to the "How to make systems communicate" section | <ul align="left"><li>System</li><li>UISystem</li></ul> |
| `.uiSystem(viewStateFactory:)`| Refer to the "Using Feedbacks with SwiftUI and UIKit" section | <ul align="left"><li>System</li></ul> |

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
44 changes: 18 additions & 26 deletions Sources/Feedbacks/System/System.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import Combine
import CombineExt
import Dispatch
import Foundation

Expand All @@ -17,37 +18,26 @@ import Foundation
public class System {
let initialState: InitialState
var feedbacks: Feedbacks
public let transitions: Transitions
var scheduledStream: (AnyPublisher<Event, Never>) -> AnyPublisher<Event, Never>
public private(set) var transitions: Transitions

private var subscriptions = [AnyCancellable]()

static let defaultQueue = DispatchQueue(label: "Feedbacks.System.\(UUID().uuidString)")

/// Builds a System based on its three components: an initial state, some feedbacks, a state machine
/// By default, the System will be executed an a serial background queue. This can be altered thanks to the `.execute(on:)` modifier.
/// - Parameter components: the three components of the System
public convenience init(@SystemBuilder _ components: () -> (InitialState, Feedbacks, Transitions)) {
let (initialState, feedbacks, transitions) = System.decode(builder: components)
self.init(initialState: initialState,
feedbacks: feedbacks,
transitions: transitions,
scheduledStream: { (events: AnyPublisher<Event, Never>) in
events
.subscribe(on: System.defaultQueue)
.receive(on: System.defaultQueue)
.eraseToAnyPublisher()
})
transitions: transitions)
}

init(initialState: InitialState,
feedbacks: Feedbacks,
transitions: Transitions,
scheduledStream: @escaping (AnyPublisher<Event, Never>) -> AnyPublisher<Event, Never>) {
transitions: Transitions) {
self.initialState = initialState
self.feedbacks = feedbacks
self.transitions = transitions
self.scheduledStream = scheduledStream
}

static func decode(builder system: () -> (InitialState, Feedbacks, Transitions)) -> (InitialState, Feedbacks, Transitions) {
Expand All @@ -61,16 +51,15 @@ public extension System {
/// Once this stream has been subscribed to, the initial state is given as an input to the feedbacks.
/// Then the feedbacks can publish event that will trigger some transitions, generating a new state, and so on and so forth.
var stream: AnyPublisher<State, Never> {
Deferred<AnyPublisher<State, Never>> { [initialState, feedbacks, transitions, scheduledStream] in
let currentState = CurrentValueSubject<State, Never>(initialState.value)
Deferred<AnyPublisher<State, Never>> { [initialState, feedbacks, transitions] in
let currentState = ReplaySubject<State, Never>(bufferSize: 1)

// merging all the effects into one event stream
let stateInputStream = currentState.eraseToAnyPublisher()
let eventStream = feedbacks.eventStream(stateInputStream)
let scheduledEventStream = scheduledStream(eventStream)

return scheduledEventStream
.scan(initialState.value, transitions.reducer)
return transitions.scheduledReducer(initialState.value, eventStream)
.prepend(initialState.value)
.handleEvents(receiveOutput: currentState.send)
.eraseToAnyPublisher()
}.eraseToAnyPublisher()
Expand All @@ -83,6 +72,14 @@ public extension System {
self.stream.sink(receiveValue: { _ in }).store(in: &self.subscriptions)
return self
}

/// Subscribes to the state stream and store the cancellable in the System.
/// The subscription will be canceled once the System is deinit.
@discardableResult
func run<SchedulerType: Scheduler>(subscribeOn scheduler: SchedulerType) -> Self {
self.stream.subscribe(on: scheduler).sink(receiveValue: { _ in }).store(in: &self.subscriptions)
return self
}
}

// MARK: modifiers
Expand All @@ -93,13 +90,8 @@ public extension System {
/// - Parameter scheduler: the scheduler on which to execute the System
/// - Returns: The newly scheduled System
func execute<SchedulerType: Scheduler>(on scheduler: SchedulerType) -> Self {
self.scheduledStream = { events in
events
.subscribe(on: scheduler)
.receive(on: scheduler)
.eraseToAnyPublisher()
}

self.feedbacks = self.feedbacks.execute(on: scheduler)
self.transitions = self.transitions.execute(on: scheduler)
return self
}

Expand Down
Loading

0 comments on commit c9cb61f

Please sign in to comment.