3

Windows and WindowController Tutorial for macOS

 3 years ago
source link: https://www.raywenderlich.com/613-windows-and-windowcontroller-tutorial-for-macos
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Home macOS Tutorials

Windows and WindowController Tutorial for macOS

In this Windows and WindowController Tutorial for macOS, learn how to create a document based Cocoa app using modal windows and Sierra new tabbed interface.

By Warren Burton May 17 2017 · Article (30 mins) · Beginner

4.9/5 7 Ratings

Version

Update note: This Windows and WindowController Tutorial for macOS has been updated to Xcode 8 and Swift 3 by Warren Burton. The original tutorial was written by Gabriel Miro.

Windows are the “containers” for the user interfaces presented by all macOS 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. macOS 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 macOS app makes use of the Model-View-Controller (MVC) relationship, 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 third component of the MVC paradigm, the model layer, varies according to your app type and design.

In this 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. However, you will need some basic knowledge of the following topics:

  • Swift 3.1
  • The latest Xcode, and in particular, storyboards
  • Creating a simple Mac (macOS) app
  • The First Responder and the responder chain.

If you’re not familiar with any of the above, you can brush up with some other tutorials in the Getting Started section of the macOS tutorials page.

Getting Started

Launch Xcode, and choose File / New / Project…. Select macOS / Application / Cocoa Application, and click Next.

In the next screen, fill out the fields as indicated below. Make sure that Create Document-Based Application is checked, that the Document Extension is set to “babyscript”, and the rest of the options are unchecked.

Click Next and save your project.

Build and run, and you will see this window:

First window state

To open more documents, select File / New (or press Command-N). All the new documents are positioned in the same place, so you’ll only see the top document until you drag them around. You will fix this very soon.

Documents: Under the Hood

Now you’ve seen it in action, let’s take a few minutes to see how a document based app works.

Document Architecture

A document is an instance of the NSDocument class that acts as the controller for the data or model in memory – you can view this model in a window. It can be written to or read from a disk or iCloud.

NSDocument is an abstract class, which means that you always create a subclass of it because on its own it does not provide enough functionality to work.

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

Here’s a chart that shows how these classes work together:

document architechture

Disabling Document Saving and Opening

The document architecture provides built-in mechanisms for saving/opening documents. However, this is one of the things you need to complete for yourself in a subclass.

Open Document.swift. You’ll find the empty implementation of data(ofType:), for writing to files, and read(from:ofType:) for reading from files.

Saving and opening documents is outside the scope of this tutorial, so you’ll make some changes to disable this behaviour and avoid any confusion in functionality.

Open Document.swift and replace the autosavesInPlace implementation with this:

override class func autosavesInPlace() -> Bool {
  return false
}

The above disables the autosave feature. Now, you need to 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, build and run, then select File / Open. Notice the Finder dialog box, including controls, sidebar, toolbar etc., is there:

stock open window

When it has no action defined, a menu item is rendered useless. So, you’ll disconnect actions that are defined for the menu items you need to disable.

Open Main.storyboard. Go to the Application Scene and select File / Open menu item in the Main Menu. Then, switch to the Connections Inspector tab on the right sidebar. As you can see, it connects the menu action to the first responder via the openDocument: selector. Delete this connection by clicking on the x as shown below:

Repeat this step to delete the actions for Save, Save As and Revert to Saved menu items.
And then, delete the Open Recent menu item entirely.

Now, open Document.swift and add this method to the class implementation to show an alert when you try to save. You’ll see this alert later on in the tutorial.

override func save(withDelegate delegate: Any?, didSave didSaveSelector: Selector?, contextInfo: UnsafeMutableRawPointer?) {
  let userInfo = [NSLocalizedDescriptionKey: "Sorry, no saving implemented in this tutorial. Click \"Don't Save\" to quit."]
  let error =  NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: userInfo)
  let alert = NSAlert(error: error)
  alert.runModal()
}

Build and Run. Select the File menu and check that it looks like this:

Now that you’ve finished breaking the windows, you can begin to put some new glass in :]

Window Position

The first thing you’ll fix is the issue of windows appearing exactly on top of each other when you create new documents.

You will do this by creating a subclass of NSWindowController and adding some code to it to control the initial location of the document window.

Create a Subclass of NSWindowController

Select the BabyScript group in the Project Navigator, and choose File / New / File…. In the dialog that pops up, select macOS / Source / Cocoa Class and click Next.

Create a new class called WindowController and make it a subclass of NSWindowController. Make sure the checkbox to create a XIB is unchecked, and the Language is set to Swift.

Click Next and choose the location to save the new file.

Next, you need to make sure that your window controller on the storyboard is an instance of WindowController. Open Main.storyboard, and select Window Controller in the Window Controller Scene. Open the Identity Inspector and select WindowController in the Class drop-down.

A Cascade of Windows

Remember how your document windows all opened on top of each other previously? You will now make your windows cascade across the screen as you create them. Open WindowController.swift and add the following code to the class implementation:

required init?(coder: NSCoder) {
  super.init(coder: coder)
  shouldCascadeWindows = true
}

You set the shouldCascadeWindows property to true to tell the window controller to cascade its windows.

Build and run and you will see each new window offsets itself from the previous one so all windows are visible at the same time.

Put it on the Tab

Cascading windows are nice, but they are a bit retro. So, how about using the latest hot Sierra API to switch to tabbed windows?

Open Main.storyboard and select the Window in the Window Controller scene. Then, open the Attributes Inspector and switch the Tabbing Mode control to Preferred.

And that’s it. With that single change your app has adopted the new tabbed style!

Build and run the app. Open several new documents and see how they all tab together in one window.

When you run BabyScript, macOS makes a few decisions regarding the size of the current screen and the requested window size to decide where to place a window and how large the actual window should be.

There are two ways to control this position and size. You will learn about them next.

Set the Window’s Position with Interface Builder

First you will use Interface Builder to set the initial position.

Open Main.storyboard, and select the Window in the Window Controller Scene. 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 visually by dragging the gray rectangle in the tiny preview window just below the X and Y values.

Note: The origin for Cocoa views is the bottom left corner. So, Y values increase with distance from the bottom edge. This is in contrast to iOS where the view origin is at top-left.

If you click the red constraints around the gray window in the window preview, you can control the decisions that macOS makes when placing a new window on the screen. Notice how the pulldown menus below the preview change as you do this.

They are initially set to Proportional Horizontal and Proportional Vertical. This means that the window’s initial position will depend on the size of the screen that its being opened on. Now, make these changes:

  1. Set the two pulldowns to Fixed From Left & Fixed From Bottom.
  2. Set the initial position to X:200 and Y:200

Build and run. Notice how your first new window ends up in the exact same position regardless of screen size.

BL aligned window

Note: macOS remembers window positions between app launches. In order to see the changes you make, you need to close the window(s), and then build and run.

Set the Window’s Position Programmatically

In this section you’ll accomplish the same thing you did previously using Interface Builder, but this time you’ll do it programatically. This approach gives you runtime control of the window’s initial position. You might find this approach more flexible in some situations.

You will make your changes to the windowDidLoad method in your window controller. When windowDidLoad is called, the window has already completed loading all its views from the storyboard, so any configuration you do will override the settings in the storyboard.

Open WindowController.swift and replace the windowDidLoad implementation with the following:

override func windowDidLoad() {
  super.windowDidLoad()
  //1.
  if let window = window, let screen = window.screen {
    let offsetFromLeftOfScreen: CGFloat = 100
    let offsetFromTopOfScreen: CGFloat = 100
    //2.
    let screenRect = screen.visibleFrame
    //3.
    let newOriginY = screenRect.maxY - window.frame.height - offsetFromTopOfScreen
    //4.
    window.setFrameOrigin(NSPoint(x: offsetFromLeftOfScreen, y: newOriginY))
  }
}

The above code sets the window’s top-left corner 100 points offset in both the x and y directions from the top-left of the screen as follows:

  1. Get the NSWindow and NSScreen instances so you can calculate the geometry.
  2. Ask for the visibleFrame of the screen.
  3. Subtract your window’s height along with the desired offset from the screens height. Remember you are trying to get the position for the bottom edge.
  4. Set the origin to the calculated point.

The visibleFrame property of NSScreen excludes the areas taken by the Dock and Menu Bar. If you didn’t take this into account, you might end up with the Dock obscuring part of your window.

Build and run. The window should now sit 100 points in each direction from the screen’s top-left corner.

TL aligned window

Make BabyScript a Mini Word Processor

Cocoa has some amazing UI functionality just waiting for you to drag into your app window. Here, you’ll explore the super powerful and versatile NSTextView. But first, you need to know about the content view of NSWindow.

The Content View

The contentView is the root of the view hierarchy of a window. It sits inside the window’s chrome (Title Bar & Controls) and it’s the view where all the user interface elements are located. You can replace the default one with your own view by just changing the contentView property. You won’t do that in this tutorial, but it’s useful to know.

Add the Text View

Open Main.storyboard and remove the text field that says “Your document contents here” from the content view. Now, add a text view:

  1. Still in the storyboard, open the Object Library.
  2. Search for NSTextView.
  3. Drag the Text View and drop it on the content view.
  4. Resize the text view so its inset is 20 points on each side from the content view. The blue dotted alignment guides will help you here.
  5. 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.
  6. Select the Resolve Auto Layout Issues control and select Reset To Suggested Constraints

set textview constraints

Build and run — you should see the following:

empty text window

Look at that friendly, blinking text insertion point inviting you to enter some text! Start your manifesto, or stick to a simple “Hello World”, and then select the text. Copy it with Edit / Copy or Command – C, and then paste several times, just to put the app through its paces.

Explore the Edit and Format menus to get an idea of what’s available. You might have noticed that Format / Font / Show Fonts is disabled. You’re going to fix that now.

Enable the Font Panel

In Main.storyboard, go to the main menu, click on the Format menu, then on Font, then 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?

The action is already defined in the code imported indirectly by Xcode as part of Cocoa – you just need to make the connection. Here’s what you do:

  1. While holding down the Ctrl key click Show Fonts and drag it to the First Responder in the Application Scene. Then release the mouse.
  2. A window with a scrollable list of all the available actions will pop up. Look for and select orderFrontFontPanel:. You can also start typing orderFrontFontPanel to find it more quickly.
  3. 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 (or press Cmd-T). Play with the vertical slider on the right side of the font panel, and observe how the text size changes in real time.

You didn’t write a single line of code, yet you have the power to change the font size. How did that work? That’s because the NSFontManager and NSTextView classes do most of the heavy lifting for you.

  • NSFontManager is the class that manages the font conversion system. It implements the method orderFrontFontPanel, so when the responder chain forwards the message to it, it shows the system’s default font panel.
  • When you change the font attributes in the panel, NSFontManager sends a changeFont message to the First Responder.
  • NSTextView implements changeFont and it’s the first object in the responder chain because you just selected some text. So, when the font attributes change, it automatically modifies the font of the selected text accordingly.

Initialize the Text View with Rich Text

To see the full power of NSTextView, download some formatted text from here, to use as the initial text for the text view.

Open it with TextEdit, select all the text and copy it to the clipboard. Then, open Main.storyboard and select the Text View. Open the Attributes Inspector and paste the copied text into the Text field.
Now, switch on the Graphics and Image Editing check boxes to allow images in the text view.

Build and run, and you should see:

window with rich text

The image from the original text you copied is gone! How come?

You can’t add images to the Interface Builder text storage field – so the image was not stored in the storyboard. But you can drag in, or paste images in, to the text view when BabyScript is running. Have a go if you want.

edit image

After you’ve made some changes to the text, or pasted in an image, try to close the window. In the alert box that pops up, chose to save the document. You’ll now see the error alert that you set up right at the start of the tutorial. :]

save alert

Show the Ruler by Default

To show the ruler automatically when a BabyScript window opens, you’ll need an IBOutlet connected to the text view. Open ViewController.swift, and delete the default viewDidLoad implementation. Then add the following code:

@IBOutlet var text: NSTextView!
  
override func viewDidLoad() {
  super.viewDidLoad()
  text.toggleRuler(nil)
}

This code defines an outlet for the text view, and in viewDidLoad calls the text view toggleRuler method to show the ruler – the ruler is hidden by default.
Now you need to connect the text view to this outlet in Interface Builder.

Open Main.storyboard and click on the ViewController proxy. Hold down Ctrl, drag into the text view until it highlights, and then release the mouse. A small window with the list of Outlets will show. Select the text outlet:

connect text outlet

Build and run, and now each editor window shows the ruler:

ruler is automatic

With two lines of code and the default functionality provided by Cocoa, you have created a mini word processor!

Stand up, stretch, have a drink, and get ready for the next section :]

Modal Windows

Modal windows are the attention seekers of the window world. Once presented, they consume all events until they are dismissed. You use them to do an activity that demands all of the user’s focus. The save and open panels that all macOS apps use are good examples of modals.

There are 3 ways to present a modal window:

  1. As a regular window using NSApplication.runModal(for:).
  2. As a sheet modal from a window using NSWindow.beginSheet(_:completionHandler:).
  3. Via a modal session. This is an advanced topic which won’t be covered in this tutorial.

Sheet modals appear from the top of the window that presents them. The save alert in BabyScript is an example.

Sheet Modal Example

You won’t take sheet modals in this tutorial any further. Instead, in the next sections you’ll learn how to present a detached modal window that shows the word and paragraph count for the active document.

Add a New Window to the Scene

Open Main.storyboard. Drag a new Window Controller from the Object Library into the canvas. This creates two new scenes: a Window Controller Scene and a View Controller Scene for its content:

Select the Window from the new Window Controller Scene and use the Size Inspector to set its width to 300 and height to 150.

With the window still selected, select the Attributes Inspector and uncheck the Close, Resize and Minimize controls. Then, set its title to Word Count.

The Close button would introduce a serious bug because clicking the button will close the window, but won’t call stopModal, so the app would remain in a “modal state”.

Having minimize and resize buttons in the title bar would be strange. Also, it’s a violation of Apple’s Human Interface Guidelines (HIG).

Now, select the View from the new View Controller Scene and use the Size Inspector to set its width to 300 and height to 150.

Setting Up the Word Count Window

Open the Object library and drag 4 Label instances on to the contentView of the Word Count window. Line them up like the image below. Since this window can’t be resized, you don’t have to worry about automatic layout.

Select the Attributes Inspector. Change the labels’ titles to Word Count, Paragraph Count, 123456 and 123456 as in the screenshot below. (Since you’re not using Auto Layout to dynamically adjust the label’s width, you use a long placeholder text like 123456 to make sure the label is wide enough at runtime and the numbers are not truncated). Now change the alignment of all the labels to right justified.

Next, drag a Push Button on to the content view.

Change the button title to OK.

Create the Word Count View Controller Class

You’ll create an NSViewController subclass for the Word Count View Controller like this:

  1. Select File / New / File…, choose macOS / Source / Cocoa Class.
  2. In the Choose Options dialog, enter WordCountViewController in the Class field.
  3. Enter NSViewController in the Subclass of field.
  4. Make sure that “Also create XIB for user interface” is unchecked.

add view controller subclass

Click Next and create the new file.

Open Main.storyboard. Select the proxy icon for the word count view controller and open the Identity Inspector. Select WordCountViewController from the Class drop-down.

set custom class

Bind the Count Labels to the View Controller

Next, you’ll use Cocoa Bindings to show the count values in the view controller. Open WordCountViewController.swift, add the following inside the class implementation:

dynamic var wordCount = 0
dynamic var paragraphCount = 0

The dynamic modifier makes the two properties compatible with Cocoa Bindings.

Open Main.storyboard and select the numeric text field for the word count. Then open the Bindings inspector and do the following:

  1. Expand the Value binding by clicking on its disclosure triangle.
  2. Select Word Count View Controller from the Bind To pull down.
  3. Check Bind To
  4. Type wordCount in the Model Key Path

Repeat the same for the paragraph count numeric label, but this time use paragraphCount into the Model Key Path.

Note: Cocoa Bindings is a super useful technique for UI development. If you want to learn more about it, have a look at our Cocoa Bindings on macOS tutorial.

Finally, assign a Storyboard ID to the controller.

Select the Window Controller of the Word Count window. Then, open the Identity Inspector, and enter Word Count Window Controller in the Storyboard ID field.

set storyboard id

Presenting and Dismissing a Modal Window

You now have the storyboard components for the Word Count window ready and waiting. It’s time to open those windows and let some air in :]

In the next few sections you’ll add the code to present the window and to make it go away again. You are almost done. So, hang in there!

Show Me the Modal

Open ViewController.swift and add the following method to the class implementation:

@IBAction func showWordCountWindow(_ sender: AnyObject) {
  
  // 1
  let storyboard = NSStoryboard(name: "Main", bundle: nil)
  let wordCountWindowController = storyboard.instantiateController(withIdentifier: "Word Count Window Controller") as! NSWindowController
  
  if let wordCountWindow = wordCountWindowController.window, let textStorage = text.textStorage {
    
    // 2
    let wordCountViewController = wordCountWindow.contentViewController as! WordCountViewController
    wordCountViewController.wordCount = textStorage.words.count
    wordCountViewController.paragraphCount = textStorage.paragraphs.count
    
    // 3
    let application = NSApplication.shared()
    application.runModal(for: wordCountWindow)
    // 4
    wordCountWindow.close()
  }
}

Taking it step-by-step:

  1. Instantiate the Word Count window controller using the Storyboard ID you specified before.
  2. Set the values retrieved from the text view’s storage object (word and paragraph count) as the relevant word count view controller properties. Thanks to Cocoa Bindings, the text fields will automatically display those values.
  3. Show the Word Count window modally.
  4. Close the Word Count window once the modal state is over. Note that this statement does not execute till the modal state is completed.

Go Away, Modal

Next, you’ll add code to dismiss the Word Count window. In WordCountViewController.swift, add the following code to the class:

@IBAction func dismissWordCountWindow(_ sender: NSButton) {
  let application = NSApplication.shared()
  application.stopModal()
}

This is an IBAction that will be invoked when the user clicks the OK button on the Word Count window.

In this method you simply stop the modal session you started earlier. A modal session must always be explicitly stopped to return the app to normal operations.

Open Main.storyboard. Click on the OK button, then hold Ctrl down and drag to the proxy icon of the Word Count View Controller. Release the mouse and select dismissWordCountWindow: from the presented list:

connect OK button to action

Add UI to Invoke Modal

Still in Main.storyboard, go to the Main Menu, expand the Edit menu item, and do the following:

  1. From the Object Library, drag a Menu Item to the bottom of the Edit menu.
  2. Select the Attributes Inspector and set the title to Word Count.
  3. 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.

Click on the Word Count menu item, then hold Ctrl down and drag over to the First Responder in the Application scene. Select showWordCountWindow: from the list.

Here, you connected the menu item to the first responder, not directly to showWordCountWindow in ViewController. This is because the application main menu and view controller are in different storyboard scenes, and can’t be connected directly.

Build and run the app, select Edit / Word Count (or press Cmd-K), and the word count window should present 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 macOS tutorial:

  • The MVC design pattern in action.
  • How to create a multi-window app.
  • Typical app architecture for macOS apps.
  • How to position and arrange windows with Interface Builder and via code.
  • Passing actions from UI to the responder chain.
  • Using modal windows to display additional information.

And more!

But it’s just the tip of the iceberg as far as what you can do with windows and window controllers. I strongly recommend that you explore Apple’s Window Programming Guide if you want to learn even more about the subject.

For a better understanding of Cocoa and how it works with the types of apps mentioned at the beginning, check out the Mac App Programming Guide. This document also expands upon the concept of multi-window document-based apps, so you’ll find ideas to keep improving BabyScript there.

If you would like to see the complete version, with saving and opening documents working, download this more complete app. It gives you an idea of how little work is needed to implement a complete document based app.

I look forward to hearing your ideas, experiences and any questions you have in the forums below!

raywenderlich.com Weekly

The raywenderlich.com newsletter is the easiest way to stay up-to-date on everything you need to know as a mobile developer.

Get a weekly digest of our tutorials and courses, and receive a free in-depth email course as a bonus!

Average Rating

4.9/5

Add a rating for this content

Sign in to add a rating
7 ratings

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK