Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Filtering NSTable while typing into NSTextField - auto-select first row

I have a NSTextView field which filters a NSTable table as user types in the input. I have successfully implemented table filtering.

Now, my goal is to auto-select the first result (the first row in the table) and allow user to use arrow keys to move between the results while typing the search query. When moving between the results in the table, the input field should stay focused. (This is similar to how Spotlight works).

This is how the app looks now:

app

This is my ViewController:

import Cocoa

class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate {

    @IBOutlet weak var field: NSTextField!
    @IBOutlet weak var table: NSTableView!

    var projects: [Project] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        projects = Project.all()

        field.delegate = self
        table.dataSource = self
        table.delegate = self
    }

    override func controlTextDidChange(_ obj: Notification) {
        let query = (obj.object as! NSTextField).stringValue

        projects = Project.all().filter { $0.title.contains(query) }

        table.reloadData()
    }

    func numberOfRows(in tableView: NSTableView) -> Int {
        return projects.count
    }

    func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
        if let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "FirstCell"), owner: nil) as? NSTableCellView {
            cell.textField?.stringValue = projects[row].title
            return cell
        }

        return nil
    }
}

and this is Project class

struct Project {
    var title: String = ""

    static func all() -> [Project] {
        return [
            Project(title: "first project"),
            Project(title: "second project"),
            Project(title: "third project"),
            Project(title: "fourth project"),
        ];
    }
}

Thank you

like image 628
Jerguš Lejko Avatar asked Jan 27 '18 22:01

Jerguš Lejko


2 Answers

This kinda, sorta has an answer already in the duplicate posted by @Willeke, but 1) that answer is in Objective-C, not Swift, 2) I can provide a somewhat more detailed answer (with pictures!), and 3) I'm brazenly going after the bounty (Rule of Acquisition #110). So, with that in mind, here's how I'd implement what you're trying to do:

Don't use an NSTextView; use an NSTextField, or even better, an NSSearchField. NSSearchField is great because we can set it up in Interface Builder to create the filter predicate with almost no code. All we have to do to do that is to create an NSPredicate property in our view controller, and then set up the search field's Bindings Inspector to point to it:

enter image description here

Then you can create an Array Controller, with its Filter Predicate bound to that same property, and its Content Array binding bound to a property on the view controller:

enter image description here

And, of course, bind the table view to the Array Controller:

enter image description here

Last but not least, bind the text field in your table's cell view to the title property:

enter image description here

With all that set up in Interface Builder, we hardly need any code. All we need is the definition of the Project class (all properties need to be marked @objc so that the Cocoa Bindings system can see them):

class Project: NSObject {
    @objc let title: String

    init(title: String) {
        self.title = title
        super.init()
    }
}

We also need properties on our view controller for the projects, array controller, and filter predicate. The filter predicate needs to be dynamic so that Cocoa Bindings can be notified when it changes and update the UI. If projects can change, make that dynamic too so that any changes to it will be reflected in the UI (otherwise, you can get rid of dynamic and just make it @objc let).

class ViewController: NSViewController {
    @IBOutlet var arrayController: NSArrayController!

    @objc dynamic var projects = [
        Project(title: "Foo"),
        Project(title: "Bar"),
        Project(title: "Baz"),
        Project(title: "Qux")
    ]

    @objc dynamic var filterPredicate: NSPredicate? = nil
}

And, last but not least, an extension on our view controller conforming it to NSSearchFieldDelegate (or NSTextFieldDelegate if you're using an NSTextField instead of an NSSearchField), on which we'll implement the control(:textView:doCommandBy:) method. Basically we intercept text-editing commands being performed by the search field's field editor, and if we get moveUp: or moveDown:, return true to tell the field editor that we will be handling those commands instead. For everything other than those two selectors, return false to tell the field editor to do what it'd normally do.

Note that this is the reason that you should use an NSTextField or NSSearchField rather than an NSTextView; this delegate method will only be called for NSControl subclasses, which NSTextView is not.

extension ViewController: NSSearchFieldDelegate {
    func control(_: NSControl, textView _: NSTextView, doCommandBy selector: Selector) -> Bool {
        switch selector {
        case #selector(NSResponder.moveUp(_:)):
            self.arrayController.selectPrevious(self)
            return true
        case #selector(NSResponder.moveDown(_:)):
            self.arrayController.selectNext(self)
            return true
        default:
            return false
        }
    }
}

Voilà!

(Of course, if you prefer to populate the table view manually instead of using bindings, you can ignore most of this and just implement control(:textView:doCommandBy:), updating your table's selection manually instead of asking your array controller to do it. Using bindings, of course, results in nice, clean code, which is why I prefer it.)

like image 154
Charles Srstka Avatar answered Oct 21 '22 17:10

Charles Srstka


As @Willeke points out, this is likely a duplicate. The solution from that other question works here. I've converted it to swift and added some explanation.

I tested this with an NSSearchField instead of an NSTextField, but I expect it should work the same.

First, you need to add the NSControlTextEditingDelegate protocol to your ViewController, and add the following function:

func control(_ control: NSControl, textView: NSTextView,
             doCommandBy commandSelector: Selector) -> Bool {
    if commandSelector == #selector(moveUp(_:)) {
        table.keyDown(with: NSApp.currentEvent!)
        return true
    } else if commandSelector == #selector(moveDown(_:)) {
        table.keyDown(with: NSApp.currentEvent!)
        return true
    }
    return false
}

You've already set the text field's delegate to the ViewController, so you're all set there.

This will cause your NSTextField to first check the delegate before executing the moveUp(_:) selector (triggered by pressing the up arrow). Here, the function responds saying "don't do what you normally do, the delegate will handle it" (by returning true) and sends the event to the NSTableView object instead. Focus is not lost on the text field.

like image 37
drootang Avatar answered Oct 21 '22 17:10

drootang