What's the idiomatic way to access the NSDocument from an NSViewController, and how should the document update its views?

Or: How to efficiently implement MVC patterns in Cocoa + AppKit + Swift?


I'm trying to make a basic Mac app that reads data from the disk (or potentially the internet), with various views and view controllers (some of which may be very deeply nested) to act on that data. Of course I want to stick to the MVC paradigm and don't want the code to be too tightly coupled. I'm using Swift 4, Xcode 9 and Storyboards.


How should a UI control/view access the NSDocument? I see the following options:

  • Wire the button to the First Responder (with a User Defined action.) – But that could let something else besides the NSDocument receive the action.
  • Send the action to the button's NSViewController, and let the NSViewController's code dig up through the hierarchy with self.view.window?.windowController?.document as? MyDocument – Looks and feels ugly.
  • Use the NSViewController's representedObject to point to the NSDocument.


How should the NSDocument update the views? Who should it inform of changes; its NSWindowController, a custom delegate?


What are the "correct" ways to do these things? How much of it should (or can) be done from the Interface Builder/Storyboard UI, and how much from code?


Thanks in advance from a beginner starting out in Mac development and wanting to pick up the right habits from early on. 🙂

Replies

Currently, there are no really correct ways to answer these questions for an app using storyboards. That's because storyboards came to the Mac very late, and the old standard practices (using XIB files) don't translate very easily.


>> How should a UI control/view access the NSDocument?


None of the above. There is no good reason for views and view controllers to know that a NSDocument instance exists. Instead, according to the MVC pattern, they need to know what their data model is. As it happens, the document may be the container of the data model, but typically it's not relevant to view controllers.


The question is therefore, really, how do view controllers find the data model? In a storyboard world, the easiest way is to have your window controller subclass "inject" a reference to the data model into its content (root) view controller from its windowDidLoad override (e.g. by setting it into the "representedObject" property. Unfortunately, by the time execution gets to windowDidLoad, the entire view and view controller hierarchy has already been instantiated, so all of your viewDidLoad methods will see a nil representedObject. That means you need to defer view controller setup related to the model until the model reference actually appears, which means the view controller should observe its own "representedObject" property, and complete the setup in the observation handler.


Of course, there is some relationship between the document and the user interface (for example, the window title, the Window menu, and the interactions in the document window title bar). If you need to customize anything here, you can do this in your window controller subclass, which of course has a direct reference to the document.


>> How should the NSDocument update the views?


In general, the document shouldn't try to push changes out to views. Instead, view controllers can use KVO to observe model properties, and handle the notifications that arrive. In specific cases where a looser coupling is desired, the data model can issue notifications via the notification center, and other objects can observe the notifications they care about.


Note that in many cases, UI elements (views and controls) will use bindings, binding the displayed values to properties of the view controller, and these view controller properties will be "derived properties" from the actual model properties. (Another way of saying this is that a view controller will often have its own "derived" data model.) In any reasonably complex app, the pure data in the data model isn't suitble for direct use in a UI, and needs some massaging at the view controller, or some other controller in the data flow path.


All of this implies that in a reasonably complex app, you need to be familiar with KVC, KVO, KVO compliance, and bindings. This is not a trivial body of information, but it is possible to approach gradually. You also need to be familiar with the responder chains (both the event chain and the action chain), and the target-action pattern. All of this is your daily bread for Mac apps.

Thanks Quincey. My specific scenario, right now, is converting Apple's Exhibition sample to an NSDocument multi-window app.


In Exhibition, which is a simple image viewing app, the NSSplitView sidebar has an ImageCollectionListController: NSViewController that holds a list of folders, and a button which shows an Open Panel to add a new folder, all done within that view controller.


The parent NSSplitViewController wires 3 view controllers (sidebar with list of folders, content list of thumbnails in selected folder, and image viewer of selected thumbnail) through handlers and closures.


I want the NSDocument to hold the list of folders, and handle the background generation of thumbnails. So my specific questions are:


  • Who should show the Open File Panel? The user can invoke it from the File menu, or the "Add Folder" button in the NSSplitView sidebar. Should it be handled by the window controller, the NSDocument, or the root view controller?
  • How should data be added to and removed from NSDocument when a button is clicked in a view? (It may seem like I'm asking the same question again but this is more specifically about modifying data in NSDocument.)


I know I could hack this together in any number of ways and get the app outwardly working the way I want, but the goal of my exercise is to learn – and understand – good practices.


KVO and bindings seem kind of...unsavory...for a basic app like this, even though Swift 4 has made them easier. 🙂

In the existing app, the data model is this declaration in the ImageCollectionListController:


    var imageCollections = [ImageCollection]() {
        didSet {
            guard isViewLoaded else { return }


            reloadOutlineAndSelectFirstItemIfNecessary()
        }
    }


Actually, the array is the data model; the "didSet" is part of the controller logic. Although there is an initial value here, what actually happens is that the window controller sets this property to an array consisting of a single collection (Desktop Pictures).


So, I would do the following:


1. Create a custom object as the root of the data model hierarchy, and make "imageCollections" an array property of the root object. This is for convenience, because once you start persisting the data model, you'll likely find other attributes to save that don't fit within the array. The root object allows you to add them later without upsetting everything downstream.


2. When you set up your document, this custom object will be what you archive and unarchive for save and open. (For a new document, you can create the 1-element array that the window controller does now, using the setup code you pull out of the window controller.)


3. In your windowDidLoad override in the window controller, retrieve the value of "self.document.model" (assuming "model" is the custom document property that holds a reference to the root model object), and set this as the "model" property of the corresponding ImageCollectionListController. The ImageCollectionListController needs to be changed to treat a nil value as if it were a data model with an empty array.


Note there's a subtlety/danger here. If you keep the array as a property of the view controller, it gets messy because it's a value object. It's different, therefore, from the array that's in the data model. You're better off giving the view controller a reference that it can use to find the model array. (Alternatively, you can use an explict NSArray, which is a reference type, or you can create a custom reference type — i.e. class — that has array-like behavior. But you may as well leverage the data model reference, and avoid extra work.)


That's most of what you need to do to adapt the app to use a data model from a document, instead of a single global data model as it does now.


>> Who should show the Open File Panel?

>> How should data be added to and removed from NSDocument when a button is clicked in a view?


The most likely choice is ImageCollectionListController, since the changes affect the UI at its level. As I said earlier, the view controller updates the data model, not the document.


There is one wrinkle, though. The document does need to be informed when the data model changes, so that it knows the data is "dirty" and must be saved. This is one reason the view controller might need to refer to the document, except that it doesn't usually work out that in practice. Instead, when a change is made that affects the data model, the changed should be encapsulated in an undo action. In a document-based application, the window's undo manager is integrated with the document's undo manager, which means the undo action gets registered with document, which in turn makes the document dirty (and not-dirty again if the change is undone, automatically). If you don't implement undo, then you'll have to have the view controller dirty the document explicitly (or you can have the window controller observe changes somehow, and have it do the dirty work).

A couple of other points:


1. This sample app is not what I'd call "idiomatic" for its use of Cocoa. It does too much creation of the UI in code. This is not wrong or bad, but it's not what experienced Cocoa developers do, generally.


2. This sample app uses XIB files instead of storyboards. This is not wrong or bad, and XIBs are more familiar to most experienced Cocoa developers, but Apple is gently pushing storyboards for macOS, in spite of the fact that they're slightly harder to use. I wouldn't recommend you upset this sample app's applecart by trying to convert it use storyboards, but be aware that using storyboards is likely to become inevitable in the future.

>My specific scenario, right now, is converting Apple's Exhibition sample to an NSDocument multi-window app.


Given your storyboard/swift/xcode 9 mandate ➕ & the current level of mac dev community pessimism in general, I would not. You'll end up squandering time attempting to climb out of holes you'll only deal with once, where realistically, that energy (6th attempt?) could be better spent staying above ground, developing genuine habits that can be leveraged again and again.


If you must, save it for later when you need something to proof skills you've already learned.


Good luck, I'm optimistic you'll succeed in any case.


Ken

> This sample app is not what I'd call "idiomatic" for its use of Cocoa. It does too much creation of the UI in code. ... This sample app uses XIB files instead of storyboards.


Yes, I said "converting" but it's more that I'm rewriting an identical app, from scratch, using the NSDocument project template and Storyboards.


I also went over the XIB vs. Storyboard vs. UI-in-code debate and decided on Storyboards, mostly because I too want them to be The Future. I've already implemented the existing Exhibition UI in Storyboards, and left the model/logic in the view controllers as is, until I can decide how to let NSDocument step in there.


I chose the Exhibition sample in the absence of good, full-fledged guides in the official documentation, to serve as a starting point for learning Mac development, because it seems to be one of the latest samples from Apple – the "Lister" sample is more complex, but it's my next step.

>> I'm rewriting an identical app, from scratch, using NSDocument template and Storyboards.


Excellent choice of workflow.


>> left the model/logic in the view controllers as is, until I can decide how to let NSDocument step in there.


Generally, changing to NSDocument will have no effect on the view controllers. The document creates the window controller. (With a storyboard, there's explicit override code for this in your document subclass's makeWindowControllers method, provided by the Xcode template. With XIBs, you would normally override windowNibName instead.)


Generally, changing from XIBs to storyboards means that the controllers are instantiated by unarchiving, rather than via explicit calls to one of the "regular" initializers. In theory, you can move custom initialization code, if any, to the init(coder:) initializer. In practice, you don't do that because multiple objects are being unarchived, and you don't know the order. Similarly, although you can do things in (say) viewDidLoad, you only know that that view is loaded, not other views and view controllers, due to the order unpredictability. The point where everything is loaded is the window controller's windowDidLoad, or at the end of NSDocument's makeWIndowControllers (since NIB loading is synchronous).


>> I've already implemented the existing Exhibition UI in Storyboards


You should also move the rest of the UI, specifically the split view controllers, out of code into the storyboard, if you haven't done that already. There shouldn't be any code that creates a view controller or view (or a window controller other than by instantiating from a storyboard).


KMT >> You'll end up squandering time attempting to climb out of holes you'll only deal with once


I'm not so pessimistic. As sample code goes, it's not terrible. Of the code that isn't reusable as is, about half will go away when the view controllers are moved into the storyboard. But I guess Zak will find out one way or the other.

After a couple weeks of working on this I do have a working app, however I am still unable to find a good way of implementing MVC ideals and "separation of concerns" in AppKit + Cocoa + Swift 4, that doesn't feel hacky.


I want to keep the use of "@objc" to a minimum and use value types (Swift structs) wherever possible, but I have embraced NSNotificationCenter.


My biggest problem right now is: When to put template-provided “default” data in a newly-created NSDocument, and how to present that template in views of the newly-created window?


That is, when the user creates a new document, it may get pre-filled by some data depending on the user’s preferences (like the templates in Pages or Numbers etc.)


I am using NSDocumentController.makeUntitledDocument(ofType: ) -> NSDocument to add the data.


However, at that point in the message flow, not all of the windows, views, and their controllers have been loaded yet, of course. So using NSNotification, delegates, or representedObject.. doesn’t do anything, before the view heirarchy has been fully loaded.


How should I approach this?

>> I am using NSDocumentController.makeUntitledDocument(ofType: ) -> NSDocument to add the data.


I wouldn't. Apart from anything else, subclassing NSDocumentController (if that's what you meant you did) is a PITA. The usual way is to override NSDocument's "init(type:)" initializer, which the documentation recommends for this purpose.


https://developer.apple.com/documentation/appkit/nsdocument/1515159-init


>> So using NSNotification, delegates, or representedObject.. doesn’t do anything, before the view heirarchy has been fully loaded.


Typically you can use a keypath chain like this: view controller <- window controller <- document <- model. The only problem is that view controllers don't have a standard property that points to the window controller, so you have to provide this yourself. Then, a controller (usually in …DidLoad) observes the rest of this keypath, which may yield nil initially. If it does, there will be a future notification when the non-nil value pops out. You just need to ensure your controllers don't crash if the model isn't available "yet".