From 288a626f07737caa9b1ad225138d23a43147c829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pacheco=20Neves?= Date: Mon, 6 Jul 2020 16:38:38 +0100 Subject: [PATCH 1/2] Add `pause` and `resume` API's to `Loop` On some scenarios, it's useful to "pause" the `Loop` so that we stop processing events for some reason (e.g. to stop a `Loop` backed Service). Following the same reasoning, it becomes necessary to have a "resume" mechanism so that the Loop starts processing events again. Given `Loop` now starts automatically and `stop` is designed as a tear down mechanism to be used on dealloc and dispose all observations, some new API's are required so that we can unplug/replug feedbacks to achieve the above mentioned pause/resume behavior. ## Changes - Create new `plugFeedbacks` and `unplugFeedbacks` API's in `Floodgate`, which establish and dispose feedbacks observations, respectively. Floodgate now retains the feedbacks passed in on `bootstrap` to use them on `plugFeedbacks`. - Add `pause` and `resume` API's to `LoopBoxBase`. - Implement `pause` and `resume` API's in `RootLoopBox`, which unplug and plug the feedbacks on the `Floodgate`, respectively. - Implement `pause` and `resume` API's in `ScopedLoopBox`, which forward the calls to their root, respectively. --- Loop/Floodgate.swift | 14 +++++++++++++- Loop/LoopBox.swift | 20 ++++++++++++++++++++ Loop/Public/Loop.swift | 8 ++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Loop/Floodgate.swift b/Loop/Floodgate.swift index 6480613..16ac975 100644 --- a/Loop/Floodgate.swift +++ b/Loop/Floodgate.swift @@ -34,7 +34,8 @@ final class Floodgate: FeedbackEventConsumer { private let queue = Atomic(QueueState()) private let reducer: (inout State, Event) -> Void - private let feedbackDisposables = CompositeDisposable() + private var feedbacks: [Loop.Feedback] = [] + private var feedbackDisposables = CompositeDisposable() init(state: State, reducer: @escaping (inout State, Event) -> Void) { self.state = state @@ -46,6 +47,12 @@ final class Floodgate: FeedbackEventConsumer { } func bootstrap(with feedbacks: [Loop.Feedback]) { + self.feedbacks = feedbacks + + plugFeedbacks() + } + + func plugFeedbacks() { for feedback in feedbacks { // Pass `producer` which has replay-1 semantic. feedbackDisposables += feedback.events( @@ -60,6 +67,11 @@ final class Floodgate: FeedbackEventConsumer { } } + func unplugFeedbacks() { + feedbackDisposables.dispose() + feedbackDisposables = CompositeDisposable() + } + override func process(_ event: Event, for token: Token) { enqueue(event, for: token) diff --git a/Loop/LoopBox.swift b/Loop/LoopBox.swift index 25da092..3cb789f 100644 --- a/Loop/LoopBox.swift +++ b/Loop/LoopBox.swift @@ -44,6 +44,14 @@ internal class ScopedLoopBox: Lo event: { [eventTransform] in eventTransform(event($0)) } ) } + + override func pause() { + root.pause() + } + + override func resume() { + root.resume() + } } internal class RootLoopBox: LoopBoxBase { @@ -83,6 +91,14 @@ internal class RootLoopBox: LoopBoxBase { ScopedLoopBox(root: self, value: scope, event: event) } + override func pause() { + floodgate.unplugFeedbacks() + } + + override func resume() { + floodgate.plugFeedbacks() + } + func start(with feedbacks: [Loop.Feedback]) { floodgate.bootstrap(with: feedbacks + [input.feedback]) } @@ -115,6 +131,10 @@ internal class LoopBoxBase { ) -> LoopBoxBase { subclassMustImplement() } + + func pause() { subclassMustImplement() } + + func resume() { subclassMustImplement() } } @inline(never) diff --git a/Loop/Public/Loop.swift b/Loop/Public/Loop.swift index cb49ba2..481590d 100644 --- a/Loop/Public/Loop.swift +++ b/Loop/Public/Loop.swift @@ -69,6 +69,14 @@ public final class Loop { box: box.scoped(to: { $0 }, event: { _ in fatalError() }) ) } + + public func pause() { + box.pause() + } + + public func resume() { + box.resume() + } } extension Loop { From d8e39d5c4af2c03f4c7ae43c10c9f186445d7055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pacheco=20Neves?= Date: Tue, 21 Jul 2020 14:01:28 +0100 Subject: [PATCH 2/2] Implement `Feedback.autoconnect` operator - Implement a new `autoconnect` operator on `Feedback` that disposes and restores its "parent" Feedback observation according to a predicate on `State`. --- Loop/Floodgate.swift | 14 +---------- Loop/LoopBox.swift | 20 ---------------- Loop/Public/FeedbackLoop.swift | 11 +++++++++ Loop/Public/Loop.swift | 8 ------- LoopTests/FeedbackVariantTests.swift | 35 ++++++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 41 deletions(-) diff --git a/Loop/Floodgate.swift b/Loop/Floodgate.swift index 16ac975..6480613 100644 --- a/Loop/Floodgate.swift +++ b/Loop/Floodgate.swift @@ -34,8 +34,7 @@ final class Floodgate: FeedbackEventConsumer { private let queue = Atomic(QueueState()) private let reducer: (inout State, Event) -> Void - private var feedbacks: [Loop.Feedback] = [] - private var feedbackDisposables = CompositeDisposable() + private let feedbackDisposables = CompositeDisposable() init(state: State, reducer: @escaping (inout State, Event) -> Void) { self.state = state @@ -47,12 +46,6 @@ final class Floodgate: FeedbackEventConsumer { } func bootstrap(with feedbacks: [Loop.Feedback]) { - self.feedbacks = feedbacks - - plugFeedbacks() - } - - func plugFeedbacks() { for feedback in feedbacks { // Pass `producer` which has replay-1 semantic. feedbackDisposables += feedback.events( @@ -67,11 +60,6 @@ final class Floodgate: FeedbackEventConsumer { } } - func unplugFeedbacks() { - feedbackDisposables.dispose() - feedbackDisposables = CompositeDisposable() - } - override func process(_ event: Event, for token: Token) { enqueue(event, for: token) diff --git a/Loop/LoopBox.swift b/Loop/LoopBox.swift index 3cb789f..25da092 100644 --- a/Loop/LoopBox.swift +++ b/Loop/LoopBox.swift @@ -44,14 +44,6 @@ internal class ScopedLoopBox: Lo event: { [eventTransform] in eventTransform(event($0)) } ) } - - override func pause() { - root.pause() - } - - override func resume() { - root.resume() - } } internal class RootLoopBox: LoopBoxBase { @@ -91,14 +83,6 @@ internal class RootLoopBox: LoopBoxBase { ScopedLoopBox(root: self, value: scope, event: event) } - override func pause() { - floodgate.unplugFeedbacks() - } - - override func resume() { - floodgate.plugFeedbacks() - } - func start(with feedbacks: [Loop.Feedback]) { floodgate.bootstrap(with: feedbacks + [input.feedback]) } @@ -131,10 +115,6 @@ internal class LoopBoxBase { ) -> LoopBoxBase { subclassMustImplement() } - - func pause() { subclassMustImplement() } - - func resume() { subclassMustImplement() } } @inline(never) diff --git a/Loop/Public/FeedbackLoop.swift b/Loop/Public/FeedbackLoop.swift index 3763807..b16e243 100644 --- a/Loop/Public/FeedbackLoop.swift +++ b/Loop/Public/FeedbackLoop.swift @@ -453,6 +453,17 @@ extension Loop { } } + public func autoconnect(followingChangesIn predicate: @escaping (State) -> Bool) -> Feedback { + return Feedback { state, consumer in + self.events( + state + .skipRepeats { predicate($0.0) == predicate($1.0) } + .flatMap(.latest) { predicate($0.0) ? state.filter { predicate($0.0) } : .empty }, + consumer + ) + } + } + public static func combine(_ feedbacks: Loop.Feedback...) -> Feedback { return Feedback { state, consumer in feedbacks.map { feedback in diff --git a/Loop/Public/Loop.swift b/Loop/Public/Loop.swift index 481590d..cb49ba2 100644 --- a/Loop/Public/Loop.swift +++ b/Loop/Public/Loop.swift @@ -69,14 +69,6 @@ public final class Loop { box: box.scoped(to: { $0 }, event: { _ in fatalError() }) ) } - - public func pause() { - box.pause() - } - - public func resume() { - box.resume() - } } extension Loop { diff --git a/LoopTests/FeedbackVariantTests.swift b/LoopTests/FeedbackVariantTests.swift index 62ed2cb..f5f5389 100644 --- a/LoopTests/FeedbackVariantTests.swift +++ b/LoopTests/FeedbackVariantTests.swift @@ -174,4 +174,39 @@ class FeedbackVariantTests: XCTestCase { loop.send("world") expect(hasCancelled) == true } + + func test_autoconnect_disposes_and_restores_flows_according_to_predicate() { + + let loop = Loop( + initial: "", + reducer: { content, string in + content = string + }, + feedbacks: [ + Loop.Feedback + .init( + lensing: { $0.hasPrefix("hello") ? $0 : nil }, + effects: { SignalProducer(value: $0.uppercased()) } + ) + .autoconnect(followingChangesIn: { $0.contains("disconnect") == false }) + ] + ) + + expect(loop.box._current) == "" + + // This should trigger an uppercased event + loop.send("hello1") + expect(loop.box._current) == "HELLO1" + + loop.send("hello2") + expect(loop.box._current) == "HELLO2" + + // This should lead to feedabck "disconnection", which in turn should cancel and disable the uppercasing effect. + loop.send("hello disconnect") + expect(loop.box._current) == "hello disconnect" + + // This should lead feedback "connection", which in turn should reestablish the uppercasing effect. + loop.send("hello3") + expect(loop.box._current) == "HELLO3" + } }