NSPopUpButton - Weird behavior when populating dynamic content with Cocoa Binding - swift

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.

Related

Can you save the selection of a master-detail bound NSTableView?

I have a manager class for my data which is configured by two properties, one to set to a category and another to select items which correspond with that category. Based on that it will expose the relevant pieces of data. I am using a couple of different forms or making those selections, including a pair of IndexSets.
My problem is that I would also like to be able to save the selected items for each category, so that whenever the category is changed the items previously selected for it are restored. This is easy to achieve when accessed programmatically, but using bindings to allow a view in a macOS app to be able to provide that configuration unfortunately does not work properly
Changing the category causes the object bound to its selection to empty or 'preserve' the selected items before the category is actually updated. So the actual selection gets overwritten with, with noway I can see to tell the difference between this behaviour and a user action.
Here are the test code I have used for experimenting, with viewDidLoad generating some random test data to roughly mimic the structure o the real class. This does not attempt to save or restore the selection, but simply shows the overwriting behaviour.
class Thing: NSObject {
#objc dynamic var name: String
required init(name: String) {
self.name = name
}
}
class Stuff: NSObject {
#objc dynamic var name: String
#objc dynamic var things: [Thing]
required init(name: String, things: [Thing]) {
self.name = name
self.things = things
}
}
class StuffManager: NSObject {
#objc dynamic var stuff = [Stuff]()
#objc dynamic var stuffIndex = IndexSet() {
didSet {
print("STUFF: ", Array(stuffIndex))
}
}
#objc dynamic var things = [Thing]()
#objc dynamic var thingsIndex = IndexSet() {
didSet {
print("THING: ", Array(thingsIndex))
}
}
}
class ViewController: NSViewController {
#objc dynamic var stuffManager = StuffManager()
override func viewDidLoad() {
super.viewDidLoad()
(1...10).forEach { stuffManager.things.append(Thing(name: "Thing \($0)")) }
(1...9).forEach {
let randomThings = Array(stuffManager.things.shuffled()[0...Int.random(in: 0..<10)])
stuffManager.stuff.append(Stuff(name: "Collection \($0)", things: randomThings))
}
stuffManager.stuff.append(Stuff(name: "Collection 10", things: []))
}
}
In Interface Builder I have a view containing an NSPopButton to select the Stuff, a multiple selection NSTableView to select the Things, and a pair of NSArrayControllers for each. The bindings are:
Stuff Array Controller
Content Array:
Binding to: ViewController, Model Key Path: stuffManager.stuff
Selection Indexes:
Binding to: ViewController, Model Key Path: stuffManager.stuffIndex
Things Array Controller
Content Array:
Binding to: Stuff Array Controller, Controller Key: Selection, Model Key Path: things
Selection Indexes:
Binding to: ViewController, Model Key Path: stuffManager.thingIndex
The two interface objects are bound to these controllers in the standard way, the Content to the arrangedObjects and the Selection Indexes to the selectionIndexes of their respective array controller.
What this test code shows is that when the value in the popup button is changed the THING debug line appears before the STUFF debug line, that is it changes the selection of Things before it changes the Stuff. So any code in the property observer on stuffManager.things to save the new selection will save this change before being aware that the Stuff has changed.
Obviously this behaviour is to avoid the selection being made incorrect by the change to the content, or worse selecting out of bounds if the new content is shorter. But is there any way to detect when this is happening, rather than a user changing the selection? Or a way to override it to gain manual control over the process rather than having to accept the default behaviour of 'Preserve Selection' or the selection being cancelled if that option is disabled?
And what makes it more awkward is if this behaviour only occurs when the selection would change. If the selected Things exist for the new Stuff, or if nothing is selected, then nothing happens to trigger the property observer. Again this is understandable, but it prevents being able to cache the change and then only save the previous one if the Stuff has not changed.
I did wonder if using a separate IndexSet for each Stuff would avoid this problem, because then there would be no need for the NSTableView to manage the selection. I do not like the idea of keeping an IndexSet in the model but would accept it if it worked. But it does not. Again understandable, because the table view has no idea the Selection Indexes binding will be changed. Unless I am missing something?
But I tested this by updating the Stuff class to include the following:
#objc dynamic var selected = IndexSet() {
didSet {
print("THING: ", Array(selected))
}
}
Then changing the Selection Indexes binding of the Things Array Controller to:
Binding to: Stuff Array Controller, Controller Key: selection, Model Key Path: selected
Is what I am trying to achieve impossible? I would not have thought it that strange a thing to want to do, to save and restore a selection, but it seems impossible with bindings.
The only solution I can see is to forgo the master-detail style pattern and instead just maintain a separate [Thing] property in my data manager class, bind the Things Array Controller to this (or even just bind the table directly to the property), then whenever the popup button changes update the new property to match the stuff object.
Something like this in the StuffManager, with the table content bound to availableThings:
#objc dynamic var stuffIndex = IndexSet() {
didSet {
print("STUFF: ", Array(stuffIndex))
availableThings = stuff[stuffIndex.first!].things
}
}
#objc dynamic var availableThings = [Thing]()
It appears there is no way to prevent the NSTableView behaviour of automatically resetting its selection when the content changes. Nor any way to detect when this is happening, as it updates this before updating the selection on the NSPopupButton having changed. So here is how I have written the StuffManager class, adding a property for binding to the tableview so I can control the content changing:
class StuffManager: NSObject {
let defaults: UserDefaults = .standard
var canSaveThingsIndex = true
#objc dynamic var stuff = [Stuff]()
#objc dynamic var stuffIndex = IndexSet() {
didSet {
canSaveThingsIndex = false
if stuffIndex.count > 0 {
availableThings = stuff[stuffIndex.first!].things
let thing = stuff[stuffIndex.first!].name
if let items = defaults.object(forKey: thing) as? [Int] {
thingsIndex = IndexSet(items)
} else if availableThings.count > 0 {
thingsIndex = IndexSet(0..<availableThings.count)
} else {
thingsIndex.removeAll()
}
} else {
availableThings.removeAll()
thingsIndex.removeAll()
}
canSaveThingsIndex = true
}
}
#objc dynamic var things = [Thing]()
#objc dynamic var availableThings = [Thing]()
#objc dynamic var thingsIndex = IndexSet() {
didSet {
if canSaveThingsIndex && stuffIndex.count > 0 {
let thing = stuff[stuffIndex.first!].name
defaults.set(Array(thingsIndex), forKey: thing)
}
}
}
}
The Things Array Controller is now bound as:
Content Array:
Binding to: ViewController, Model Key Path: stuffManager.availableThings
Selection Indexes:
Binding to: ViewController, Model Key Path: stuffManager.thingsIndex
Though without being able to use the master-detail benefits of an NSArrayController they are not needed. Both the NSPopupButton and NSTableView can be bound directly to the StuffManager. And this allows the NSPopupButton's Selected Index can be bound to an Int int he Stuff Manager rather than needing to use an IndexSet despite multiple selections being impossible.
The main feature of the workaround is that because I am manually changing the content I can use the canSaveThingsIndex flag before changing the NSTableView content. So whenever its natural behaviour triggers the thingsIndex property observer, this can be ignored to prevent it overwriting the user's selection. It also avoids the unnecessary saving of a selection immediately after being restored.

NSComboBox does not display any text

I have a NSViewController with multiple NSComboBox instances.
I have the following code that adds the items to the NSComboBox and then selects an item:
#IBOutlet weak var monitors: NSComboBox!
fileprivate func fillMonitors()
{
self.monitors.removeAllItems()
var items: [String] = []
for screen in NSScreen.screens
{
items.append(screen.localizedName)
}
self.monitors.addItems(withObjectValues: items)
guard let primaryMonitor = self.configuration?.layout.displayName ?? NSScreen.main?.localizedName else { return }
if let index = items.firstIndex(of: primaryMonitor)
{
self.monitors.selectItem(at: index)
}
}
The items are added and the correct monitor is found which means that the following line selects the proper monitor:
self.monitors.selectItem(at: index)
However, the NSComboBox does not display any text whatsoever. Also, the opening of the dropdown feels very weird, sometimes it's closing again, but then it works properly.
When selecting another item, the event handler is called and the value is processed, so I don't think that this is a bug in my code. Has anyone experienced this before? I have tried to set the text directly using the stringValue but it also does not work. I am really lost here. Really appreciate any hints. I am not using a datasource (property set to false), the delegate is nil.
Using Ventura with Xcode 14.1 RC2.

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.

Update menu from within RxSwift subscribe

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.

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
}
}