Search code examples
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:

  • File owner for the window is my WindowController
  • ArrayController's Content Array is binded to the File owner's->Data enter image description here
  • The Value column content (which is TextField) is binded to the Table Cell View -> objectValue.Value. The "Validates immediately" option is turned on. enter image description here

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:

  • enter an invalid value, for example "456"
  • click "OK" in the error message
  • press Esc on the keyboard

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.