Skip to content

Commit be9f5eb

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

File tree

2 files changed

+92
-34
lines changed

2 files changed

+92
-34
lines changed

MobiusCore/Source/MobiusController.swift

Lines changed: 54 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,24 @@ 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+
/// - Parameter connectable: the view to connect
136+
/// - Returns: an identifier that can be used to disconnect the view
137+
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running
138+
@discardableResult
141139
public func connectView<ViewConnectable: Connectable>(
142140
_ connectable: ViewConnectable
143-
) where ViewConnectable.Input == Model, ViewConnectable.Output == Event {
141+
) -> UUID where ViewConnectable.Input == Model, ViewConnectable.Output == Event {
144142
do {
143+
let id = UUID()
145144
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")
148-
}
149-
150-
stoppedState.viewConnectable = AsyncDispatchQueueConnectable(connectable, acceptQueue: viewQueue)
145+
stoppedState.viewConnectables[id] = AsyncDispatchQueueConnectable(connectable, acceptQueue: viewQueue)
151146
}
147+
148+
return id
152149
} catch {
153150
MobiusHooks.errorHandler(
154151
errorMessage(error, default: "\(Self.debugTag): cannot connect a view while running"),
@@ -162,16 +159,44 @@ public final class MobiusController<Model, Event, Effect> {
162159
///
163160
/// May not be called directly from an effect handler running on the controller’s loop queue.
164161
///
165-
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if there isn't anything to
166-
/// disconnect
162+
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running, if there is more than 1 connection,
163+
/// or if there isn't anything to disconnect
167164
public func disconnectView() {
168165
do {
169166
try state.mutate { stoppedState in
170-
guard stoppedState.viewConnectable != nil else {
167+
guard stoppedState.viewConnectables.count <= 1 else {
168+
throw ErrorMessage(message: "\(Self.debugTag): missing view connection id, cannot disconnect")
169+
}
170+
171+
guard let id = stoppedState.viewConnectables.keys.first else {
171172
throw ErrorMessage(message: "\(Self.debugTag): no view connected, cannot disconnect")
172173
}
173174

174-
stoppedState.viewConnectable = nil
175+
stoppedState.viewConnectables[id] = nil
176+
}
177+
} catch {
178+
MobiusHooks.errorHandler(
179+
errorMessage(error, default: "\(Self.debugTag): cannot disconnect view while running; call stop first"),
180+
#file,
181+
#line
182+
)
183+
}
184+
}
185+
186+
/// Disconnect a connected view from this controller.
187+
///
188+
/// May not be called directly from an effect handler running on the controller’s loop queue.
189+
///
190+
/// - Parameter id: the identifier received from calling `connectView(_:)`
191+
/// - Attention: fails via `MobiusHooks.errorHandler` if the loop is running or if the id is not connected
192+
public func disconnectView(id: UUID) {
193+
do {
194+
try state.mutate { stoppedState in
195+
guard stoppedState.viewConnectables[id] != nil else {
196+
throw ErrorMessage(message: "\(Self.debugTag): invalid view connection, cannot disconnect")
197+
}
198+
199+
stoppedState.viewConnectables[id] = nil
175200
}
176201
} catch {
177202
MobiusHooks.errorHandler(
@@ -191,11 +216,8 @@ public final class MobiusController<Model, Event, Effect> {
191216
do {
192217
try state.transitionToRunning { stoppedState in
193218
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
219+
let disposables: [Disposable] = [loop] + stoppedState.viewConnectables.values.map { connectable in
220+
let connection = connectable.connect { [weak loop] event in
199221
guard let loop = loop else {
200222
// This failure should not be reached under normal circumstances because it is handled by
201223
// AsyncDispatchQueueConnectable. Stopping here means that the viewConnectable called its
@@ -205,13 +227,14 @@ public final class MobiusController<Model, Event, Effect> {
205227

206228
loop.unguardedDispatchEvent(event)
207229
}
208-
loop.addObserver(viewConnection.accept)
209-
disposables.append(viewConnection)
230+
loop.addObserver(connection.accept)
231+
232+
return connection
210233
}
211234

212235
return RunningState(
213236
loop: loop,
214-
viewConnectable: stoppedState.viewConnectable,
237+
viewConnectables: stoppedState.viewConnectables,
215238
disposables: CompositeDisposable(disposables: disposables)
216239
)
217240
}
@@ -236,11 +259,12 @@ public final class MobiusController<Model, Event, Effect> {
236259
public func stop() {
237260
do {
238261
try state.transitionToStopped { runningState in
239-
let model = runningState.loop.latestModel
240-
241262
runningState.disposables.dispose()
242263

243-
return StoppedState(modelToStartFrom: model, viewConnectable: runningState.viewConnectable)
264+
return StoppedState(
265+
modelToStartFrom: runningState.loop.latestModel,
266+
viewConnectables: runningState.viewConnectables
267+
)
244268
}
245269
} catch {
246270
MobiusHooks.errorHandler(

MobiusCore/Test/MobiusControllerTests.swift

Lines changed: 38 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)
@@ -212,6 +227,25 @@ class MobiusControllerTests: QuickSpec {
212227
controller.disconnectView()
213228
expect(controller.disconnectView()).to(raiseError())
214229
}
230+
231+
describe("multiple view connections") {
232+
it("should not allow disconnecting without a connection id") {
233+
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)
234+
235+
controller.connectView(view)
236+
controller.connectView(secondaryView)
237+
238+
expect(controller.disconnectView()).to(raiseError())
239+
}
240+
it("should not allow disconnecting an invalid connection id") {
241+
let secondaryView = RecordingTestConnectable(expectedQueue: self.viewQueue)
242+
243+
controller.connectView(view)
244+
controller.connectView(secondaryView)
245+
246+
expect(controller.disconnectView(id: UUID())).to(raiseError())
247+
}
248+
}
215249
}
216250
#endif
217251
}

0 commit comments

Comments
 (0)