Windows are the “containers” for all the UI associated with all OS X apps. They define the area on the screen that the app is currently responsible for, and allow users to interact using a well-understood multi-tasking paradigm. OS X apps fall into one of the following categories:
- Single-window utility like Calculator
- Single-window library-style “shoebox” like Photos
- Multi-window document-based like TextEdit
Regardless of which category an app falls into, nearly every OS X app makes use of MVC (Model-View-Controller), a core design pattern.
In Cocoa, a window is an instance of the NSWindow
class, and the associated controller object is an instance of the NSWindowController
class. In a well-designed app, you typically see a one-to-one relationship between a window and its controller. The model layer varies according to your app type and design.
In this windows and window controllers in OS X tutorial, you’ll create BabyScript, a multi-window document-based app inspired by TextEdit. In the process, you’ll learn about:
- Windows and window controllers
- The document architecture
- NSTextView
- Modal windows
- The menu bar and menu items
Prerequisites
This tutorial is aimed at beginners. Having said that, it requires basic knowledge of the following topics:
- Swift
- Xcode, and in particular, storyboards
- Creating a simple Mac (OS X) app
If you’re not familiar with any of the above, you might want to brush up with some other tutorials on this site:
Getting Started
Launch Xcode, and choose File / New / Project…. Select OS X / Application / Cocoa Application, and click Next.
In the next screen, fill out the fields as indicated below, but enter your own name (or superhero alias) instead of mine.
Click Next and save your project.
Build and run, and you will see:
To open more documents, select File / New. All the documents are positioned in the same place, so you’ll only see the top document when you click and drag them around. It’s not a desirable effect, so add fixing this to your to-do list, but don’t dive in yet.
You can also use the Windows menu to bring windows to the front.
Documents: Under the Hood
Now you’ve seen it in action, let’s take a few minutes to see how it works.
Document Architecture
A document is a container for data in memory that you can view in a window. Eventually, it can be written to or read from a disk or iCloud. Programmatically speaking, a document is an instance of the NSDocument
class that acts as the controller for the data objects—aka model—associated with the document.
The other two major classes in the document architecture are NSWindowcontroller
and NSDocumentController
. These are the roles of each primary class:
NSDocument
: Creates, presents and stores document data
NSWindowController
: Manages a window in which a document is displayed
NSDocumentController
: Manages all of the document objects in the app
Visuals are nice too, so here’s a chart that shows how the classes work together:
Disabling Document Saving and Opening
The document architecture also provides the saving/opening mechanism for documents.
In Document.swift, you’ll find the empty implementation of dataOfType
, for writing, and readFromData
for reading. Saving and opening documents is outside the scope of this tutorial, so you’ll make some changes to prevent confusing behavior.
In Document.swift, remove autosavesInPlace
:
override class func autosavesInPlace() -> Bool {
return true
} |
Now you’ll disable all menu items related to opening and saving, but before you do, notice that all the functionality you would expect is already there. For example, select File / Open and the finder dialog box, including controls, sidebar, toolbar etc., is there:
When it has no action defined, a menu item is rendered useless. The same disabling effect happens when there is no object in the responder chain that responds to the selector associated with the action.
Hence, you’ll disconnect actions that are defined for the menu items you need to disable.
In the storyboard, select File / Open in Main Menu in the Application Scene.
Select the Connections Inspector and click Open. As you can see, it connects to the first responder via the openDocument
selector, aka the first object to respond to this selector in the responder chain. Delete this connection by clicking on the x as shown below:
Repeat this step for Save, Save As and Revert to Saved.
Build and Run. Toggle the Open menu and check that it looks like this:
Window Position
When you run BabyScript, the window opens near the left edge, but somewhat below the center of the screen.
Why does it choose this location?
Go to the storyboard, and in the outline view select Window, and then select the Size Inspector. Run BabyScript – or bring it to the front – and you should see the following screen:
Entering numeric values for the X and Y under Initial Position is one way to set the window’s position. You can also set it graphically by dragging the gray rectangle just below.
Note: The origin of a visual object (window, view, control, etc.) in Cocoa is the lower-left corner. Values increase as you go up and to the right in the coordinate system.
In contrast, many graphic environments, especially with iOS, the origin is in the upper-left, and values increase going down and to the right.
Suppose that the desired opening position for your window is 200 points offset horizontally and vertically from the top-left. You can set this with Xcode in the window’s Size Inspector or do it programmatically.
Set the Window’s Position with Interface Builder
It’s a safe bet that users will launch BabyScript on various screen sizes. Since your app doesn’t have a crystal ball to see what screen size it will open on at compile time, Xcode uses a virtual screen size and uses a concept similar to Auto Layout to determine the window position at run time.
To set the position, you’ll work with the X and Y values under Initial Position and the two drop-down menus.
Go to Initial Position to set window’s opening position in terms of screen coordinates. Enter 200 for both X and Y and select Fixed from Left and Fixed from Bottom in the upper and lower drop-downs, respectively. This sets the window’s origin at 200 points offset in both the x and y directions.
Build, run and you should see:
Follow these steps to pin the window to the upper-left corner:
- Drag the gray rectangle in the preview to the top-left of the virtual screen – this changes the initial position.
- Enter 200 for X and for Y, enter the maximum value minus 200, in this case 557.
- Select Fixed from Top in the lower drop-down.
The right side of the image below also shows what you should enter and where:
Note: OS X remembers window positions between app launches. In order to see the changes you made, you need to actually close the app window – not just rebuild and run.
Close the window(s), and then build and run.
Set the Window’s Position Programmatically
Now you’ll accomplish the same task you did with Interface Builder, but this time you’ll do it programmatically.
The reason to take the “hard way” is two-fold. First, you’ll walk away with a better understanding of NSWindowController
. Second, it’s a more flexible and straightforward approach.
At run time, the app will perform the final positioning of the window once it knows the screen size.
In the Project Navigator select the BabyScript group, then select File / New / File... From the dialog that pops up, select OS X / Source / Cocoa Class and click Next.
Create a new class called WindowController
and make it a subclass of NSWindowController
. The checkbox for XIB should be unchecked, and the Language should be Swift.
Choose a location to save the new file. Once done, you’ll see a new file named WindowController.swift appear in the group BabyScript.
Go to the storyboard, and in Outline View select Window Controller from the Window Controller Scene. Choose the Identity Inspector, and from the Class drop-down select WindowController
.
When windowDidLoad
is called the window will have completed loading from the storyboard, so any configuration you do will override the settings in the storyboard.
Open WindowController.swift and replace windowDidLoad
with the following:
override func windowDidLoad() {
super.windowDidLoad()
if let window = window, screen = window.screen {
let offsetFromLeftOfScreen: CGFloat = 20
let offsetFromTopOfScreen: CGFloat = 20
let screenRect = screen.visibleFrame
let newOriginY = CGRectGetMaxY(screenRect) - window.frame.height
- offsetFromTopOfScreen
window.setFrameOrigin(NSPoint(x: offsetFromLeftOfScreen, y: newOriginY))
}
} |
This logic positions the window’s top-left corner 20 points offset in both the x and y directions from the top-left of the screen.
As you can see, NSWindowController
has a window
property and NSWindow
has a screen
property. You use these two properties to access the geometry of the window and the screen.
After ascertaining the height of the screen, your window’s frame is subtracted along with the desired offset. Remember the Y value increases as you move upwards on the screen.
visibleFrame
excludes the areas taken by the dock and menu bar. If you don’t take this into account, you might end up with the dock obscuring part of your window.
When you enable dock and menu hiding, visibleFrame
may still be smaller than frame
, because the system retains a small boundary area to detect when to show the dock.
Build and run. The window should sit 20 points in each direction from the screen’s top-left corner.
Cascading Windows
To further improve your windows’ position, you’ll introduce Cascading Windows, meaning an arrangement of windows that overlap one another while leaving the title bar for each window visible.
Add the following below the definition of WindowController
in WindowController.swift:
required init?(coder: NSCoder) {
super.init(coder: coder)
shouldCascadeWindows = true
} |
You’re setting the shouldCascadeWindows
property of NSWindowController
to true
by overriding the required init
method of NSWindowController
.
Build and run the app, and then open five windows. Your screen should look a little bit more friendly:
Make BabyScript a Mini Word Processor
Now comes the most exiting part of this tutorial. With just two little lines of code and the addition of an NSTextView
control to your window’s contentView
, you can add functionality that will blow your mind!
The Content View
Upon creation, a window automatically creates two views: an opaque frame view with a border, title bar, etc., and a transparent content view accessible via the window’s contentView
property.
The content view is the root of the view hierarchy of a window, and you can replace the default with a custom view. Note that to position the content view, you must use the setContentView
method of NSWindow
— you can’t position it with the standard setFrame
method of NSView
.
Note: If you’re an iOS developer, please note that in Cocoa, NSWindow
is NOT a subclass of NSView
. In iOS, UIWindow
is a special subclass, of UIView
. UIWindow
itself is the root of the view hierarchy, and it’s simply playing the role of the content view.
Add the Text View
Remove the text field that says “Your document contents here” from the contentView
in the storyboard, by selecting it and pressing delete.
To create the new NSTextField
that will form the main part of your UI follow these instructions:
- In the storyboard, open the Object Library.
- Search for nstextview.
- Drag Text View and drop it on the content view.
- Resize the text view so its inset is 20 points on each side from the content view.
- In the Outline View, select Bordered Scroll View. Note that the text view is nested in the Clip View, which is nested inside a scroll view.
- Select the Size Inspector. Enter 20 for X and Y, 440 for Width and 230 for Height
Build and run — you should see the following:
Look at that friendly, blinking text insertion point inviting you to enter your text! Start your manifesto, or just keep it simple with “Hello World”, and then select the text. Copy it with File / Copy or command – C, and then paste several times, just to get a feeling for the app.
Explore the Edit and Format menu to get the idea what’s available. You might have noticed that the Font / Show Fonts is disabled. You’re going to enable it now.
Enable the Font Panel
In the storyboard, go to the main menu, click on the Format menu, then on Font, then follow with a click on Show Fonts.
Go to the Connections Inspector and you’ll see that no actions are defined for this menu item. This explains why the menu item is disabled, but what do you connect it to?
Apparently, the action is already defined in the code imported indirectly by Xcode, you just need to make the connection.
Right-click Show Fonts and drag it to the First Responder in the Application Scene, and then release the mouse. A small window with a scrollable list of all the actions defined will pop up. Look for and select orderFrontFontPanel
. You can also start typing it to find it more quickly.
Now, take a look at the Connections Inspector with Show Fonts selected. You’ll see the menu is now connected to orderFrontFontPanel
of the first object in the responder chain that responds to this selector.
Build and run the app, then enter some text and select it. Choose Format / Font / Show Fonts to open the fonts panel. Play with the vertical slider on the right side of the font panel, and observe how the text size changes in real time.
Wait, but you didn’t enter yet a single line of code regarding the text view, yet you have the power to change the size. You’re amazing!
Initialize the Text View with Rich Text
To see the full power of the app, download some formatted text from here, and use it as the initial text for the text view.
Open it with TextEdit, select all and copy it to the clipboard. Go to the storyboard, select the Text View, then Attributes Inspector and paste the text into the Text Storage field.
Build and run, and you should see:
Use Auto Layout
You do have the ability to scroll text that doesn’t fit the current window, but try to resize the window.
Oops! The text view does not resize with the window.
It’s a simple fix with Auto Layout.
Note: Auto Layout assists both you in both Cocoa and iOS with your app’s UI. It creates a set of rules that define the geometric relationship between the elements, and you define these relationships in terms of constraints.
With Auto Layout, you create a dynamic interface that responds appropriately to changes in screen size, window size, device orientation and localization.
There’s more to it than that, but for the sake of this tutorial, all you need to do is follow the few simple steps below — you can learn more about Auto Layout later. Here are a couple of good tutorials to check out: Beginning Auto Layout Tutorial in iOS 7, Part 1 and Part 2.
In the storyboard’s Outline View, select Bordered Scroll View, and click on the Pin button at the bottom-right of the canvas.
Click on each of the four little red bar constraints; the broken faded red will turn to solid red. Click at the bottom on the button that reads Add 4 Constraints.
Build and run, and watch how both the window and text view resize together:
Show the Ruler by Default
To show the ruler automatically when a window opens, you’ll need an IBOutlet
in the code. Select Format / Text / Show Ruler from the menus. In ViewController.swift, add one line into the viewDidLoad
method toggleRuler
, and add an IBOutlet above the method as shown below:
@IBOutlet var text: NSTextView!
override func viewDidLoad() {
super.viewDidLoad()
text.toggleRuler(nil)
} |
Now you’ll connect the text view to the view controller in the storyboard.
In the storyboard, right-click on the ViewController
, hold and drag into the text view until it highlights, and then release the mouse. A small window with the list of Outlets will show itself. Select the text
outlet:
Build and run, and now the window by default shows the ruler by default:
So just as I promised, with two lines of code and the storyboard, you have created a mini word processor – Chapeau, Apple!
Modal Windows
You can make a window run in a modal fashion. The window still uses the app’s normal event loop, but input is restricted to the modal window.
There are two ways to utilize a modal window. You’ll call the runModalForWindow
method of NSApplication
. This approach monopolizes events for the specified window until it is gets a request to stop, which you can invoke by stopModal
, abortModal
or stopModalWithCode
.
For this case, you’ll use stopModal
. The other way, called a modal session, is not covered by this tutorial.
Add a Word Count Window
You’ll add a modal window that counts words and paragraphs in the active window. It has to be modal because it’s associated with a specific window and a specific state.
From the Object Library, drag a new window controller to the canvas. This creates two new scenes: a window controller scene and a view controller scene:
Select Window from the new window controller scene and use the Size Inspector to set its width to 300 and height to 150. Select View from the new view controller scene and resize it to match the window:
Since Word Count is a modal, having the close, minimize and resize buttons in its title bar would be bizarre, and a violation of HIG (Apple’s Human Interface Guidelines).
For the Close button, it would also introduce a serious bug because clicking the button will close the window, but won’t call stopModal
. So, the app will forever stay in a “modal state”.
Removing Buttons from a Modal
In the storyboard, select the Word Count window and choose Attributes Inspector. Uncheck Close, Minimize and Resize. Also change the Title to Word Count.
Now you’ll add four label controls and a push button from the Object Library to the contentView
of the Word Count window.
Select the Attributes Inspector. Change the labels’ titles to Word Count, Paragraph Count, 0 and 0 respectively. Also change the alignment for the two 0 labels to right justified. Change the push button title to OK.
Next on the list is creating a subclass for the Window Count ViewController. Select File / New / File.., choose OS X / Source / Cocoa Class. In the Choose Options dialog, enter WordCountViewController
in the Class field and NSViewController
in the Subclass of field.
Click Next and create the new file. Confirm that WordCountWindowControll.swift is now in the project navigator.
Go to the storyboard. Select the proxy icon for the view controller in the view controller scene for word count. Open the Identity Inspector, and select WordCountViewController
from the Class drop-down. Note how the name on the canvas and the Outline View changed from the generic name to Word Count View Controller.
Create the Count Labels
Now you’ll create outlets for the two labels that show the count values — the two 0 labels. Under the class definition for WordCountViewController.swift, add the following:
@IBOutlet weak var wordCount: NSTextField!
@IBOutlet weak var paragraphCount: NSTextField! |
In the storyboard, right-click on the proxy icon for the word count view controller, drag over the top-most 0 label and release when the control highlights. From the Outlets list that pops up, select wordCount
.
Repeat the same for the lower 0 label, but this time select paragraphCount
. Check for each of the labels in the Connections Inspector that the Outlets are connected like this:
In a few moments, you’ll add code to programmatically load the word count window controller. This requires that it have a storyboard ID. Select the window controller of the word count window from the storyboard. Select the Identity Inspector, and in Storyboard ID enter Word Count Window Controller:
Show Me the Modal
Now for the basic logic to show the modal window. In the document window’s view controller, find and select ViewController.swift add the code below under viewDidLoad
:
@IBAction func showWordCountWindow(sender: AnyObject) {
// 1
let storyboard = NSStoryboard(name: "Main", bundle: nil)
let wordCountWindowController = storyboard.instantiateControllerWithIdentifier("Word Count Window Controller") as! NSWindowController
if let wordCountWindow = wordCountWindowController.window, textStorage = text.textStorage {
// 2
let wordCountViewController = wordCountWindow.contentViewController as! WordCountViewController
wordCountViewController.wordCount.stringValue = "\(textStorage.words.count)"
wordCountViewController.paragraphCount.stringValue = "\(textStorage.paragraphs.count)"
// 3
let application = NSApplication.sharedApplication()
application.runModalForWindow(wordCountWindow)
}
} |
Take it step-by-step:
- Instantiate the word count window controller, using the storyboard ID you specified before.
- Set the values retrieved from the text view in the word count window count outlets
- Show the word count window modally
Note: In step two, you passed data between two view controllers. This is similar to what you’d usually do in a prepareForSegue
method when a segue is involved in the transition. Since showing a modal window is done directly with a call to runModalForWindow
and there’s no segue involved, you pass the data just before the call.
Go Away, Modal
Now you’ll add code to dismiss the word count window. In WordCountViewController.swift, add the following method below the paragraphCount
outlet:
@IBAction func dismissWordCountWindow(sender: NSButton) {
let application = NSApplication.sharedApplication()
application.stopModal()
} |
This is an IBAction that should be invoked when the user clicks OK on the word count window.
Go to the storyboard, right-click on OK, then hold and drag to the proxy icon of the word count view controller. Release the mouse and select dismissWordCountWindow:
from the presented list:
Add UI to Invoke It
The only thing left to present the window is adding the UI to invoke it. Go to the storyboard, and in the Main Menu click Edit. From the Object Library, drag a Menu Item to the bottom of the Edit menu. Select the Attributes Inspector and set the title to Word Count.
Take a moment to create a keyboard shortcut by entering command – K as the key equivalent.
Now you’ll connect the new menu item to the showWordCountWindow
method in ViewController.swift.
Go to the storyboard, right-click on the Word Count menu item, hold and drag over First Responder in the application scene. Select showWordCountWindow
from the list.
Note: You might wonder why you connected the menu item to the first responder, but not directly to showWordCountWindow
. It’s because the document view’s main menu and view controller are in different storyboard scenes, and as such, can’t be connected directly.
Build and run the app, select Edit / Word Count, and voila, the word count window presents itself.
Click OK to dismiss the window.
Where To Go From Here?
Here is the final version of BabyScript.
You covered a lot of ground in this windows and window controllers for OS X tutorial! But it’s just the tip of the iceberg as far as what you can do with windows and window controllers.
You covered:
- The MVC design pattern in action
- How to create a multi-window app
- Typical app architecture for OS X apps
- How to position and arrange windows with Interface Builder and programmatically
- Using Auto Layout to resize a view with its window
- Using modal windows to display additional information
And more!
I strongly recommend that you explore the huge amount of documentation provided by Apple in El Capitan’s Mac Developer Library. In particular, reference the Window Programming Guide.
For better understanding of Cocoa app design and how it works with the types of apps mentioned at the beginning, check out the Mac App Programming Guide. This document also extends on the concept of multi-window document-based apps, so you’ll find ideas to keep improving BabyScript there.
I look forward to hearing your ideas, experiences and any questions you have in the forums below!
The post Windows and Window Controllers in OS X Tutorial appeared first on Ray Wenderlich.