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
}
}
Related
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.
I have this function to return a reusable view when my side menu is clicked. When the function is called for the first time GameMenuView is initialized, but the next time it's called, the view is already initialized and I would like to reinitialize it since I want it to behave like a different view with onAppear being called and so on.
Any idea how to accomplish this?
func getGameMenuView(isGameOne: Bool, menuInteractor: MenuInteractor) -> some View {
let gameInteractor = GameInteractor(isGameOne: isGameOne)
return AnyView(
GameMenuView()
.environmentObject(menuInteractor)
.environmentObject(gameInteractor)
)
}
SwiftUI views are value types and provided function creates/initialized a new view, so I believe your issue is not in provided code but in code which uses this function.
Anyway try to give it unique ID (or in places where you use result of this function), this prevents SwiftUI equality optimization.
func getGameMenuView(isGameOne: Bool, menuInteractor: MenuInteractor) -> some View {
let gameInteractor = GameInteractor(isGameOne: isGameOne)
return AnyView(
GameMenuView().id(UUID()) // << here !!
.environmentObject(menuInteractor)
.environmentObject(gameInteractor)
)
}
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.
I came across this problem when testing my View:
In my ViewModel I call to an asynchronous operation and when the response arrives, I use a PublishSubject to produce a change in my View. In my View, I call DispatchQueue.main.async in order to hide or show a button.
ViewModel
let refreshButtons = PublishSubject<Bool>(true)
refreshButtons.onNext(true)
View
model.refreshButtons.asObservable()
.subscribe(onNext: {
[unowned self] success in
self.updateButtons(success)
})
.addDisposableTo(disposable)
private func updateButtons(_ show:Bool) {
DispatchQueue.main.async{
button.isHidden = !show
}
}
Now I don't know how to unit test that refreshButtons.onNext(true) will hide or show my button.
The solutions I can think of are:
Overriding the method and having an async expectation, but for that I need to make the method public, what I don't want, or
Dispatching the main queue in my ViewModel and not in the view, what it sounds odd to me, but might me ok.
How can I solve this?
Thank you in advance.
You could use an async expectation based on a predicate in your unit test to wait an see if the button is not hidden anymore.
func testButtonIsHidden() {
// Setup your objects
let view = ...
let viewModel = ...
// Define an NSPredicate to test your expectation
let predicate = NSPredicate(block: { input, _ in
guard let _view = input as? MyView else { return false }
return _view.button.isHidden == true
})
// Create an expectation that will periodically evaluate the predicate
// to decided whether it's fulfilled or not
_ = expectation(for: predicate, evaluatedWith: view, handler: .none)
// Call the method that should generate the behaviour you are expecting.
viewModel.methodThatShouldResultInButtonBeingHidden()
// Wait for the
waitForExpectationsWithTimeout(1) { error in
if let error = error {
XCTFail("waitForExpectationsWithTimeout errored: \(error)")
}
}
}
Something worth noting is that the value you pass to the NSPredicate should be a class. That is because classes are passed by reference, so value inside the predicate block will be the same as the one touched by your view model. If you were to pass a struct or enum though, which are passed by copy, the predicate block would receive a copy of the value as it is at the time of running the setup code, and it will always fail.
If instead you prefer to use UI tests as suggested by #Randall Wang in his answer, then this post might be useful for you: "How to test UI changes in Xcode 7". Full disclosure, I wrote that post.
First of all, You don't need test private method
If you want to test if the button is hidden or not,try UI testing
here is the WWDC of UI testing.
https://developer.apple.com/videos/play/wwdc2015/406/
I have a variable in my NSViewController:
dynamic var roll_rate:Double = 0.0
I attach it to my NSTextField:
Model Key Path shows error, but it is working: When i changed value in field, variable changed too. But what means:
Validates Immediately and how do i show and check validation errors for field.
I tried implement method validateRoll_rate, but it didn't call when value changed.
Generic solution (work with or without bindings) One way of dealing with this is based on the response here
Basically you use the controlTextDidChange(notification:) delegate method of NSTextField and you implement your validation code in it.
override func controlTextDidChange (notification: NSNotification) {
guard let textField = notification.object as? NSTextField else { return }
// test here, replace the dummy test below with something useful
if textField.stringValue != "expected value" {
myTextFieldOutlet.backgroundColor = NSColor.red
myErrorLabelOutlet.stringValue = "Error !!!"
} else {
// everything OK, reset the background color and error label to the normal state
....
}
}
Obviously myTextFieldOutlet is an outlet linked to your text field and myErrorLabelOutlet is an outlet to a conveniently placed label used to show errors (blank if no error should be presented)
Bindings oriented solution Be sure Validates immediately is selected in Interface Builder and implement the following method in the class where the binding is made (Tuning View Controller in your example)
override func validateValue(_ ioValue: AutoreleasingUnsafeMutablePointer<AnyObject?>, forKey inKey: String) throws {
// test here, replace the dummy test below with something useful
if roll_rate > 10.0 {
throw NSError(domain: "your-domain", code: 100, userInfo: [NSLocalizedDescriptionKey: "Error, roll rate too high"])
}
}
When the error is thrown, the user will be presented with the standard sheet announcing the error and the option to cancel the change or correct it.
If Continuously updates value is selected in Interface Builder the method above will be called for each keystroke in the text field, otherwise only after pressing Enter or loosing focus.
Note: For a full understanding on how updating values through bindings work, including what Validates immediately does, see the docs here.