My goal is to implement radio buttons using ReactiveCocoa. This means I have an array of child viewModels and only one can be in a selected state at a time. I also want the ability to quickly switch to multi-select
when I want but my problem is with single-select
. When I try to clear the previously selected item, I get a deadlock. The code below will give the following error:
-[NSLock lock]: deadlock (<NSLock: 0x7fc64233e180> 'org.reactivecocoa.ReactiveCocoa.Signal')
2016-06-09 04:05:08.731 ios_samples[92288:26027579] *** Break on _NSLockError() to debug.
I'm not sure if I'm just going about this all wrong. Please help.
Code sample:
// MAIN METHOD
let model = Model(selectionType: .Single)
model.children[0].toggleState()
model.children[1].toggleState() // deadlocks here
print("selected:\(model.selectedChildren.value.filter { $0.isSelected }.map { $0.name }), count: \(model.totalSelected.value)")
// TYPES
enum SelectionType { case Single, Multi }
class Model {
let children = [ChildModel(name: "child1"), ChildModel(name: "child2")]
let totalSelected = MutableProperty<Int>(0)
let selectedChildren = MutableProperty<[ChildModel]>([])
init(selectionType: SelectionType) {
let aggregateProducer = SignalProducer(values: children.map { $0.selectedSignal.producer })
.flatten(.Merge)
func singleSelectChildrenProducer() -> SignalProducer<[ChildModel], NoError> {
return SignalProducer { emitter, disposable in
aggregateProducer.startWithNext { state in
self.children
.filter { $0.isSelected && $0.name != state.childModel.name }
.forEach { $0.clearState() /* SOURCE OF DEADLOCK */ }
emitter.sendNext(state.childModel.isSelected ? [state.childModel] : [])
}
}
}
func multiSelectChildrenProducer() -> SignalProducer<[ChildModel], NoError>{
return SignalProducer { emitter, disposable in
aggregateProducer.startWithNext { _ in
let allSelectedChildren = self.children.filter { $0.isSelected }
emitter.sendNext(allSelectedChildren)
}
}
}
let selectionProducer = selectionType == .Multi
? multiSelectChildrenProducer()
: singleSelectChildrenProducer()
selectionProducer.startWithSignal { signal, disposable in
totalSelected <~ signal.map { $0.count }
selectedChildren <~ signal
}
}
}
class ChildModel {
let name: String
var selectedSignal: MutableProperty<ChildModelState>!
init(name: String) {
self.name = name
selectedSignal = MutableProperty<ChildModelState>(ChildModelState(childModel: self, state: false))
}
var isSelected: Bool { return selectedSignal.value.state }
func toggleState() {
selectedSignal.value = ChildModelState(childModel: self, state: !selectedSignal.value.state)
}
func clearState() {
selectedSignal.value = ChildModelState(childModel: self, state: false)
}
}
struct ChildModelState {
let childModel: ChildModel
let state: Bool
}
Update 1
So I solved the problem by coming up with some rules about how my view model object graph changes:
1) have two sets of observables, one set to represent incoming events and the other set are state properties which represents viewModel state; state that can be read or bound to.
2) changes to the state properties can only be done when "observing events", never while observing properties. This means that one state property should never change itself or another state property and observe the change.
3) incoming events cannot kick off other events i.e. one event per stack / runloop loop
The code now looks like this:
// MAIN METHOD
let model = Model(selectionType: .Multi)
model.children[0].tapEvent.bind(SignalProducer(value: Void()))
model.children[1].tapEvent.bind(SignalProducer(value: Void()))
print("selected:\(model.selectedChildren.value.filter { $0.isSelected }.map { $0.name }), count: \(model.totalSelected.value)")
// TYPES
enum SelectionType { case Single, Multi }
class Model {
let children = [ChildModel(name: "child1"), ChildModel(name: "child2")]
let totalSelected = MutableProperty<Int>(0)
let selectedChildren = MutableProperty<[ChildModel]>([])
init(selectionType: SelectionType) {
// watch events and update state properties
let allTapProducer = SignalProducer(values: children.map { $0.tapEvent.producer }).flatten(.Merge)
allTapProducer.startWithNext { child in
if selectionType == .Single {
self.children
.filter { $0.isSelected && $0.name != child.name }
.forEach { $0.clearState() }
}
child.toggleState()
}
//watch state properties and bind to other state properties
SignalProducer(values: children.map { $0.selectedSignal.producer })
.flatten(.Merge)
.map { _ in self.children.filter { $0.isSelected } }
.startWithSignal { signal, disposable in
totalSelected <~ signal.map { $0.count }
selectedChildren <~ signal
}
}
}
class ChildModel {
let name: String
var selectedSignal = MutableProperty<Bool>(false)
var tapEvent: UIEventSignal<ChildModel>!
init(name: String) {
self.name = name
tapEvent = UIEventSignal<ChildModel>(sender: self)
}
var isSelected: Bool { return selectedSignal.value }
func toggleState() {
selectedSignal.value = !self.selectedSignal.value
}
func clearState() {
selectedSignal.value = false
}
}
class UIEventSignal<T> {
private let property = MutableProperty<T?>(nil)
let sender: T
init(sender: T) {
self.sender = sender
}
func bind(uiSignalProducer: SignalProducer<Void, NoError>) {
property <~ uiSignalProducer.map { [weak self] _ in self?.sender }
}
var producer: SignalProducer<T, NoError> {
return property.producer.filter { $0 != nil }.map { $0! }
}
}
This pattern feels like it will keep things simple as the object graph relationships become more complex...maybe. I'll probably go with this for now but please suggest any alternative patterns or even alternative libraries to ReactiveCocoa.