Update menu from within RxSwift subscribe - swift

I am updating a menu (adding, deleting item) from within a RxSwift subscriber. This is how the menu supposed to look like:
The "Item A" will be continuously added and removed, depending on changes of the model, like the following:
// Using ObservableArray (https://github.com/safx/ObservableArray-RxSwift)
model.changeset.rx()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { (changes) in
// Inserts
for change in changes.insertedElements {
guard let item = self.newItem(item: change) else { continue }
let index = (self.view?.indexPlaceholder(at: .specialItem) ?? 0) + 1
// self.view is a NSMenu Object, so menu item will be added here
self.view?.insertItem(bridgeItem, at: index)
}
// Deletes
for change in changes.deletedElements {
guard let menuItems = self.view?.getItems(withIdentifier: .specialItem) else { continue }
guard let newIndex = menuBridgeObjects.firstIndex(where: {
...
}
let index = (self.view?.indexPlaceholder(at: .specialItem) ?? 0) + 1 + index
// self.view is a NSMenu Object, so menu item will be removed here
self.view?.removeItem(safe: index, onlyIf: .specialItem)
}
}).disposed(by: disposeBag)
}
The model.changeset will be populated or altered based on several network activities running in the background.
However, I have realized that while the menu is open, any modifications to the model.changeset and the menu changes through the subscriber, the menu looks like this (missing the separator item below "Item A"):
After closing the menu (tracking lost) and re-open again (no changes on the model this time, so code snippet above will not be triggered), the menu looks as it was supposed to be like this:
I already tried something like NSMenu.update(), but this is somehow not helping to draw the NSMenu properly while open. Do you know if I have overseen something very important here?

The code above will be enabled and triggered immediately after NSMenu's delegate func menuWillOpen(_ menu: NSMenu)
The above feels wrong to me. You should instead setup the code to trigger any time the array changes, don't tie it to menuWillOpen(_:). That way the menu items array will always be in the correct state when the menu opens.

Related

RxSwift: FlatMap on nested observables

I have this editor view model that I use in different other view models. The parent view models have a selectable user, once a user is selected, I'm gonna need a new instance of the editor with the new user.
This is a simplified version of the editor and a parent.
class EditorViewModel {
let user: String
let item = PublishSubject<String>()
init(user: String) {
self.user = user
}
}
class ParentViewModel {
var editor: Observable<EditorViewModel>!
let user = BehaviorSubject<String?>(value: nil)
init() {
editor = user.compactMap { $0 }.map { EditorViewModel(user: $0) }
}
}
Once the editor saves an item, I expect to get the saved item by flatMaping the editor to its item. Like this:
let parent = ParentViewModel()
parent.editor.flatMapLatest { $0.item }.debug("item").subscribe(onNext: { item in
print("This doesn't print")
})
parent.editor.subscribe(onNext: { editor in
print("This one prints")
editor.item.onNext("something")
})
parent.user.onNext("1")
The flatMap line does subscribe but it never gets an item.
This is the output for running the code above in the playground:
2021-10-28 13:47:41.528: item -> subscribed
This one prints
Also, if you think this is too crazy a setup, I concur and am open to suggestions.
By default, Observables are cold. This means that each subscription works independently and in this case each subscription is getting a different EditorViewModel. (The .map { EditorViewModel(user: $0) } Observable will call its closure for each subscription.)
Adding a .share() or .share(replay: 1) after the .map { EditorViewModel(user: $0) } Observable will make it hot which means that all subscriptions will share the same effect.
As to your sub-question. I don't think I would setup such a system unless something outside of this code forced me to. Instead, I would pass an Observable into the EditorViewModel instead of a raw User. That way you don't need to rebuild editor view models every time the user changes.

SwiftUI. How to notify when the custom binding value changes?

I am a bit confused. I have some complex functionality. There are several lines with checkboxes. When you tap on the line ViewModel decides which lines to select and which lines to deselect. I created a custom binding for CheckBox, so it should change its state according to changes inside ViewModel. I try to update the binding using .update() method. However it does nothing. get of the binding is not called.
Here is my code:
let lineIndex = $0 + viewModel.getSectionFirstLineIndex(sectionIndex: sectionIndex)
let line = viewModel.getLine(index: lineIndex)
var checkBoxBinding = Binding<Bool>(
get: {
viewModel.isLineSelected(lineIndex: lineIndex)
}, set: { _ in
fatalError("Not supported")
}
)
HStack {
CheckBox(isSelected: checkBoxBinding)
Text(line)
}.padding(16).onTapGesture {
viewModel.didTapOnLine(lineIndex: lineIndex)
checkBoxBinding.update()
}
you could listen for changes on hstack with .onChange(viewModel.$isLineSelected) and then in the closure change the value of checkBoxBinding.

NSPopUpButton - Weird behavior when populating dynamic content with Cocoa Binding

I'm trying to create a NSPopUpButton with dynamic contents, this is my design:
+-------------+
| None | <-- Static
| Last Item | <-- Static
|-------------| <-- Separator
| History: | <-- Dynamic: "History:" / "No History"
| ... | <-- Dynamic
+-------------+
And here's my code for ViewController.swift:
class ViewController: NSViewController, NSMenuDelegate {
#objc dynamic var contents: [String] = ["None", "Last Item", ""]
#objc dynamic var selectedIndex: Int = 0
func updateContent() {
// update contents array
}
override func viewDidLoad() {
super.viewDidLoad()
updateContent()
// other code
}
func menuNeedsUpdate(_ menu: NSMenu) {
for (index, item) in menu.items.enumerated() {
if item.title == "" {
menu.items[index] = .separator()
} else if item.title == "History:" || item.title == "No History" {
menu.items[index].isEnabled = false
}
}
}
func menuDidClose(_ menu: NSMenu) {
print(selectedIndex)
}
}
I did the Cocoa Bindings with the interface builder. My NSPopUpButton's Content Values is bound to property contents, Selected Index is bound to selectedIndex. I set the ViewController object to be the delegate for NSPopUpButton's embedded NSMenu.
So, there is no problem with NSPopUpButton's content, but it checks whatever item I select in the NSPopUpButton and left them checked even if I select something else, eventually it becomes something like this:
And also if I open the menu (NSPopUpButton) and directly close it by not selecting any item in the menu (click anywhere other than the menu), it automatically selects the first item ("None") regardless of the previously selected item.
Then, I decided to monitor the value for selectedIndex after closing the menu by implementing menuDidClose(_:), it turns out selectedIndex is exactly what I selected previously (which is correct). This problem persists even after I deleted the binding for selectedIndex.
This is really weird and doesn't make any sense. Can anyone explain what is happening with this? And how can I properly populate a NSPopUpButton with a mixture of static and dynamic content?
The issue is caused by menu.items[index] = .separator(). It replaces the item array of the menu. The itemArray property of the popup button points to the item array of the menu and this property is not adjusted. The popup button can't find the menu item to switch off the check mark. Replace the menu item with
menu.removeItem(at: index)
menu.insertItem(NSMenuItem.separator(), at: index)
Or put a separator item in the menu in IB and use the Content Placement Tag setting of the binding to insert the bound items below the separator item.

Customize "Add" button in MultivaluedSection

I'm trying to change the "Add" button of a MultivaluedSection in Eureka. The current behavior is that when you click on the "Add" button, it creates a new empty cell in the MultivaluedSection.
What I would like to achieve, would be that when a user click on the "Add" button, it shows up a PushRow where the user can choose the initial value of the cell.
I had no luck with any of the two way I tried to get this behavior.
I first tried to create a custom class where I could completely change the MultivaluedSecion behavior :
class ExerciseMultivaluedSection : MultivaluedSection {
override public var addButtonProvider: ((MultivaluedSection) -> ButtonRow) = { _ in
let button = ButtonRow {
$0.title = "MyCustomAddButton"
$0.cellStyle = .value1
}.cellUpdate { cell, _ in
cell.textLabel?.textAlignment = .left
}
// Here i would link my button to a function
// that would trigger a PushRow, maybe through a segue ?
return button
}
required public init() {
super.init()
}
required public init<S>(_ elements: S) where S : Sequence, S.Element == BaseRow {
super.init(elements)
}
required public init(multivaluedOptions: MultivaluedOptions, header: String, footer: String, _ initializer: (MultivaluedSection) -> Void) {
super.init(header: header, footer: footer, {section in initializer(section as! ExerciseMultivaluedSection) })
}
}
However, it did not work because of this error : "Cannot override with a stored property 'addButtonProvider'"
Then, I tried to change the addButtonProvider at run time, but it did nothing :
let exerciseSection = MultivaluedSection(multivaluedOptions:[.Delete,.Reorder,.Insert],header:"Exercises")
exerciseSection.tag = "exercise"
exerciseSection.multivaluedRowToInsertAt = {idx in
let newRow = LabelRow(){row in
row.value = "TestValue"
let deleteAction = SwipeAction(style: .destructive, title: "DEL"){action,row,completion in
completion?(true)
}
row.trailingSwipe.actions = [deleteAction]
}
return newRow
}
exerciseSection.addButtonProvider = {section in
let addBtn = ButtonRow("Test add"){ row in
row.title = "Custom add button"
}
print("Custom add button" )
return addBtn
}
Even after that, my add button still shows "Add", and my print function never gets called. Why is that ?
Also, is one of these two ways a good one ? If not, what would be the "correct way" to achieve that ?
I'm using XCode 9.4.1 with iOS 11.4
If you are willing to fork and slightly change the Eureka source code, a very few changes will allow you to use a PushRow, or a custom ButtonRow, or any other Row as the AddButtonProvider in a MultiValuedSection.
The few necessary changes are documented in this following GitHub commit which shows the before and after source code changes:
https://github.com/TheCyberMike/Eureka/commit/bfcba0dd04bf0d11cb6ba526235ae4c10c2d73fd
In particular, using a PushRow can be tricky as the add button. I added information and example code to the Eureka GitHub site in the following issue's comments:
https://github.com/xmartlabs/Eureka/issues/1812
Subsequent comments may be added there by other users of Eureka.
=== From #1812 comment ===
I made a pretty simple change to Eureka in my app's fork to allow any Row to be used as the Add row. It allows the default ButtonRow to be used as-is and handled automatically. But it also allows any other row such as a PushRow to be used as the AddButtonProvider.
In my app, in multiple places, when an end-user presses the add button, I present them a popup list of choices they may add. They choose one, and I add that choice to the MultiValuedSection.
The simple changes are in this forked Eureka commit in my app:
https://github.com/TheCyberMike/Eureka/commit/bfcba0dd04bf0d11cb6ba526235ae4c10c2d73fd
Although I have not tested it in my own app, this change should also support allowing a custom ButtonRow for the MVS. But I think you will need to handle the tableview's .insert in an .onCellSelection in your own code for that custom ButtonRow. The statements you need to do that are in both the commit code above and the example below.
Using a PushRow as an AddButtonProvider is somewhat tricky, since the PushRow invokes a separate view controller to present the list, then returns to the original form's view controller.
So you must be sure NOT to rebuild the original form and its MultiValuedSection when viewWillAppear() and viewDidAppear() are called upon returning from the PushRow view controller.
Also, the choice itself is made within the PushRow view controller. The PushRow handles preserving that choice back from the PushRow view controller. But the .onChange is invoked while the PushRow view controller is still active. I used a DispatchQueue.main.async closure to handle deferring the tableview .insert call to when the original form's view controller is active.
To prevent the PushRow from showing the last choice made in its right-most grayed accessory field, one must nil out its .value. However, that triggers a .onChange too, which if you are not careful can cause an infinite loop situation. I just used a simple if statement to make sure the PushRow's value is not nil to prevent that loop (yes it could also be a guard statement).
Be sure to use the [weak self]'s to prevent memory leaks.
Below is an adapted example of usage code from my eContact Collect app (https://github.com/TheCyberMike/eContactCollect-iOS), where things like languages and data entry fields and email accounts are chosen from pre-defined lists. Again, this code WILL NOT WORK unless the cited source code changes are make.
self.mForm = form
form +++ MultivaluedSection(multivaluedOptions: [.Insert, .Delete, .Reorder], header: NSLocalizedString("Shown languages in order to-be-shown", comment:"")) { mvSection in
mvSection.tag = "mvs_langs"
mvSection.showInsertIconInAddButton = false
mvSection.addButtonProvider = { [weak self] section in
return PushRow(){ row in
// REMEMBER: CANNOT rebuild the form from viewWillAppear() ... the form must remain intact during the PushRow's view
// controller life cycle for this to work
row.title = NSLocalizedString("Select new language to add", comment:"")
row.tag = "add_new_lang"
row.selectorTitle = NSLocalizedString("Choose one", comment:"")
row.options = langOptionsArray
}.onChange { [weak self] chgRow in
// PushRow has returned a value selected from the PushRow's view controller;
// note our context is still within the PushRow's view controller, not the original FormViewController
if chgRow.value != nil { // this must be present to prevent an infinite loop
guard let tableView = chgRow.cell.formViewController()?.tableView, let indexPath = chgRow.indexPath else { return }
DispatchQueue.main.async {
// must dispatch this so the PushRow's SelectorViewController is dismissed first and the UI is back at the main FormViewController
// this triggers multivaluedRowToInsertAt() below
chgRow.cell.formViewController()?.tableView(tableView, commit: .insert, forRowAt: indexPath)
}
}
}
} // end of addButtonProvider
mvSection.multivaluedRowToInsertAt = { [weak self] index in
// a verified-new langRegion code was chosen by the end-user; get it from the PushRow
let fromPushRow = self!.mForm!.rowBy(tag: "add_new_lang") as! PushRow
let langRegionCode:String = fromPushRow.value!
// create a new ButtonRow based upon the new entry
let newRow = self!.makeLangButtonRow(forLang: langRegionCode)
fromPushRow.value = nil // clear out the PushRow's value so this newly chosen item does not remain "selected"
fromPushRow.reload() // note this will re-trigger .onChange in the PushRow so must ignore that re-trigger else infinite loop
return newRow // self.rowsHaveBeenAdded() will get invoked after this point
}
}

Observable being disposed ahead of time

I think it's better if I explain what I'm trying to achieve because I think the error is on my misunderstanding on how Observables work.
I have a UIViewController that contains a UITableView I'm also using RxSwift and RxDataSources, so I'm binding my tableView items like this:
vm.model
.debug()
.drive(tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
Where vm is a viewModel which contains:
self.model = self.network.provider.getMarkets()
.map { (markets: [Market]) -> [Row] in
var rows = [Row]()
for market in markets {
rows.append(.market(market: market))
}
return rows
}
.map { (rows: [Row]) -> [Model] in
return [Model(section: .market, items: rows)]
}
.shareReplay(1)
.asDriver(onErrorJustReturn: [])
Where model is:
var model: Driver<[Model]>
This all works great the first time, the tableview displays the items, but the print from the debug():
2017-04-28 20:07:21.382: MarketAndLanguageSelectionViewController.swift:36 (viewDidLoad()) -> subscribed
2017-04-28 20:07:22.287: MarketAndLanguageSelectionViewController.swift:36 (viewDidLoad()) -> Event next(*Multiple items*)
2017-04-28 20:07:22.289: MarketAndLanguageSelectionViewController.swift:36 (viewDidLoad()) -> Event completed
2017-04-28 20:07:22.289: MarketAndLanguageSelectionViewController.swift:36 (viewDidLoad()) -> isDisposed
The problem is I didn't want the datasource to dispose because I wan't to update it based on the user action. If the user clicks a tableViewCell I want to update the model. Any ideas on how can I achieve this?
Sorry for such a big question.
I'm guessing that network.provider.getMarkets() makes a network call which returns a single result and completes.
Now, getMarkets() is the source, and tableView.rx.items is the sink. Once the source completes, the chain is broken.
It sounds like what you want to do is create a new getMarkets Observable every time the user taps something, as well as calling getMarkets once for free. I would expect something like:
let markets = trigger.flatMap {
self.network.provider.getMarkets()
}.map { (markets: [Market]) -> [Row] in
var rows = [Row]()
for market in markets {
rows.append(.market(market: market))
}
return rows
}.map { (rows: [Row]) -> [Model] in
return [Model(section: .market, items: rows)]
}.startWith(self.network.provider.getMarkets())
.shareReplay(1)
.asDriver(onErrorJustReturn: [])
Note that the only real difference is the beginning trigger.flatMap {. Your source will then be the button or whatever the user taps on to cause the network update which won't complete until it's deleted.
(The above is untested code, but it should give you an idea of the shape you want.)