Prototyping With Xcode

View Transitions in Mac App Windows

John Marstall
BPXL Craft
15 min readJan 7, 2016

--

I suppose because storyboards for Mac projects and the Swift language were both so new at the time, figuring out how to use them together was more aggravating than expected. This overview is offered as a resource to help others skip some of the pain I encountered.

What we want to do is animate nicely between two views in the same window, with the window resizing as necessary to match whichever is the current view.

Note: This tutorial requires Xcode 7.0 or above.

Launch Xcode. Select File > New > Project. Choose OS X > Application > Cocoa Application. Hit Next. Fill out the product details however you like, but make sure the language is set to Swift and “Use Storyboards” is checked.

After the project is created, select “Main.storyboard” in the Project navigator (first icon in the leftmost pane).

To do this, we’ll need four things on our storyboard:

  1. The window controller already provided. Its size doesn’t matter, but you need to turn off its “Resize” capability. View transitions within user-resizable windows are possible but beyond the scope of this project.

To turn off Resize, first select the Window item within the Window Controller Scene. Then select the Attributes inspector (that’s the fourth button in the inspector on the right side of the Xcode window). Look for the checkbox there.

Disable resize on the window

2. A “container” view controller occupying the “window content” relationship to the window controller. Here again you’ll just use the initially provided view controller. Its size doesn’t matter either; we’ll set that in code. Keep in mind anything you place within this view controller’s view will be visible in the app at runtime, therefore you probably want to leave it devoid of content.

3. An “origin” view controller that will contain the initial view the user sees. The size you assign its view will determine the size of the window at runtime.

From the Object library in the lower right, find the View Controller object and drag it onto the storyboard. Next, find the Push Button (you can search for it) and place one inside that view controller.

View controller with button

4. A “destination” view controller that will contain the view at which the user arrives after interacting with the origin view. The size you assign its view will determine how the window resizes following that interaction.

Again, drag a view controller onto the storyboard and place a push button within it.

To further differentiate these controllers, add a visible label to each indicating its purpose. I’ve simply labeled them “Origin” and “Destination.”

Labels are also found in the Object library. Drag a label to each view controller, then customize by double-clicking the label and typing your own text.

A window controller and three view controllers

You’ve probably noticed the “origin” and “destination” view controllers display no obvious relationship to the window or “container” controllers. There is no line connecting them. This is because making the transition happen requires both the origin and destination to be designated as children of the container, and this is not possible via storyboard manipulation alone.

We need to build that relationship with Swift code, and that means we need to be able to identify some of these storyboard identities in code. For that, we’ll need the name of the storyboard file, usually “Main.storyboard,” and a couple “storyboard IDs” that we’ll set. Storyboard IDs are set via the Identity inspector, the third button in the inspector on the right side of the Xcode window.

In my example, I’m identifying the “container” view controller as containerViewController and the “origin” view controller as originViewController. It’s not necessary to set a storyboard ID for the “destination” view controller. That’s lucky for us, because it means we could set up an indefinite chain of destinations and not have to rewrite any code for the additional view controllers. For clarity’s sake, however, I will refer to the “destination” view controller as if we’d assigned it the storyboard ID destinationViewController.

Assign storyboard IDs

Assign storyboard IDs containerViewController to the window’s attached view controller and originViewController to the first additional view controller you created. Make sure you’re selecting the view controller itself and not anything within the controller. Click the blue icon at the top of the controller if you’re unsure. The storyboard ID field is found in the Identity inspector.

Building the initial parent-child relationship will only involve connecting containerViewController and originViewController. We’ll hold off on adding destinationViewController as a child until we really need it.

To do this, we’ll create a new kind of view controller: a “subclass” of Apple’s own NSViewController.

Go to File > New > File and select OS X > Source > Cocoa Class. Use “ContainerViewController” for the class name and NSViewController for the thing it’s subclassing. Uncheck “Also create XIB file” and leave the language as Swift.

Create a view controller subclass

Set the containerViewController to use the ContainerViewController class by selecting it on the storyboard and entering “ContainerViewController” in the Class field on the Identity inspector (third inspector button).

Assign the subclass

Switch to “ContainerViewController.swift” in the Project navigator.

We want the containerViewController to do several things when it first loads, so we’re going to add several expressions within the viewDidLoad() function. Everything we add should be entered following the // Do view setup here comment but before the curly bracket that follows it. The curly bracket closes the viewDidLoad() function.

We want to point containerViewController at originViewController, which is contained within our main storyboard. But the Main.storyboard file isn’t yet something ContainerViewController knows about. We have to “import” it, if you will, as an NSStoryboard object. That looks like this:

let mainStoryboard: NSStoryboard = NSStoryboard(name: "Main", bundle: nil)
Let ContainerViewController know about the storyboard

This is not a Swift language guide, but I’ll break this down a bit. The Swift keyword let means we’re going to create a new constant, a variable that doesn’t vary. mainStoryBoard is just the name of the constant, which could be anything, but we want the name to make clear what it is. : NSStoryboard signifies this constant will be of the type “NSStoryboard.” The equals sign means that constant will be made equal to what follows, and what follows is a method for getting the storyboard file named “Main.”

Since our storyboard is part of the main project, we don’t need to specify the “bundle.” We can leave that as nil, which means “no value.”

Now we have the storyboard as a Swift object and can address anything within it that we’ve assigned a storyboard ID. Handy!

Next we grab our originViewController off the storyboard and render it to another constant:

let originViewController = mainStoryboard.instantiateControllerWithIdentifier("originViewController") as! NSViewController

Since we established that mainStoryboard is an NSStoryboard, we can do NSStoryboard things with it like .instantiateControllerWithIdentifier(). That means we tell Swift to grab the view controller with the storyboard ID in the parentheses, “originViewController,” and spin it up so we can put it to use.

It’s also possible to use .instantiateControllerWithIdentifier() to grab that other kind of controller, a window controller. That may be why we need to specify as! NSViewController at the end there.

At last we can connect containerViewController to originViewController as parent and child:

self.insertChildViewController(originViewController, atIndex: 0)

Since self here is going to be the containerViewController, we’ve told it to insert originViewController as a child of itself. We have to provide the index value, because containerViewController could have more than one child controller, and we need to explain where in the stack originViewController is meant to go. Since it’s the first child controller, it goes in at the first index, index 0. Programmers always start counting at zero.

Interestingly, if you run your app at this point you won’t see originViewController get pulled in. That’s because while it’s being added as a child controller “in the background,” we haven’t expressly said we want to make it visible yet. Computers are not good at inferring intent.

Tell containerViewController to show the view in originViewController as a subview of its own:

self.view.addSubview(originViewController.view)
Display originViewController

Now it will show up, but it’s likely your window size will be out of whack. We fix that by getting containerViewController to resize itself according to the size of the view in originViewController. What we want to mess with is the box around the view, which is called the view’s “frame.” The frame includes both the view box’s point of origin as well as its width and height.

Here’s how we tell containerViewController to match its view’s frame to that of the originViewController:

self.view.frame = originViewController.view.frame

That’s all we need to do in ContainerViewController. If you run your app now, you should see originViewController appear in the app window, but the button you added won’t do anything yet. That’s next.

Return to Main.storyboard. Select the push button you added to originViewController. Hold down the control key and drag the blue connecting line into destinationViewController. Release the mouse button, then select “custom” from the Action Segue popup.

Control-drag to create a segue

What we’ve done is create a new “segue” from originViewController to destinationViewController. Custom segues require custom code that we haven’t written, so this won’t do much yet.

Next, select the push button in destinationViewController and control-drag back toward originViewController. Again, select “custom” segue.

A segue is just a transition from one view controller to another. Apple provides the built-in types shown in the Action Segue popup, but none of those will provide the kind of transition we want. That’s why we’re creating our own. Unfortunately, this takes quite a bit of code. Hang in there.

We’re going to create another subclass, this time of NSStoryboardSegue, and call it “CrossfadeStoryboardSegue.”

Select File > New > File and OS X > Source > Cocoa Class. Use “CrossfadeStoryboardSegue” for the class name and NSStoryboardSegue for the thing it’s subclassing. Leave the language as Swift.

Select CrossfadeStoryboardSegue.swift in the Project navigator.

The first thing a custom segue has to do is pull in the identities of the controllers it’s connecting. We do that with the following block of code:

override init(identifier: String?,
source sourceController: AnyObject,
destination destinationController: AnyObject) {
let myIdentifier = identifier ?? ""
super.init(identifier: myIdentifier, source: sourceController, destination: destinationController)
}
Boilerplate initialization

Now we’re going to customize what happens when our storyboard segue performs its animation. We do that by adding an

override func perform() {
}

function underneath the init function we just added. Make sure both functions are still within the curly brackets, which enclose the class definition. We’re customizing perform(), because it’s the block that will execute when the segue is performed.

At this point all three view controllers (containerViewController, originViewController, and destinationViewController) are hanging around in code, either initialized within ContainerViewController or connected by the segue. We’re going to create handy references to each.

let originViewController = self.sourceController as! NSViewController
let destinationViewController = self.destinationController as! NSViewController

The exclamation mark (as!) is used to insist on something we know to be true but which Xcode isn’t so sure about. originViewController and destinationViewController will certainly be NSViewControllers, because we assigned them subclasses of that kind. We’re telling Xcode to expect that. This little clarification simplifies the code to follow.

Programmers refer to this use of the exclamation point as “forced unwrapping,” but in my own head I tend to think of it as an “insist.”

It’s good to avoid unwrapping as much as possible, because if you insist on something that isn’t able to be done at runtime, your app will likely crash!

Because of our storyboard work, we can count on containerViewController already being a parent to originViewController; unfortunately, Xcode doesn’t “see” that. With parentViewController, Swift’s handy reference to the parent of a view controller, getting a reference to our container should be straightforward:

let containerViewController = originViewController.parentViewController

However, from Xcode’s point of view originViewController may have no parent controller at all, so we need to account for the possibility of the parent reference returning nothing at all. There are a number of ways to do this, but a method suggested by our devs strikes me as particularly elegant:

guard let containerViewController = originViewController.parentViewController else {return}

The guard keyword accounts for the possibility of our parent reference failing. This line says, in effect, “We need to protect against the possibility of parentViewController returning nothing. If it does so, exit without doing anything.”

By the way, my first instinct was to import the storyboard and reinitialize containerViewController by its storyboard ID. But it seems that would actually create a second instance of the controller. By referring to it here via its parent relationship, we get the existing, already initialized controller that we need.

We can now refer to containerViewController, the parent controller, with a bit more confidence. originViewController is already set as a child of containerViewController. We’re going to establish that relationship for destinationViewController as well.

containerViewController.insertChildViewController(destinationViewController, atIndex: 1)

We’re inserting at index 1 this time, because we don’t want to lose our originViewController hanging out at index 0.

Add destination controller

Because we’re going to resize the window to match the dimensions of destinationViewController, it’ll make our work easier if we store that size information in some handy variables within func perform() now:

var targetSize = destinationViewController.view.frame.size
var targetWidth = destinationViewController.view.frame.size.width
var targetHeight = destinationViewController.view.frame.size.height
Save the destination controller size for later

Remember, the view’s frame is its box containing origin, width, and height. From .view.frame we can grab .view.frame.size, which includes the width and height but not the origin. From .view.frame.size, we can grab .view.frame.size.width and .view.frame.size.height, each one still more narrowly focused than .view.frame.size.

If you wanted to get at the origin alone, you could do: .view.frame.size.origin or, more specifically, .view.frame.size.origin.x and .view.frame.size.origin.y. We don’t need those in this case.

Xcode will complain that we’re not using these variables for anything. We will.

We’re finally ready to animate our view controllers. First we have to prep each controller for animation by granting it a Core Animation layer:

containerViewController.view.wantsLayer = true
originViewController.view.wantsLayer = true
destinationViewController.view.wantsLayer = true

And then we tell containerViewController to make the transition happen:

containerViewController.transitionFromViewController(originViewController, toViewController: destinationViewController, 
options: NSViewControllerTransitionOptions.Crossfade, completionHandler: nil)
Actually perform the transition

All that is just to say: have containerViewController transition from originViewController to destinationViewController with the Crossfade option. NSViewControllerTransitionOptions offers a few methods for animating this transition, including sliding the new view controller in from one side or another.

What’s a completionHandler? The transitionFromViewController() method includes a built-in “callback” that will let our code know when it’s done transitioning. We could use that information to make something else happen once the transition is complete. That’s a neat trick, but we don’t have any use for it just now. We set the completionHandler to nil to indicate “don’t bother.”

If you want to see a use for completions, you can find an example of just that in Prototyping for Xcode: Part 3. You should probably start with Part 1, though.

At this point we have a custom segue that will mostly work, though we have to establish that this is the segue we want our view controllers to use. Go back to Main.storyboard, click on each of the two segue arrows connecting originViewController and destinationViewController, and in the Attributes inspector (fourth button in the inspector on the right side of the Xcode window) enter “CrossfadeStoryboard” for Class.

Assign the CrossfadeStoryboard class to the custom segue

You can now run your app and try the button in originViewController. It will at least animate the transition to destinationViewController, though you may not like how destinationViewController fits in the window. That’s because we haven’t told the window to resize to fit it. If you resize the destination controller on the storyboard to use a very different size from the origin controller, you’ll be better able to see the issue.

Go back to CrossfadeStoryboardSegue.swift and add the following on the next available line of the perform() function after transitionFromViewController():

originViewController.view.animator().setFrameSize(targetSize)
destinationViewController.view.animator().setFrameSize(targetSize)

Because we set up originViewController and destinationViewController with Core Animation layers earlier, we can now use the animator() feature on their views. animator() can do many things, but here we’re just using it to resize the frames for both source and destination controllers. Our end goal for both controllers is to be the size of the destination controller, which we captured earlier in the variable targetSize. originViewController needs to resize itself to match the size of destinationViewController as that controller fades in.

But why does destinationViewController need to resize itself to match the very size we grabbed from destinationViewController? I’m not entirely sure, honestly. My best guess is somewhere in the process destinationViewController adapted its size to that of its parent, containerViewController, requiring us to get it straightened out here.

All that remains, really, is to resize the app window. First, we need to get the current size and location of the window by storing its frame:

guard var currentFrame = containerViewController.view.window?.frame else {return}

containerViewController is still the window’s main view controller, so it’s the best controller to ask about the current window size. view.window means something like “the window containing this view.” And what we need is not the window but its location and size, its frame.

Why the question mark? Question marks in Swift indicate a value that is optional. We’re indicating that containerViewController’s view may or may not be in a window at runtime. It’s almost certainly going to be in a window because of the setup we’ve done elsewhere, but Xcode can’t know that just looking at our custom segue code.

This is also why we wrap setting the var in another guard statement.

What we’re going to do with the next several lines of code is manipulate this currentFrame, and then pass it back to the window so it resizes the way we want. There’s a catch though: We can’t readily manipulate currentFrame in its current form. We have to convert it from an NSRect, the usual data type for a view’s frame, to a CGRect.

var currentRect = NSRectToCGRect(currentFrame)

Now we have the window’s frame stored as currentFrame and a CGRect version called currentRect. We want to resize this to match targetFrame using targetWidth and targetHeight, which we set earlier. But we also want to shift it along the x and y axes, so the window seems to resize from the center rather than the top left.

Making that happen is a matter of shifting the frame one half of the difference in dimensions between the original currentFrame and the end goal targetFrame. We need to do the math to get those horizontal and vertical shifts first:

var horizontalChange = (targetWidth - containerViewController.view.frame.size.width)/2
var verticalChange = (targetHeight - containerViewController.view.frame.size.height)/2

horizontalChange is the difference between targetWidth (the width of destinationViewController’s view frame) and the width of containerViewController’s view frame, divided by two. verticalChange is the difference between targetHeight (the height of destinationViewController’s view frame) and the height of containerViewController’s view frame, divided by two.

Now we can take horizontalChange, verticalChange, targetWidth, and targetHeight and make a new NSRect from them.

var newWindowRect = NSMakeRect(currentRect.origin.x - horizontalChange, 
currentRect.origin.y - verticalChange, targetWidth, targetHeight)

NSMakeRect is a function for making rects, natch, and requires four components: the x and y for the upper left origin of the rect, and a width and height. We want our x and y to be the same as that of the current window, but adjusted for the difference between it and the destinationViewController frame origin. The width and height are just the targetWidth and targetHeight we’ve already established.

Finally we can resize the window:

containerViewController.view.window?.setFrame(newWindowRect, display: true, animate: true)

We tell containerViewController to set the frame of its window (which still may not exist, hence the question mark) to the newWindowRect we just created.

It’s fairly clear that setting animate to false will cause the window to snap to the new size instantly. It’s less clear what display is doing there. Setting it to false has no obvious effect. Well, we certainly want to display the new window, so true it is.

At this point your view controller transition, complete with window resize, is finished. Since originViewController is no longer visible in the window, it might be wise to dump it from the hierarchy:

containerViewController.removeChildViewControllerAtIndex(0)
The completed custom segue

For as much code as the custom segue requires, you only have to write it once. The same segue can be used anytime you want to create the same kind of transition; you just have to make sure your source controller is connected as a child to a container controller first.

Download the example project (requires Xcode 7.0 or above).

--

--

John Marstall
BPXL Craft

Designer. Xplane | Firewheel Design | Gowalla | Black Pixel | Kaleidoscope | NetNewsWire | Hypergiant