NSViewController User Interface State Restoration - swift

Summary:
What's the proper way to save/restore the state of an NSSearchField in my NSViewController using the built-in user interface preservation mechanism of Cocoa?
Details:
I'm working on my first macOS app and I'm having a little trouble with state restoration for my user interface. So far I have it working to the point where the encodeRestorableState(with:) and restoreState(with:) methods are called in my NSViewController subclass.
I have an NSSearchField in my view controller and I want to save/restore the state of the search field including its text, any selection, the cursor position, and whether it is currently in focus or not.
If I use the following code, the text is properly saved and restored:
override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder)
coder.encode(searchField.stringValue, forKey: "searchText")
}
override func restoreState(with coder: NSCoder) {
super.restoreState(with: coder)
if let searchText = coder.decodeObject(forKey: "searchText") as? String {
searchField.stringValue = searchText
}
}
Obviously I can add more code to save/restore the search field's selection and cursor position, etc.
My real question is, is there a better, proper, more automatic way to save and restore the search field's state? Or is it required that I write my own code for each attribute of the search field I wish to save?
I tried using:
searchField.encodeRestorableState(with: coder)
and:
searchField.restoreState(with: coder)
in the two above methods but that didn't result in anything appearing in the search field when my app was restarted.
I also implemented:
override class func restorableStateKeyPaths() -> [String] {
var keys = super.restorableStateKeyPaths()
keys.append("searchField.stringValue")
return keys
}
where "searchField" is the name of the NSTextField outlet property in my view controller. This method is called when my app is launched but the text was not restored in the search field.
This view controller is created from a storyboard. The view controller's identifier is set. The search field's identifier is set as well. This view controller is a child view controller of another view controller.
I've read through the User Interface Preservation section of the "The Core App Design" document but it's unclear on how a view controller's views are saved/restored and how much of this is automatic versus manual.
Supporting OSX 10.12 and 10.11. Objective-C or Swift in any answers is fine.

To achieve aforementioned effect let's create NSSearchField and a custom class named RestoredWindow containing just one property:
import Cocoa
class RestoredWindow: NSWindow {
override class var restorableStateKeyPaths: [String] {
return ["self.contentViewController.searchField.stringValue"]
}
}
Assign this custom class to Window Controller in Identity Inspector.
Next, let's bind searchField.stringValue property to ViewController in Value section of Bindings Inspector.
import Cocoa
class ViewController: NSViewController {
#IBOutlet weak var searchField: NSSearchField!
override func viewDidLoad() {
super.viewDidLoad()
}
}
After this, make sure you haven't checked Close windows when quitting an app option in your System Preferences -> General tab.
Now, entered text in NSSearchField restored after quitting and launching the app again.
P.S.
I've tried the same way as you but failed too. This approach doesn't work for me:
override func encodeRestorableState(with coder: NSCoder) {
coder.encode(searchField.stringValue, forKey: "restore")
super.encodeRestorableState(with: coder)
}
override func restoreState(with coder: NSCoder) {
if let state = coder.decodeObject(forKey: "restore") as? NSSearchField {
searchField.stringValue = state
}
super.restoreState(with: coder)
}

Related

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:
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
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:
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:
And, of course, bind the table view to the Array Controller:
Last but not least, bind the text field in your table's cell view to the title property:
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.)
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.

Object is nil although it was set in init

I have an object called navigator, which I set within init. I break on it to make sure it is set. However when an IBAction func, linkButtonClicked, get's called and try's to use navigator I get a nil exception. Why?
class HomeCollectionViewCell: UICollectionViewCell {
let appDelegate:AppDelegate!
let navigator: Navigator!
#IBOutlet weak var linkButton: UIButton!
var destinationView:String?
var parentViewController:UIViewController?
#IBAction func linkButtonClicked(_ sender: Any) {
do {
try self.navigator.navigate(to: self.destinationView!, from: parentViewController!)
} catch {
}
}
required init?(coder aDecoder: NSCoder) {
self.appDelegate = UIApplication.shared.delegate as! AppDelegate
self.navigator = self.appDelegate.navigator
super.init(coder: aDecoder)
}
override func prepareForReuse() {
super.prepareForReuse()
// do resetting here if needed, like empty out data
linkButton.setTitle(nil, for: .normal)
}
}
The init?(coder: NSCoder) initializer gets used when you are retrieving the object from some kind of encoded store such as Core Data. This initializer is required by the NSCoding protocol and is used only for deserializing the object. Therefore, it does not get called at object creation. It only gets called if you serialize the object using NSCoding and later deserialize it.
The function you want to override in order to ensure some value will be set in your view is not its init (and if you really want to use its init, the method to overload is init(frame:)). Instead, you should set any variables you want to be available in the viewDidLoad method of the view controller.

ViewController + Storyboard setting up validation with controlTextDidChange

Trying to setup validation for a few text fields in a new (and very small) Swift Mac app. Following various other topics here on SO and a few other examples, I can still not get controlTextDidChange to propagate (to my ViewController).
E.g: How to live check a NSTextField - Swift OS X
I have read at least a dozen variations of basically that same concept. Since none of the accepted answers seem to work I am just getting more and more confused by something which is generally a fairly simple task on most platforms.
I have controlTextDidChange implemented to just call NSLog to let me know if I get anything.
AppDelegate should be part of the responder chain and should eventually handle controlTextDidChange but I see nothing there either.
Using the current Xcode I start a new project. Cocoa app, Swift, Storyboard and nothing else.
From what I can gather the below isolated example should work. In my actual app I have tried some ways of inserting the ViewController into the responder chain. Some answers I found suggested it was not always there. I also tried manually adding the ViewController as the delegate in code theTextField.delegate = self
Nothing I have done seems to get text changed to trigger any events.
Any ideas why I have so much trouble setting up this delegation?
My single textfield example app
Storyboard is about as simple as it gets:
AppDelegate
import Cocoa
#NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate, NSTextFieldDelegate, NSTextDelegate {
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
func controlTextDidChange(notification: NSNotification) {
let object = notification.object as! NSTextField
NSLog("AppDelegate::controlTextDidChange")
NSLog("field contains: \(object.stringValue)")
}
}
ViewController
import Cocoa
class ViewController: NSViewController, NSTextFieldDelegate, NSTextDelegate {
#IBOutlet var theTextField: NSTextField!
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
}
override var representedObject: Any? {
didSet {
// Update the view, if already loaded.
}
}
func controlTextDidChange(notification: NSNotification) {
let object = notification.object as! NSTextField
NSLog("ViewController::controlTextDidChange")
NSLog("field contains: \(object.stringValue)")
}
}
I think the samples you're following are a bit out-of-date.
Try...
override func controlTextDidChange(_ notification: Notification) {
...as the function definition for your method in your NSTextFieldDelegate.

textDidChange points to the wrong NSCollectionViewItem

I am trying to build an NSCollectionView filled with multiple editable TextViews. (OS X app in Swift.) My subclass of NSCollectionViewItem is called NoteViewItem. I am trying to have the program detect when one of the TextView has changed. I tried using both controlTextDidChange and textDidChange in the NoteViewItem's delegate with test print statement to see which would work. ControlTextDidChange did nothing; textDidChange recognized a change happened, so I went with that.
The problem is that textDidChange appears to point to a different NoteViewItem than the one that was shown on screen in the first place. It wasn't able to recognize the variable (called theNote) set in the original NoteViewItem; when I ask NoteViewItem to print String(self), I get two different results, one while setting the initial text and one in textDidChange. I'm wondering if I've set up my delegates and outlets wrongly. Any thoughts on why my references are off here?
Here's my code for NoteViewItem:
import Cocoa
class NoteViewItem: NSCollectionViewItem, NSTextViewDelegate
{
// MARK: Variables
#IBOutlet weak var theLabel: NSTextField!
#IBOutlet var theTextView: NSTextView!
var theNote: Note?
{
didSet
{
// Pre: The NoteViewItem's theNote property is set.
// Post: This observer has set the content of the *item's text view*, and label if it has one.
guard viewLoaded else { return }
if let theNote = theNote
{
// textField?.stringValue = theNote.noteText
theLabel.stringValue = theNote.filename
theTextView.string = theNote.noteText
theTextView.display()
print("theTextView.string set to "+theTextView.string!+" in NoteViewItem "+String(self))
}
else
{
theLabel.stringValue = "Empty note?"
}
}
}
// MARK: Functions
override func viewDidLoad() {
super.viewDidLoad()
// Do view setup here.
// Hopefully this will set the note's background to white.
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.whiteColor().CGColor
}
// MARK: - NSTextViewDelegate
/*
override func controlTextDidChange(notification: NSNotification)
{
print("Control text changed.")
}
*/
func textDidChange(notification: NSNotification)
{
if let noteyMcNoteface = theNote
{
print("On edit, we have a note: "+String(noteyMcNoteface))
}
else
{
print("On edit, we have no note. I am NoteViewItem "+String(self))
}
}
}
I figured it out. My delegate, in the TextView, was connected to the wrong object in the Interface Builder for NoteViewItem.xib. I had connected it to the object labelled Note View Item, under objects in the outline. It should have been connected to File's Owner instead, since File's Owner stands for the NoteViewItem.swift class associated with the xib.
You'd think that if you want to connect the delegate to the NoteViewItem class and there is exactly one Note View Item listed in the outline, then that Note View Item is the thing you want to connect it to. Nope, you connect it to something entirely different that isn't called the Note View Item but is the Note View Item. I'm glad Interface Builder makes things so simple.

XCode: Swift strange behaviour and errors

I'm new to Swift, I'm trying to create a new Cocoa Application based on the following code:
Drag and Drop in Swift - Issues with Registering for Dragged Types?
I'm doing this:
create Swift Cocoa Application
add a Custom View
add a Swift Class subclass of NSView named DropView
set the Custom View class to DropView
paste the referenced code in DropView
Then I'm getting a few errors, and I'm not really understanding why and how to solve it. Can someone help me?
As the first error says, you need to add the override to your init method:
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
}
Also there are some changes in the draggingEntered and the draggingUpdated methods. You don't need the ! for your NSDraggingInfo anymore:
override func draggingEntered(sender: NSDraggingInfo) -> NSDragOperation {
return NSDragOperation.Copy
}
override func draggingUpdated(sender: NSDraggingInfo) -> NSDragOperation {
return NSDragOperation.Copy
}
To check the correct implementation of a method you want to override, just start typing the method-name in your file and Xcode will create you the method with header etc:
To fix your last error, you need to implement the required init the NSView needs:
required init?(coder: NSCoder) {
super.init(coder: coder)
}
To help you for further errors like these: Often Xcode itself helps you to solve the problems. Especially if you see the round error-circle on the left side of your editor. Just click on it and check what Xcode recommends: