How to delete tokens from NSTokenField? [Swift 4.2]
Over the last few days, I spent a significant amount of time properly implementing a standard NSTokenField with some drag and drop functionality.
If you don’t exactly know, what a NSTokenField is, I suggest having a look into Apple’s Human Interface Guidelines, which provides a great explanation and some screenshots with examples.
A good starting point before implementing a NSTokenField is the Token Field Programming Guide for Cocoa. Although the document is no longer being updated, it covers the basics that are needed to get the “tokenizing effect” in a text field working. At a minimum, the class needs to adopt the NSTokenFieldCellDelegate protocol and implement the following 3 methods:
- tokenField(_:completionsForSubstring:indexOfToken:indexOfSelectedItem:)
- tokenField(_:representedObjectForEditing:)
- tokenField(_:displayStringForRepresentedObject:)
Despite inheriting from NSTextField and NSTextFieldCell, it, by default, lacks some common behavior, that a user expects. It makes me wonder why Apple decided to hand over the user experience to the developer and why they did not make it very easy to control in code.
Deleting a token from NSTokenField
Unfortunately, NSTokenFieldCellDelegate does not provide a method that is called when a token is deleted. That means you need to handle the delete event. The tricky part is to let users delete single incorrect characters (e.g. a typo), while at the same time allowing them to delete an entire token. That means you need to know:
- Was the delete key pressed?
- Do we delete one or multiple tokens or a character?
- Which token(s) do we need to delete (the first one, last one, or any other)?
How to detect a keyboard event in NSTokenField?
NSTokenField inherits from NSControl, that means we can use its instance method control(_:textView:doCommandBy:) and check the command selector, which in case of deletion is deleteBackward (or deleteForward).
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if control == myTokenField {
if commandSelector == #selector(NSStandardKeyBindingResponding.deleteBackward(_:)) { // insert logic to delete token next }
}
}
Please note, that we also need to check for our NSTokenField, because this method is called for all fields that inherit from NSControl.
Now, that we know it’s a delete event, we check what kind of deletion we need to perform.
How to find out if a token or a character is to be deleted?
The basic idea is to first check the type of the objects in the field and then compare the number of objects in the field with the number of objects in the string representation. So, what exactly does this mean?
NSControl (remember NSTokenField inherits from NSControl) has two instance properties, objectValue and stringValue. objectValue contains an array of objects, which are the representedObjects of NSTokenField and stringValue contains a comma-separated String representation of the NSTokenField objects.
When the user types any characters that do not match an existing represented object, NSTokenField will create a new object on-the-fly, to allow creating new tokens. Those new objects are added to a NSArray, while the represented objects are added directly to the objectValue array.
Knowing this, we can check if the type of the objectValue objects equals the type we expect for our represented object. If it does, we know that we need to delete an entire token. Then, we check the number of objects by splitting stringValue into an array and comparing it to the number of objects in objectValue.
if let anyObjectsArray = control.objectValue as? [Any], let tokenFieldObjects = anyObjectsArray as? [Tag] {
// now we continue deleting a token…
let stringObjectComponents = control.stringValue.split(separator: ",")
// check if the number of tokens is equal to the objects in the tokenField's stringValue if tokenFieldObjects.count == stringObjectComponents.count {
// Next, find out which objects to delete
}
}
We are certain, that we have to delete tokens. However, at this stage we don’t know which token or how many tokens will have to be deleted. Luckily, it’s relatively easy to find out (when you know it).
How to find out which token(s) to delete?
The instance method control(_:textView:doCommandBy:) also provides a NSTextView, which is super handy to have if you want to find out where the cursor is positioned in the token field. NSTextView has a method called rangeForUserTextChange which returns a NSRange consisting of a location and length component.
var textChangeLocation = textView.rangeForUserTextChange.location
let textChangeLength = textView.rangeForUserTextChange.length
We ignore length for the moment (we get back to it later), and focus on location. We simply need to check if location is less than the number of objects. If it is, we need to reduce it by 1 to get the index of the object in the array, because arrays are zero-indexed whereas NSRange starts at 1.
if textChangeLocation < tokenFieldObjects.count {
// NSRange is not zero-indexed
let index = textChangeLocation - 1
tokenFieldObjects.remove(at: index)
} else {
// textChangeLocation points to the last element
tokenFieldObjects.removeLast()
}
Et voilà! We are able to delete single tokens from NSTokenField. However, if you paid close attention to the video above, I also demonstrated selecting multiple tokens and delete them all at once.
How to delete multiple tokens at once?
In order to delete multiple tokens, we need to use the length component, that we previously ignored. As soon as there are multiple tokens selected, length will be greater than 0. To be precise, length will have the exact number of selected tokens.
So, thanks to location we know the start position and thanks to length we know how many tokens are selected. That means, we can write a simply loop from location to location+length (minus 1 because of the zero index). Swift provides us with the stride(from:to:by:) method which does exactly this.
// Delete selected tokens -> NSRange.length has number of selected tokens
if textChangeLength > 0 {
for i in stride(from: textChangeLocation, to: textChangeLocation+textChangeLength, by: 1) {
tokenFieldObjects.remove(at: i)
}
}
That’s it. Now, we can delete single tokens, multiple selected tokens as well as typos from NSTokenField.