Skip to content

Commit f83d084

Browse files
committed
Support multiple controller view connections
1 parent 8bc883f commit f83d084

File tree

2 files changed

+64
-34
lines changed

2 files changed

+64
-34
lines changed

MobiusCore/Source/MobiusController.swift

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,15 @@ import Foundation
1010
public final class MobiusController<Model, Event, Effect> {
1111
typealias Loop = MobiusLoop<Model, Event, Effect>
1212
typealias ViewConnectable = AsyncDispatchQueueConnectable<Model, Event>
13-
typealias ViewConnection = Connection<Model>
1413

1514
private struct StoppedState {
1615
var modelToStartFrom: Model
17-
var viewConnectable: ViewConnectable?
18-
16+
var viewConnectables: [UUID: ViewConnectable]
1917
}
2018

2119
private struct RunningState {
2220
var loop: Loop
23-
var viewConnectable: ViewConnectable?
21+
var viewConnectables: [UUID: ViewConnectable]
2422
var disposables: CompositeDisposable
2523
}
2624

@@ -78,7 +76,7 @@ public final class MobiusController<Model, Event, Effect> {
7876
self.viewQueue = viewQueue
7977

8078
let state = State(
81-
state: StoppedState(modelToStartFrom: initialModel, viewConnectable: nil),
79+
state: StoppedState(modelToStartFrom: initialModel, viewConnectables: [:]),
8280
queue: loopQueue
8381
)
8482
self.state = state
@@ -130,25 +128,28 @@ public final class MobiusController<Model, Event, Effect> {
130128

131129
/// Connect a view to this controller.
132130
///
133-
/// May not be called while the loop is running.
134-
///
135131
/// The `Connectable` will be given an event consumer, which the view should use to send events to the `MobiusLoop`.
136132
/// The view should also return a `Connection` that accepts models and renders them. Disposing the connection should
137133
/// make the view stop emitting events.
138134
///
139-
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if the controller already is
140-
/// connected
135+
/// - Returns: An identifier that can be used to disconnect the view.
136+
/// - Attention: Fails via `MobiusHooks.errorHandler` if the loop is running.
137+
@discardableResult
141138
public func connectView<ViewConnectable: Connectable>(
142139
_ connectable: ViewConnectable
143-
) where ViewConnectable.Input == Model, ViewConnectable.Output == Event {
140+
) -> UUID where ViewConnectable.Input == Model, ViewConnectable.Output == Event {
144141
do {
142+
var uuid = Self.viewConnectableID
145143
try state.mutate { stoppedState in
146-
guard stoppedState.viewConnectable == nil else {
147-
throw ErrorMessage(message: "\(Self.debugTag): only one view may be connected at a time")
144+
if stoppedState.viewConnectables[uuid] != nil {
145+
// For backwards compatibility with id-less `disconnectView()` usage, we default
146+
// to the reserved connection id and generate a new one when needed.
147+
uuid = UUID()
148148
}
149-
150-
stoppedState.viewConnectable = AsyncDispatchQueueConnectable(connectable, acceptQueue: viewQueue)
149+
stoppedState.viewConnectables[uuid] = AsyncDispatchQueueConnectable(connectable, acceptQueue: viewQueue)
151150
}
151+
152+
return uuid
152153
} catch {
153154
MobiusHooks.errorHandler(
154155
errorMessage(error, default: "\(Self.debugTag): cannot connect a view while running"),
@@ -158,20 +159,22 @@ public final class MobiusController<Model, Event, Effect> {
158159
}
159160
}
160161

161-
/// Disconnect the connected view from this controller.
162+
/// Disconnect a connected view from this controller.
162163
///
163164
/// May not be called directly from an effect handler running on the controller’s loop queue.
164165
///
166+
/// - Parameter id: The identifier received from calling `connectView(_:)`.
165167
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if there isn't anything to
166168
/// disconnect
167-
public func disconnectView() {
169+
public func disconnectView(id: UUID? = nil) {
168170
do {
171+
let uuid = id ?? Self.viewConnectableID
169172
try state.mutate { stoppedState in
170-
guard stoppedState.viewConnectable != nil else {
171-
throw ErrorMessage(message: "\(Self.debugTag): no view connected, cannot disconnect")
173+
guard stoppedState.viewConnectables[uuid] != nil else {
174+
throw ErrorMessage(message: "\(Self.debugTag): invalid view connection id, cannot disconnect")
172175
}
173176

174-
stoppedState.viewConnectable = nil
177+
stoppedState.viewConnectables[uuid] = nil
175178
}
176179
} catch {
177180
MobiusHooks.errorHandler(
@@ -191,11 +194,8 @@ public final class MobiusController<Model, Event, Effect> {
191194
do {
192195
try state.transitionToRunning { stoppedState in
193196
let loop = loopFactory(stoppedState.modelToStartFrom)
194-
195-
var disposables: [Disposable] = [loop]
196-
197-
if let viewConnectable = stoppedState.viewConnectable {
198-
let viewConnection = viewConnectable.connect { [weak loop] event in
197+
let disposables: [Disposable] = [loop] + stoppedState.viewConnectables.values.map { connectable in
198+
let connection = connectable.connect { [weak loop] event in
199199
guard let loop = loop else {
200200
// This failure should not be reached under normal circumstances because it is handled by
201201
// AsyncDispatchQueueConnectable. Stopping here means that the viewConnectable called its
@@ -205,13 +205,14 @@ public final class MobiusController<Model, Event, Effect> {
205205

206206
loop.unguardedDispatchEvent(event)
207207
}
208-
loop.addObserver(viewConnection.accept)
209-
disposables.append(viewConnection)
208+
loop.addObserver(connection.accept)
209+
210+
return connection
210211
}
211212

212213
return RunningState(
213214
loop: loop,
214-
viewConnectable: stoppedState.viewConnectable,
215+
viewConnectables: stoppedState.viewConnectables,
215216
disposables: CompositeDisposable(disposables: disposables)
216217
)
217218
}
@@ -236,11 +237,12 @@ public final class MobiusController<Model, Event, Effect> {
236237
public func stop() {
237238
do {
238239
try state.transitionToStopped { runningState in
239-
let model = runningState.loop.latestModel
240-
241240
runningState.disposables.dispose()
242241

243-
return StoppedState(modelToStartFrom: model, viewConnectable: runningState.viewConnectable)
242+
return StoppedState(
243+
modelToStartFrom: runningState.loop.latestModel,
244+
viewConnectables: runningState.viewConnectables
245+
)
244246
}
245247
} catch {
246248
MobiusHooks.errorHandler(
@@ -287,6 +289,11 @@ public final class MobiusController<Model, Event, Effect> {
287289
}
288290
}
289291

292+
/// Reserves a special identifier for the primary view connectable
293+
private static var viewConnectableID: UUID {
294+
return UUID(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0))
295+
}
296+
290297
/// Simple error that just carries an error message out of a closure for us
291298
private struct ErrorMessage: Error {
292299
let message: String

MobiusCore/Test/MobiusControllerTests.swift

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,16 @@ class MobiusControllerTests: QuickSpec {
7878

7979
expect(view.recorder.items).toEventually(equal(["S", "S-hey"]))
8080
}
81+
it("should allow multiple connections") {
82+
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)
83+
84+
controller.connectView(view)
85+
controller.connectView(secondaryView)
86+
controller.start()
87+
88+
expect(view.recorder.items).toEventually(equal(["S"]))
89+
expect(secondaryView.recorder.items).toEventually(equal(["S"]))
90+
}
8191

8292
context("given a connected and started loop") {
8393
beforeEach {
@@ -149,10 +159,6 @@ class MobiusControllerTests: QuickSpec {
149159
}
150160

151161
describe("error handling") {
152-
it("should not allow connecting twice") {
153-
expect(controller.connectView(view)).toNot(raiseError())
154-
expect(controller.connectView(view)).to(raiseError())
155-
}
156162
it("should not allow connecting after starting") {
157163
controller.connectView(view)
158164
controller.start()
@@ -182,6 +188,15 @@ class MobiusControllerTests: QuickSpec {
182188

183189
expect(view.recorder.items).toEventually(equal(["S"]))
184190
}
191+
it("should allow disconnecting by id") {
192+
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)
193+
194+
let connectionID = controller.connectView(view)
195+
let secondaryConnectionID = controller.connectView(secondaryView)
196+
197+
controller.disconnectView(id: connectionID)
198+
controller.disconnectView(id: secondaryConnectionID)
199+
}
185200
it("should not send events to a disconnected view") {
186201
let disconnectedView = RecordingTestConnectable(expectedQueue: self.viewQueue)
187202
controller.connectView(disconnectedView)
@@ -209,8 +224,16 @@ class MobiusControllerTests: QuickSpec {
209224
}
210225
it("should not allow disconnecting without a connection") {
211226
controller.connectView(view)
227+
228+
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)
229+
let secondaryConnectionID = controller.connectView(secondaryView)
230+
212231
controller.disconnectView()
232+
controller.disconnectView(id: secondaryConnectionID)
233+
213234
expect(controller.disconnectView()).to(raiseError())
235+
expect(controller.disconnectView(id: secondaryConnectionID)).to(raiseError())
236+
expect(controller.disconnectView(id: UUID())).to(raiseError())
214237
}
215238
}
216239
#endif

0 commit comments

Comments
 (0)