swiftvalidationnstableviewcocoa-bindings

NSTableView cell value may remain with invalid value after validateValue(_:forKey:) failed. How to prevent this?


(Note: full project for experiments may be found here: https://github.com/snechaev/cocoa-validation-question)

I have a simple two-column view-based NSTableView connected to the data using the Cocoa bindings via the `ArrayController:

The data model for the table is as follows

//Data is a property of WindowController
@objc let Data : NSMutableArray = NSMutableArray(array: [TestClass(Name: "1"),
                                                         TestClass(Name: "2"),
                                                         TestClass(Name: "3"),
                                                         TestClass(Name: "4"),
                                                         TestClass(Name: "5")])


class TestClass: NSObject {
    @objc let Name : String!
    @objc var Value : String?
    
    init(Name: String!) {
        self.Name = Name
        self.Value = Name
    }
    
    override func validateValue(_ ioValue: AutoreleasingUnsafeMutablePointer<AnyObject?>, 
                                  forKey inKey: String) throws {
        if(inKey == #keyPath(Value)){
            guard let strVal = ioValue.pointee as? String 
            else {throw MyError.error("Value should be a string")}

            if(!strVal.starts(with: "1")){
                throw MyError.error("Value should starts with 1");
            }
        } 
    }
}

The model implements validation for the Value parameter values using the validateValue(_:forKey:) override. The validation works fine except for the following case:

The result is that the edited cell has lost the focus, but remains with the invalid value. In addition, when the user returns to edit mode for this cell and presses Enter, no error message is displayed. And the cell will still remains with the invalid value, so the user may think that this value is fully valid. If we inspect the data model we will clearly see that the invalid value was not assigned in the corresponding TestClass instance (which is ok).

So the question is how to handle such a situation so as not to mislead the user? It seems to me that the best way is to restore the initial value when Esc is pressed, but I can't find a way to do this. And maybe Apple's guidelines advise the other behavior for this situation?


Solution

  • It looks like a bug in the view based NSTableView. The cell based NSTableView and a NSTextField outside a table view do nothing when Esc is pressed and beep. Validating the data in a formatter has a similar issue.

    Workaround 1 in a NSTextField subclass:

    class TextField: NSTextField {
    
        override func abortEditing() -> Bool {
            // bugfix, the data isn't restored after an error
    
            let aborted = super.abortEditing()
            
            // restore the data
            if aborted,
                let bindingInfo = infoForBinding(.value),
                let object = bindingInfo[.observedObject] as? NSObject,
                let keyPath = bindingInfo[.observedKeyPath] as? String {
                objectValue = object.value(forKeyPath: keyPath)
            }
            
            return aborted
        }
        
    }
    

    Workaround 2 in a NSTableView subclass:

    class TableView: NSTableView {
    
        override func cancelOperation(_ sender: Any?) {
            // bugfix, the data isn't restored after an error
            
            // get the edited control
            var control: NSControl?
            if let firstResponder = window?.firstResponder {
                if let fieldEditor = firstResponder as? NSTextView,
                    let delegate = fieldEditor.delegate as? NSControl {
                    control = delegate
                }
                else if let firstResponder = firstResponder as? NSControl {
                    control = firstResponder
                }
            }
            
            super.cancelOperation(sender)
            
            // restore the data
            if let control = control,
                let bindingInfo = control.infoForBinding(.value),
                let object = bindingInfo[.observedObject] as? NSObject,
                let keyPath = bindingInfo[.observedKeyPath] as? String {
                control.objectValue = object.value(forKeyPath: keyPath)
            }
        }
        
    }
    

    And maybe Apple's guidelines advise the other behavior for this situation?

    The outline views in Finder and Xcode always discard the change if it's invalid. I don't know how to do this and I think it's more user-friendly to be able to fix a typo.