Creating an OS X Core Data Helper App

Do you ever find you need to pre-populate an iOS Core Data app with some data and wanted to create and edit that data with a full OS X desktop app? If you have only ever developed for iOS you may not consider this approach because OS X seems at first to be more complicated. Convinced that it could not be that hard I thought I would give it a go and share my work.

I have an iOS Core Data example app in my GitHub repository which will serve as a test. The WorldFacts project uses a Core Data store to hold a list of Country objects containing some common facts about each country such as its capital, population, area, etc. The Core Data store is created on startup from a plist file. Our aim is to create an editable version of that core data store that we can managed on the Mac and then imported into the iOS app.

Creating An OS X Target

The first step is to add an OS X target to the WorldFacts Xcode project. With the project highlighted in the Xcode Navigator pane use the “+” button to add a target:

Choose the “Cocoa Application” template for the new target from the OS X section:

The product name for the target needs to be unique within the Xcode project so we will call the OS X target WorldFactsBuilder. We will also stick to Objective-C for now and use both Storyboards and Core Data as with the iOS project:

The Xcode project should now contain a new OS X target named WorldFactsBuilder with the basic template files for a core data OS X app stored in a new folder named after the target. (There is also be an OS X unit test target created by default but I will remove that for now).

OS X Application Architecture

It is interesting to take a look at the objects in the storyboard created by the Xcode template to get a feel for the architecture of a modern OS X application:

Application Object (NSApplication): Like the UIApplication object of an iOS application you do not normally subclass this object instead you work with the application delegate. Note that the main menu (NSMenu) is a property of the application object.

Application Delegate: The applicate delegate object implements the NSApplicationDelegate protocol in much the same way as an iOS application delegate implements the UIApplicationDelegate protocol. Many of the methods such as application[Will|Did]FinishLaunching: and applicationWillTerminate: will be recognisable to an iOS developer.

Window Controller (NSWindowController): This takes care of loading and managing a single window (it is the window delegate). You do not need to create the window as it is already contained in the Storyboard. In OS X 10.10 Yosemite the window controller has properties for an associated content view controller and the storyboard that loaded it:

@property (strong) NSViewController *contentViewController NS_AVAILABLE_MAC(10_10);
@property(readonly, strong) NSStoryboard *storyboard NS_AVAILABLE_MAC(10_10);

View Controllers (NSViewController): As with a UIViewController an NSViewController manages a view hierarchy. We will have a single view controller to manage a table view for our country data.

If you want to dig deeper see this earlier post on useful OS X resources.

Building the Storyboard

The Main Menu

Let’s start with the easy part and simplify the main menu by deleting the Format and View menus which we will not need:

We also need to fix a small problem with the template code for the File > Save command. In the template this command sends the SaveDocument: action to the first responder. The method in the application delegate that saves the core data stack is named SaveAction:. Delete the SaveDocument: action and control drag from the Save menu command to the first responder action and select SaveAction:.

Creating a Table View

For the main interface we have some choices. The iOS app uses a split view controller to display a summary list of the countries in a table view with a separate detail view controller. Yosemite also has an NSSplitViewController but I am going to keep it simple. Since we have more screen space we will show everything in a single table view.

To create the table view interface drag a table view from the object library into the view of the generic view controller. The result is, at first glance, a complex hierarchy of unfamiliar views:

If you compare with a UITableView on iOS much more of the view hierarchy is shown in Interface Builder. The top view is the scroll view which contains a clip view which contains the actual table view. The scroll bar objects and table header view are also directly visible in Interface Builder.

The other major difference with iOS is that our table has columns. Each column contains an NSTableCellView with a single NSTextField to display the country property. We have eight properties so we need eight columns in the table.

Click on the table view in the Interface Builder document outline and then in the attributes inspector increase the number of columns to “8”. Whilst in the attributes inspector you can also adjust the appearance and behaviour of the table view.

I kept the defaults apart from two settings as follows:

  • Alternating rows to highlight alternate rows.
  • Autosave including Column Information. Entering a name for the Table View causes the table setup such as column widths, order, etc. to be saved to the user defaults system and restored across application runs.

At this point you will probably need to resize the table view columns and the overall size of the view to make everything visible. I found it easier to set the table column widths to 100 and then resize the containing view so that the table view fits within the layout guidelines. Leave some extra space below the table view so we can add some buttons. I also set the name of each column in the table header:

Number Formatters

The Population and Area columns both show some large numbers which we want to format correctly.

  • Drag a Number Formatter from the Xcode object library to the NSTextFieldCell
  • In the Atributes inspector set the Style to Decimal and the minimum value to zero.

Adding buttons

We want to be able to add and delete rows so drag two round buttons below the table view and position them in the bottom left of the view. It does not matter which style of button you use as you can change it in the attributes inspector to match the screenshot below.

Pay attention to the following settings:

  • Style: Round
  • Title: Blank
  • Font: System Regular
  • Image: NSAddTemplate or NSRemoveTemplate
  • Position: Image Only (second option from the left, see screenshot)

If you end up with a wrongly sized button select it and use Editor > Size to Fit Content (⌘=) to make it match the image size. At this point the view with the buttons should look something like this:

Setting Autolayout Constraints

If you have used autolayout for iOS applications this is nothing new. We need constraints to make the scroll view and the two buttons fit inside the containing superview. I find it easiest to add the constraints from the document outline and then if necessary use the size inspector to set the spacing for each of the constraints to the “Standard” space.

  • Control drag from the Scroll View to the containing superview and add constraints for the leading, trailing and top space to container:

  • Control drag from the Add button to the containing superview and add leading and bottom space to container constraints. Then add vertical spacing between the Add button and the scroll view. Finally add horizontal spacing between the Add and Remove buttons.

  • Control Drag from the Remove button to the containing superview and add a bottom and trailing space to container constraints. Also add a vertical spacing constraint from the button to the scroll view. Use the size inspector to change the relation of the horizontal constraint to “Greater Than or Equal” to allow the space between the remove button and the right hand edge of the window to grow.

  • Finally select the Window in the Window Controller Scene and use the size inspector to set a minimum width and height for the window. (Note that you can also set the initial size and position of the window at this point if you wish):

If Xcode is showing autolayout issues you may need to update frames to position each of the views. At the time of writing I did find one autolayout issue with the clip view that I could not resolve.

Misplaced View: Frame for “Clip View” will be different at run time.

According to this forum post it is a known Xcode bug.

We also need some contraints for the text field in our NSTableCellView to make the text fill the view. Repeat the following step for the text field in each column.

  • Control drag from the NSTextField to the containing NSTableCellView and add leading, trailing, top and botton space constraints. I set the top and bottom space constraints to 0 and the leading and trailing space to 2.

Configuring Core Data

We already have a Core Data model (WorldFacts.xdatamodeld) and NSManagedObject subclasses (Country.m and Country+Extensions.m) as part of the iOS application. Those files are not yet included when Xcode builds the OS X WorldFactsBuilder target. With each file selected you can add them to the new target from the file inspector:

The Xcode OS X template includes the boilerplate code to setup the core data stack in the application delegate. It is very similar to setting up a core data stack for iOS so I will not go into details. By default the name of the model and persistent store files are based on the name of the target. Since we want to use the same names as the iOS target we need to fix the template code.

Delete the WorldFactsBuilder.xcdatamodeld file from the project and correct the URL for the managed object model file:

NSURL *modelURL = [[NSBundle mainBundle] URLForResource:@"WorldFacts" withExtension:@"momd"];

iOS 7 and OS X Mavericks changed the default journalling mode for Core Data SQLite stores to Write-Ahead Logging (WAL). This results in extra -wal and -shm files which would need to be included when we copy the Core Data store generated by the OS X app to the iOS app. To avoid these extra files we need to change to rollback journalling mode when we add the persistent store. Note that the template code also uses an XML store when we need an SQLite store:

NSURL *url = [applicationDocumentsDirectory URLByAppendingPathComponent:@"WorldFacts.sqlite"];
NSDictionary *options = @{NSSQLitePragmasOption:@{@"journal_mode":@"DELETE"}};
if (![coordinator addPersistentStoreWithType:NSSQLiteStoreType
                               configuration:nil
                                         URL:url
                                     options:options
                                       error:&error]) {

Note: A better way to do this would have been to leave the WAL journalling enabled and add an export button that used migratePersistentStore:toURL:options:withType:error.

Subclassing the Window Controller

We need to subclass the NSWindowController to add a property for the core data context. When adding the new class to the project make sure you select the Cocoa Class from the OS X section of the Xcode template dialog:

I named the class CountryWindowController as it will manage the display of Country objects. Note that there is no need to create a XIB file as the user interface is already in the storyboard:

Also make sure that the new class is only added to the OS X WorldFactsBuilder target. Do not add it to the iOS target:

With the class added remember to update the storyboard. Select the window controller object in the Window Controller Scene and using the Identity inspector change the class from NSWindowController to our custom CountryWindowController class:

Now in the public interface of the window controller we need a property for the managed object context:

@interface CountryWindowController : NSWindowController
@property (strong, nonatomic) NSManagedObjectContext *managedObjectContext;
@end

The NSWindowController class is not a view controller it is a window controller. It does not have viewWillLoad or viewDidLoad methods like a view controller but it does have windowWillLoad and windowDidLoad methods. The windowWillLoad method is a good place to retrieve the managed object context from the application delegate.

- (void)windowWillLoad {
  [super windowWillLoad];
  AppDelegate *delegate = [[NSApplication sharedApplication] delegate];
  self.managedObjectContext = delegate.managedObjectContext;
  [Country importDataToMoc:self.managedObjectContext];
}

I have also borrowed the importDataToMoc: method from the iOS app to populate our empty core data stack with some data. (The countries.plist file must be added to the OS X target). A better approach would have been to add a menu command that allows the user to select an external file to import but I will take a shortcut here to save space.

If we run the application at this point not much will happen as we still have not connected the user interface (make sure you have the OS X target selected) but the core data store should be created in the application support directory:

~/Library/Application%20Support/com.useyourloaf.WorldFactsBuilder/
WorldFacts.sqlite

Undo Manager

This bit had me confused for a while. When a user tries to undo/redo a change either from the edit menu or with ⌘Z an undo/redo message is sent to the responder chain. When the undo message reaches the window, the NSWindow object tries the windowWillReturnUndoManager: method in its delegate - the NSWindowController. At that point we need to return the NSUndoManager from the core data managed object context (unlike on iOS the OS X managed object context provides an undo manager by default).

So in the CountryWindowController we need to implement windowWillReturnUndoManager as follows:

- (NSUndoManager *)windowWillReturnUndoManager:(NSWindow *)window {
  return self.managedObjectContext.undoManager;
}

Country Table View Controller

A final piece of setup code. We need to subclass our generic view controller and pass it the managed object context from our window controller. To do that I deleted the view controller created by the Xcode template and added a new one named CountryTableViewController. As with the window controller make sure you only add this file to the OS X target. The public interface for the class has a single property for the managed object context:

@interface CountryTableViewController : NSViewController
@property (strong, nonatomic) NSManagedObjectContext *managedObjectContext;
@end

One thing to note that differs from iOS is that we do not have an equivalent of UITableViewController. For OS X you subclass NSViewController and then implement the NSTableViewDelegate and NSTableViewDataSource methods as required. Before we discuss that we just need to remember to update the storyboard to change the class of the view controller to CountryTableViewController.

Then back in the windowDidLoad method of our window controller we can retrieve our view controller from the contentController property and set the managed object context:

- (void)windowDidLoad {
  [super windowDidLoad];
  CountryTableViewController *countryTVC = (CountryTableViewController *)self.contentViewController;
  countryTVC.managedObjectContext = self.managedObjectContext;
}

Cocoa Bindings

Now things start to get interesting. I should warn you that many an experienced OS X developer will warn you about using Cocoa bindings. They are like magic when they work but can be a nightmare to debug and I can’t really recommend this approach.

At this point in an iOS app we would probably add a subclass for the table view controller and implement the various table view delegate and data source methods. A similar approach will also work here but there is another more interesting way which saves us a lot of code.

Cocoa Bindings is an OS X only feature that allows you to connect a data object to a view object. An acronym soup of Key-Value Binding (KVB), Key-Value Coding (KVC) and Key-Value Observing (KVO) provide the underlying technologies that ensure that the value displayed by the view and stored in the data object stay in sync without the need for additional glue code. For further details see Cocoa Bindings Programming Topics.

It is possible to bind a view directly to a model object. In practise it is useful to have a controller object. AppKit supplies a number of controller classes (all subclasses of NSController) specifically to work with Cocoa Bindings. These classes all provide easy ways to bind views to the currently selected object and also to supply placeholders values when the selection is empty.

The AppKit supplied subclasses of NSController:

  • NSUserDefaultsCollection binds views to the user preferences system
  • NSObjectController binds views to a single model object
  • NSArrayController binds views to a collection of objects
  • NSTreeController binds views to an hierarchical collection of objects

In our case we have a simple flat collection of objects so we will use an NSArrayController. We want to bind the array controller to the columns in our table view so we need to add it to the view controller. Drag an Array Controller object from the Xcode object library to the view controller scene.

It is not required but it can help to rename the Array Controller label that appears in Interface Builder. For some reason the label does not always update in the document outline. If that happens a restart of Xcode seems to force the update:

Binding to the model

We want the array controller to be bound to Country objects from our core data managed object context:

  • Using the Attributes inspector change the Object Controller mode from Class to Entity Name and then set the Entity Name to Country.

  • Set the Prepares Content flag - this makes the array controller fetch the Country entity data when it is loaded from the storyboard.

  • In the Bindings inspector (second from last tab) in the parameters section expand the Managed Object Context and bind it to the Country Table View Controller with a model key path of managedObjectContext:

At this point we have our array controller bound to the managed object context in our view controller so that it can fetch, add and remove Country objects.

Binding to the View

In the storyboard document outline expand the table view and select the Country column. In the Bindings inspector bind the value to the CountriesArrayController (this is where changing the label of the array controller is useful). Make sure the Controller Key is set to arrangedObjects (the NSArrayController property that has all of our Country objects) and the Model Key Path is name which is the property of the Country object we want to display in this column:

In the document outline expand the Country table column and navigate down to the text field (NSTextField) below the Table Cell View. In the Bindings inspector bind the value to the Table Cell View and set the Model Key Path to objectValue.name:

With the text field still selected switch to the Attributes inspector and change the “Behavior” to Editable. Repeat this process for each of the columns and text fields using the appropriate property name for the model key path.

Finally we can connect our add and remove buttons to the array controller. Control drag from the add button to the array controller and connect the add: action. Repeat for the remove button connecting to the remove: action.

For an extra bonus we can also bind the enabled state of the remove button to the canRemove: method of the array controller. The remove button will then be disabled when there are no entries to remove. In the Binding inspector for the remove button look under the Availability section for the Enabled property and bind to the CountriesArrayController with a Controller key of canRemove:

Running the app at this point you should be able to add, edit and remove entries to the table. If you have problems go back and check that you correctly added the core data files to the OS X target. You may also want to delete the core data file:

~/Library/Application Support/com.useyourloaf.WorldFactsBuilder/
WorldFacts.sqlite).

Initial Column Sorting

Clicking on any column header sorts that column and the table setting is automatically saved to the user defaults to preserve the setting. However on first run, before the user clicks on a column header the table is unsorted. To fix that we need to give the country array controller a sort descriptor that it will use by default. Add a property to the country table view controller private interface to hold the sort descriptors:

@property (nonatomic, strong, readonly) NSArray *sortDescriptors;

Then implement the getter to return a sort descriptor for the name property:

- (NSArray *)sortDescriptors {
  if (_sortDescriptors == nil){
    NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:@"name" ascending:YES];
    _sortDescriptors = [NSArray arrayWithObjects:sortDescriptor, nil];
  }
  return _sortDescriptors;
}

Then in the Bindings inspector for the array controller, in the Controller Content Parameters section:

  • Bind Sort Descriptors to Country Table View Controller

  • Model Key Path: sortDescriptors

Good Enough?

There is a lot more we could do to improve and polish but for the purposes of this post I think we have done enough. Given how little code we have written our application has a lot of functionality to view and edit our core data store:

Using the Core Data Store with iOS

To complete the exercise we can take the WorldFacts.sqlite file from the Application Support directory (~/Library/Application Support/com.useyourloaf.WorldFactsBuilder) and add it to the iOS target. We need a minor change to our Core Data setup code in the iOS app to use this new store straight from the application bundle. Since we will never change the store in the iOS app we open it in read only mode:

NSURL *storeURL = [[NSBundle mainBundle] URLForResource:@"WorldFacts" withExtension:@"sqlite"];
NSDictionary *storeOptions = @{NSReadOnlyPersistentStoreOption: @YES};
NSError *error = nil;
_persistentStoreCoordinator = [[NSPersistentStoreCoordinator alloc] initWithManagedObjectModel:[self managedObjectModel]];
if (![_persistentStoreCoordinator addPersistentStoreWithType:NSSQLiteStoreType
                                               configuration:nil
                                                         URL:storeURL
                                                     options:storeOptions
                                                       error:&error]) {

Room for Improvement

If you are still reading at this point you deserve a medal! This has been a long post as I have covered a lot of detail but I hope it will show how easy it can be to make the jump from iOS to OS X when you need to.

The full Xcode project including both the iOS and OS X applications can be found in my GitHub Code Examples repository.