NSTokenField is particularly tricky to use with Core Data bindings. I wanted to model a to-many relationship between an object (specifically Client, Project, etc.) and multiple Tags. The user would select e.g. a client from a table view, and in the inspector panel a token field would represent the assigned tags, which the user can edit. It’s a typical master-detail configuration. Tags that already exist are autocompleted; non-existent tags are created once the focus leaves the field. When the user removes tags from the field, they are also automatically removed from the relationship by Core Data.

As the default behavior of NSTokenField is not going to suffice, and I’m going to be using tag fields fairly often in my project, a little bit of code is needed.

import Cocoa

protocol TagsFieldController {
    weak var tagsTokenField: NSTokenField! { get set }
    weak var completionsArrayController: NSArrayController! { get set }
    
    func autocomplete(substring: String) -> [String]
}

extension TagsFieldController {
    func autocomplete(substring: String) -> [String] {
        guard let completionsArray = completionsArrayController.arrangedObjects as? NSArray else {
            return []
        }

        guard let tokens = tagsTokenField.objectValue as? NSArray else { return [] }
        // Get trimmed text from field.
        let token = substring.trimmingCharacters(in: CharacterSet(charactersIn: " "))
        
        // Remove from completions array tokens already selected.
        let filteredCompletions = completionsArray.filter({(item: Any) -> Bool in
            guard let tag = item as? TagMO else { return false }
            return !tokens.contains(tag)
        }) as NSArray
        
        // Select words starting with what the user entered.
        let predicate = NSPredicate(format: "SELF.name beginswith[cd] %@", token)
        let completions = filteredCompletions.filtered(using: predicate).map({ (item) -> String in
            // Replace first character of tag name with the first character the user typed,
            // to preserve case in the field.
            return String(substring.first!) + String((item as! TagMO).name!.dropFirst())
        })
        return completions
    }
}

 

I’ll use this protocol to extend my View Controller, which is also the token field delegate:

class TagsTabViewController: NSViewController, TagsFieldController {
    @IBOutlet weak var arrayController: NSArrayController!
    @IBOutlet weak var tagsTokenField: NSTokenField!
    @IBOutlet weak var completionsArrayController: NSArrayController!
    
    @objc dynamic lazy var managedContext: NSManagedObjectContext = TagStore.instance.managedContext
}

extension TagsTabViewController: NSTokenFieldDelegate {
    func tokenField(_ tokenField: NSTokenField, completionsForSubstring substring: String, indexOfToken tokenIndex: Int, indexOfSelectedItem selectedIndex: UnsafeMutablePointer<Int>?) -> [Any]? {
        return autocomplete(substring: substring)
    }
    
    func tokenField(_ tokenField: NSTokenField, displayStringForRepresentedObject representedObject: Any) -> String? {
        guard let tag = representedObject as? TagMO else { return nil }
        return tag.name
    }
    
    func tokenField(_ tokenField: NSTokenField, editingStringForRepresentedObject representedObject: Any) -> String? {
        guard let tag = representedObject as? TagMO else { return nil }
        return tag.name
    }
    
    func tokenField(_ tokenField: NSTokenField, representedObjectForEditing editingString: String) -> Any? {
        return TagStore.instance.fetchByName(name: editingString)
    }
}

 

Before I get to bind the token field, I’ll need the necessary Array Controllers configured to access clients and tags.

Configuring the Array Controllers:

Once a client is selected in the table view, a Clients Array Controller will be bound to that selection so all properties of the selected object are available for binding.

The Tags Array Controller will be used to provide content to the token field for the selected client. As the tags relationship for a client is an NSSet, the Content Set property has to be bound to the Clients Array Controller with “selection” as the Controller Key and “tags” as Model Key Path.

Content Set for Tags Array Controller
Content Set for Tags Array Controller

Finally the Completions Array Controller will be used to access all tags to support token field autocomplete. There’s no special configuration for this controller, so it’s left as is.

Value Transformer for NSSet:

Because the NSTokenField stores tokens in an NSArray and the tags relationship is an NSSet, we need a custom ValueTransformer to convert between each other:

import Cocoa

class TagSetTransformer: ValueTransformer {
    override class func transformedValueClass() -> AnyClass {
        return NSArray.self
    }
    
    override class func allowsReverseTransformation() -> Bool {
        return true
    }
    
    override func transformedValue(_ value: Any?) -> Any? {
        guard value != nil else { return nil }
        if let set = value as? NSSet {
            return set.allObjects
        } else {
            return NSArray(object: value!)
        }
    }
    
    override func reverseTransformedValue(_ value: Any?) -> Any? {
        guard let array = value as? NSArray else { return nil }
        // Check all items are either TagMO or Strings, which then are
        // replaced with TagMO.
        let tags = array.map { (item) -> TagMO in
            if let tagName = item as? String {
                return TagStore.instance.createTag(name: tagName)
            } else {
                return item as! TagMO
            }
        }
        return NSSet(array: tags)
    }
}

extension NSValueTransformerName {
    static let tagSetTransformerName = NSValueTransformerName(rawValue: "TagSetTransformer")
}

Here the TagSetTransformer will automatically create Tag objects replacing string tokens as internal representation. Core Data will then create the relationship with the parent object.

Configuring the Token Field:

At last, binding the token field depends on the current client selection and the TagSetTransformer.

Value Binding for NSTokenField
Value Binding for NSTokenField

 

Conclusion:

Although the solution I implemented is a little more involved than usual when using other controls with Core Data, it proves to be effective for my purpose. I tried to abstract away much of the work into the TagsFieldController protocol and the TagSetTransformer, even though some of the code smells a bit. Ultimately it works as expected.

 

NSTokenField with Core Data Bindings: finally solved it
Tagged on:                 
Do NOT follow this link or you will be banned from the site!