Creating a Document-Based Mac Application Using Swift and Storyboards

August 7th, 2017

Filed under: Cocoa, Mac Development | 2 comments

I continue my writing on crafting modern Cocoa applications with Swift and storyboards. In this article you’ll learn about building document-based Cocoa applications by developing a simple text editor. I hope this article and project provide a gentle introduction to building document-based Cocoa applications in Swift.

If you haven’t read it already, I recommend reading my Creating a Simple Mac Application Using Cocoa, Swift, and Storyboards article. It provides more detailed information on some steps I gloss over in this article.

Create the Project

Let’s start by creating a new project. In Xcode choose File > New > Project to open the New Project Assistant. Select Cocoa Application, which is in the macOS section. Click the Next button to move to the next step.

NewProjectStep2

Enter a name for the project in the Product Name text field. Select None from the Team menu.

Select the Create Document-Based Application checkbox and deselect the Use Core Data checkbox. Enter rtf in the Document Extension text field because the project saves RTF files. RTF stands for Rich Text Format, a text file format the Cocoa text system natively supports.

Click the Next button to move to the last step of the project creation process. Choose a location to save your project. If you want to put your project under version control, select the Create Git repository checkbox. Click the Create button.

There are three files you will work on in the project.

  • Main.storyboard contains the user interface.
  • ViewController.swift contains the code for the view controller.
  • Document.swift contains the code for the document. The document is a subclass of NSDocument.

The names ViewController and Document are generic. I kept these names for this project because there’s only one view controller in the project and a text editor deals with documents. If you write your own document-based applications, you would benefit from renaming the classes and files ViewController and Document to something more descriptive.

Create the User Interface

Select the storyboard from the project navigator to open it. At the start, the storyboard should look like the following:

StoryboardStartingPoint

Xcode provides a menu bar, a window controller for an empty window, and a view controller for the window’s content view. Start by selecting the label that says Your document contents here and deleting it by pressing the Delete key.

Add a Text View

The user interface for this project requires only one additional interface element: a text view. Find the text view in Xcode’s object library. Drag the text view from the object library to the view controller. Resize the text view so it fills the content view.

Tell the Text View to Use the Inspector Bar

The inspector bar provides controls to do things like change the font, set the text alignment, and add lists. Using the inspector bar isn’t mandatory, but it makes text editing more pleasant.

The nice thing about using the inspector bar is it requires no additional code. Select the text view and open the attributes inspector. Select the Uses Inspector Bar checkbox.

UseInspectorBar

Create an Outlet for the Text View

Select the file ViewController.swift from the project navigator. Create an outlet for the text view so you can access the text view in your code.

@IBOutlet weak var textView: NSTextView!

Now connect the text view in the storyboard to the outlet you just created. Open Xcode’s assistant editor so the files ViewController.swift and Main.storyboard are open at the same time. Choose View > Assistant Editor > Show Assistant Editor to open the assistant editor. Open ViewController.swift in one editor and Main.storyboard in the other editor.

Select the text view in the storyboard. Hold down the Control key and drag it to the textView variable in the source code file to connect the outlet.

Resize the Text View When the Window Resizes

At this point you could build and run the project and have a mostly functioning text editor. You can create documents, enter text, cut and paste, choose fonts, and print documents. But you’ll notice a problem if you make the window larger. The text view stays the same size so you get empty space when you make the window bigger.

TextViewNotResizing

The next step is to make the text view resize when the window resizes so the text view fills the window. Select the text view’s scroll view and open the size inspector. In the autoresizing section, click the two arrows in the inner square.

AutoresizingMask

Save and Open the Document

To make the text editor usable, you must save documents to disk and open those documents. Saving the document involves writing the text view’s contents to a file. Opening the document involves reading the saved data and filling the text view with the file’s data.

Accessing the View Controller from the Document

Remember that saving the document involves writing the text view’s contents to a file. To retrieve the text view’s contents, the document needs to access the view controller, which holds the text view. Add the following code to the Document.swift file to get the view controller:

var viewController: ViewController? {
    return windowControllers[0].contentViewController as? ViewController
}

The code takes advantage of the fact the document has only one window controller. Access the first item in the windowControllers array and get its content view controller.

Saving the Data

To save the document you must override the function dataOfType. Fortunately Xcode provides a shell of this function in the Document.swift file.

override func data(ofType typeName: String) throws -> Data {

}

Your job is to write the function, which saves the text view’s contents.

override func data(ofType typeName: String) throws -> Data {
    // Save the text view contents to disk
    if let textView = viewController?.textView,
        let rangeLength = textView.string?.characters.count {

        textView.breakUndoCoalescing()
        let textRange = NSRange(location: 0, length: rangeLength)
        if let contents = textView.rtf(from: textRange) {
            return contents
        }
    }
    throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: nil)
}

The function starts with a nested if-let statement. The outer if-let statement grabs the text view. The inner let statement gets the length of the text view’s contents.

The call to breakUndoCoalescing preserves the tracking of unsaved changes and the document’s dirty state so that saving the document doesn’t mess with them.

The next two lines of code build a range for the text view’s contents and supply the range as an argument to the NSText function rtf. The rtf function converts the text view’s contents to RTF for saving.

The throw statement at the end opens an alert if the save fails.

Notice that you don’t have to open a Save panel to save the document. Cocoa’s document architecture handles that for you.

Loading the Data

To load the data from disk, you must override the function readFromData. Xcode supplies a shell of the function for you to fill in the Document.swift file.

override func read(from data: Data, ofType typeName: String) throws {

}

Start by adding a property to the Document class to store the saved text.

var text = NSAttributedString()

In the readFromData function create an attributed string with the file’s RTF contents. Set the text property to the attributed string you created.

override func read(from data: Data, ofType typeName: String) throws {
    if let contents = NSAttributedString(rtf: data, documentAttributes: nil) {
        text = contents
    }
}

I used an if-let statement instead of setting the text variable directly to avoid dealing with Swift implicitly unwrapped optionals. Implicitly unwrapped optionals can crash your application if the optional value is nil. Avoid using implicitly unwrapped optionals when you can.

You might be wondering why the readFromData function doesn’t set the text view’s contents. My initial attempt at writing the code for this tutorial did set the text view’s contents. But I discovered that when loading a document, readFromData is called before the storyboard loads the window controller and view controller. This means if you try to access the view controller in readFromData, the view controller doesn’t yet exist. Because the view controller doesn’t exist, you can’t access the view controller and text view from readFromData.

Filling the Text View with the File’s Contents

The last step is to fill the text view with the loaded document’s contents. Override the viewDidAppear function in the view controller.

override func viewDidAppear() {
    // Fill the text view with the document's contents.
    let document = self.view.window?.windowController?.document as! Document
    textView.textStorage?.setAttributedString(document.text)
}

The first line of code in viewDidAppear accesses the document. The second line sets the text view’s contents to the document’s text property, which contains the data stored in the file.

You might be wondering why I overrode viewDidAppear instead of viewDidLoad. viewDidAppear is called after the storyboard and document are loaded so I can be sure the document exists. If I overrode viewDidLoad, the document wouldn’t exist when viewDidLoad was called, and the program would crash.

If you build and run the project, you should be able to save and open documents.

Conclusion

If you made it this far, congratulations. You wrote a usable text editor. Now you can see why there are so many text editing applications on the Mac. Adding a text view provides most basic text editing functions so you don’t have to reinvent common behavior. Cocoa’s document architecture handles opening Save and Open panels, reducing the amount of code you have to write. There’s fewer than 20 lines of code to write in this project.

The project is on GitHub for you to download if you have trouble building or running the project.

If you want to learn more about developing document-based applications, read the Document-Based App Programming Guide for Mac, which is part of Apple’s documentation.

Facebooktwittergoogle_plusredditmail

Tags: ,


2 thoughts on “Creating a Document-Based Mac Application Using Swift and Storyboards

  1. Yann says:

    Hi,

    Thank you for this article.
    But I have one question : is it possible to use a IBAction in document.swift, else is it possible to use a document.swift’s func in viewcontroller ?

  2. Mark Szymczyk says:

    Yann,

    To call a document’s function in a view controller, the view controller must access the document. The first line of the viewDidAppear() function in the article shows how the view controller can access the document. Once you have access to the document, call the function.

    It is definitely possible for a document to have IBActions. The tricky part is connecting the IBAction so it gets called. In most cases you connect the user interface element or menu item to the First Responder object to connect the IBAction.

Leave a Reply

Your email address will not be published. Required fields are marked *