Centering a Resizable Document View Within a NSScrollView With Swift 2.2 and Xcode 7.3 on Mac OS X

Image 1 - An image that is smaller than the scroll view content area in Apple's Preview Application.  Notice the image is centered in the scroll view.

Image 2 - An image that is larger than scroll view content area in Apple's Preview Application.  Notice the image is scrollable within the scroll view.

Introduction

There are many applications available for Mac OS X that center a scrollable document view when the view is smaller than the scrollable area.  A notable example would be the Preview Application that comes with OS X.  View an image that is smaller than the visible window area, and it centers the document view - resize the document view to larger than the viewable area, and the view is scrollable.  This is such a common idiom, one would think that it would be well supported by default as part of the platform SDK for NSScrollView.  But in fact it is not.  Indeed it is not readily apparent how to do this effectively without incurring strange side effects.

Apple does not give one an easy setup for this behavior.  The document view of a NSScrollView is pinned to the lower left corner of the window by default.  A custom view can pin the document view to the upper left by overriding NSView.flipped property getter.  But neither of these options achieve the desired result.  Other than that, Apple gives very few hints in their standard documentation.  Fortunately, there are some pointers, and bits of sample code from conferences that we can pull from.

It does not take a lot of experimentation before it becomes apparent that naive approaches to creating a centering scroll view, like overriding the NSView.frame or NSView.intrinsicContentSize property of the document view, become unwieldy or simply do not work.  There are some older solutions, such as the ones proposed here that are either missing something critical, require old, deprecated code, or simply don't work in modern code.

After much experimentation, perusing of various solutions from several sources, and reading much documentation from Apple, I have found a solution that works consistently, and without apparent side effects.  This blog post describes how to construct such a scroll view.  I am hoping it will be useful to someone, and perhaps relieve them of one less headache.

This tutorial is intermediate difficulty.  Some familiarity with Xcode, Swift, Storyboards, and basic knowledge of auto layout constraints is required.  A complete, working project can be found at my Github page here.

I am going to keep this project fairly simple for the sake of brevity.  I will illustrate the salient points, but extending the concept by adding features will be up to you.  This application will be a simple NSImageView contained as the documentView of a NSScrollView.  The image view will display single asset that will be part of the project.  That asset will be resizable by using Zoom InZoom OutZoom to FitZoom to Actual Size menu items.  The image asset will appear centered when it is displayed smaller than the scroll view's viewable area, and it will scroll when it is larger than the viewable area.  Adding things like loading an image file from the filesystem, or adding extended editing functionality, etc are beyond the scope of this document.

Summary

A very short summary for the more advanced programmer.  Use CenteringClipView.swift as a replacement for the NSScrollView's default NSClipView.  This will add centering capability.  For this to work, you need to provide proper constraints for your contained view.  Add leading and top constraints from your Document View to the CenteringClipView, as well as width and height constraints within the Document View.  Make outlets for your width and height constraints, and use these exclusively for resizing your Document View by modifying their constant properties, rather than modifying the frame or bounds properties (or overriding the intrinsicContentSize property).  Using the width and height constraint constants will ensure that any changes are reflected when the view hierarchy gets laid out.  The details of how and when you resize the Document View is up to you.  There is a sample project available on Github for you to see how I did it.

Lets Get Started

Image 3 - New project settings

Let's start by making a new project. Open Xcode, go to File Menu➞New➞Project...  Make a Cocoa Application, give yourself a Product Name (I use SimpleImageViewer), choose Swift for the Language, make sure check the Use Storyboard option, and uncheck Create Document-Based Application.  You can leave the rest of the options as defaults, or fill them out as you like.  We won't be using Core Data or making Unit Tests for this tutorial, but you are welcome to enable or disable these based on your needs.  Click Next, then choose a filesystem location for your project, and click Create.

Add Important Files

Apple provides part of the solution for us with some sample code from the Apple Developer website.  They provide the centering capability for us in the form a of a file called CenteringClipView.swift.  You will want to grab this file and add it to your Project.  Remember to grab the license as well if you intend on distributing your application.  There is a copy of this file in the Github Repository for this project, that includes the license within the file itself, which you are welcome to use as well.  Download the file, and then add it to your Project by dragging the file from the Finder to the Project Navigator area of your window.   CenteringClipView.swift will be used later to replace the standard ClipView of our NSScrollView.

Image 4 - Adding an asset to the project

We need an image to work with, so also grab an image asset and add it to your Assets.xcassets group.  To do this, select the Assets.xcassets group in the Project Navigator.  Drag your file to the document view, right under the AppIcon placeholder (be careful not to drop it on the AppIcon placeholder, or the image will become your application icon).  This will add the asset to your project.  Make sure to take not of the name of your asset, or change it to something memorable.  You are welcome to use the image asset included with the project in the Github Repository.  I will refer to that name of that asset, IMG_3558, when we need to use it later.

Setup the ViewController

The Object Library of the Utilities View is necessary for the next steps, so make sure you have it visible.  You can do this by selecting View Menu➞Utilities➞Show Object Library.

Image 5 - Scroll View in the Object Library

Image 6 - CenteringClipView Custom Class that replaces the standard NSClipView

Now we are read to set up the ViewController in the Storyboard.  Click the Main.Storyboard in the Project Navigator.  Expand the View Controller Scene, and then the View Controller, in the Document Outline (just to the right of the Project Navigator).  Find the Scroll View object in the Object Library (Image 5).  Replace the content View of the View Controller by dragging the Scroll View over the content View in the Document Outline.  The content View will highlight blue when you are in the right place.  Drop the Scroll View in place.  The content View of the View Controller should now display as Bordered Scroll View in the Document Outline.

Image 7 - View Controller hierarchy

Expand the Bordered Scroll View, and select the Clip View.  Go to the Identity Inspector in the Utility View on the right.  Type "CenteringClipView" in the Class combo box (Image 6).

Your view hierarchy in the Document Outline should now look like Image 7.  The View seen highlighted in blue in the image, is referred to as the Document View of the Scroll View.  I will use that name from here on out.  You may want to rename it in the Document Outline, if it helps you keep things clear.  To do this click once to highlight the View, wait a second or two, then click again to edit the name.

 

 

Add Constraints

Then next step is to add constraints from the Document View to the Centering Clip View, and constraints within the Document View.  If you have not already done so, make sure to expand the Centering Clip View to expose the Document View (as shown in Image 7).  Before you add constraints, it is important to know that strange behavior can happen if your Document View is larger than the Centering Clip View in the Storyboard.  If you have not changed any of the standard sizing of the views, you shouldn't have a problem.  But if you have, you will want to make sure that your Document View is fully contained within the Centering Clip View.

Okay, let's add some constraints.  Hold down the Ctrl key, click the Document View, and drag a blue line to the Centering Clip View, then release the mouse button (Image 8).  A dark grey, semi-transparent popup panel window will appear.  Hold the shift key, and click Leading Space to Container, Top Space to Container (Image 9).  Once all of these are checked, click Add Constraints.  Ctrl-click the Document View again, but this time drag the blue line a little to the left or right so that the line ends within the same Document View, then release the mouse button (Image 10).  This will give you a popup panel that lets you set constraints internal to the Document View.  Shift-click the Width and Height options, then click Add Constraints (Image 11).  What you have just done is pin the Document View to the top left of the Centering Clip View, and then given the Document View a Width and Height constraint based on the default width and height in Storyboard layout.  The default priority for the Width and Height is 1000, or the highest priority possible.  This is important, so that when we use the Width and Height constraints to resize our view, our new size constraints are given higher priority than other constraints around it.  The Leading Space to Container and Top Space to Container constraints are essentially placeholders to give us the ability to add Document View constraints without warnings or errors showing up in the Storyboard layout.

Image 8 - Ctrl-click and drag a blue constraint line from Document View to Centering ClipView

Image 9 - Constraints for Document View to Centering Clip View

Image 10 - Ctrl-click and drag a blue constraint line from Document View to itself

Image 11 - Internal Constraints for Document View

Image 12 - Resolve Auto Layout Constraints button

This will set up the constraints, but the layout will have some unresolved issues because of what appears to be a bug in Xcode 7.3. The Storyboard will show the layout problem as an orange square with an orange dashed square below it.  To fix the issue, make sure you have the Document View selected, then click the Resolve Auto Layout Issues button, and select Update Frames (Image 12).  This will set the view to it's proper position in the Storyboard layout, and the layout warnings will disappear.

Add NSImageView to Document View

Image 13 - Ctrl-click and drag a blue line from the Image View to the Document View

Image 14 - Constraints for Image View to Document View

Now we are ready to add a NSImageView to the Document View.  In the Object Library, click and drag a NSIMageView and drop it as a child of the Document View in the Document Outline.  If it does not expand automatically, expand the Document View, and select the Image View.  Add constraints to the image view by Ctrl-clicking and dragging a blue line from the Image View to the Document View, then releasing the mouse button (Image 13).  In the panel that appears, shift-click to add the following constraints: Center Horizontally in ContainerCenter Vertically in Container, Equal WidthsEqual Heights.  Click Add Constraints (Image 14).  Unless you happen to get lucky, or unless you already changed the position of and size of the view to match the Document View, you will likely have layout constraint issues that show up as orange rectangles and lines around the Image View in the Storyboard.  To fix the issue, make sure you have the Image View selected, then click the Resolve Auto Layout Issues button, and select Update Frames (Image 12). 

Image 15 - Attributes Inspector for the Image View

Now remember that image asset we added to the project at the beginning of this whole thing?  We now want to set that as the image to display for the Image View.  Select the Image View, then go to the Attributes Inspector.   If the Attributes Inspector is not open already, you can show it by going to the View Menu➞Utilities➞Show Attributes Inspector, and it will slide in from the right hand of the screen.  Either type in "IMG_3558" (or then name of your image asset), or select it from the combo-box next to the Image label.  Once you select the image, you should see it appear in the Storyboard.  For Scaling, select Axes Independently.  We will be taking over the proportional scaling of the image in a small bit of code later, so having the image scale in one of the other methods listed in the Scaling popup is not necessary.  Finally, select None for the Border.  Everything else should be set to default. (Image 15)

Test Run

If you run the program now, you should see a window with a small image centered in the middle.  The image may be rather small, depending on what size your Document View is set to by default.  In addition, your image may appear stretched, depending on it's aspect ratio in relationship to the Document View's aspect ratio.  Your image should appear centered vertically and horizontally, but it likely won't scroll, unless you can make the window small enough that the image is larger than it's scroll view container.  In the next sections we will be adding code to adjust the default size of the image based on it's aspect ratio, and add menu items to zoom the image.

Add Outlets

As I mentioned earlier, to avoid layout issues when resizing the Document View we want to modify the Width and Height constraints of the Document View, rather than use one of the other methods (such as frame, bounds, or intrinsicContentSize).  So we need outlets to the Width and Height constraints in our code.  In addition, we need an outlet to the Image View to get the original size and aspect ratio of the image asset it contains, and we need the size of the Centering Clip View to accurately calculate the scaling factor when attempting to Zoom to Fit, so we will need an outlet for that as well.  As a note, there are many other methods to get and set these values than the way I describe that may be useful to you (such as Cocoa Bindings).  But I am providing this method since it is the most immediately straightforward.  I do recommend looking into Cocoa Bindings and Core Data if you are curious, as both technologies are extremely powerful and can simplify complex code considerably.

Add Outlets for Document View Width and Height Constraints

In the Document Outline, expand the Document View to show the Image View and Constraints.  Expand the Constraints outline that is contained in the Document View (right under the Image View).  You should see a list of constraints, one of which will be View Width Constraint, and one that will be View Height Constraint (the View Width Constraint may also just say width followed by a number and View Height Constraints may say height followed by a number. For example "width = 400" and "height = 300".  Note the number for the width and/or height in your Project may be different than mine).  Open the Assistant Editor (Click the button on the right hand side of the Toolbar that looks like two interlocking circles, or select View Menu->Assistant Editor->Show Assistant Editor). The Assistant Editor should automatically show the ViewController.swift file.  If it does not, click inside the Assistant Editor window, then select the ViewController.swift file in the Project Navigator.  You should now see the Storyboard on the left in the Standard Editor view, and the ViewController.swift file on the right in the Assistant Editor view.

Create an outlet from the View Height Constraint to ViewController.swift by Ctrl-clicking the View Height Constraint and then dragging a blue line to the ViewController.swift file.  Drag the blue line right over the line just below the class declaration ( the line below where it says

class ViewController: NSViewController {

If you have line numbering turned on, it should be line 12 or 13).  You will know you are in a good place to add an outlet when a little grey box appears that says "Insert Outlet".  Release the mouse button.  A connection dialog will appear.  Type in "viewHeightConstraint"  for the Name of the outlet.  Make sure that the Type is NSLayoutConstraint, and that Storage is Weak.  Then click Connect.  Congratulations, you just made an outlet!

Image 16 - Inserting an Outlet from the Document View Width Constraint into the ViewController class.  Also, the Assistant Editor Button in the upper right and of the Toolbar is circled in yellow.

Do the same process to make an outlet for the View Width Constraint.  Place this one right under the viewHeightConstraint in the ViewController.swift Assistant Editor view, and name it "viewWidthConstraint".  Use the same settings for the other values in the connection dialog as you used for the View Height Constraint.

Add Image View and Centering Clip View Outlets

Now add outlets for the Image View and Centering Clip View.  Use the same method demonstrated above.  Name the Image View outlet "imageView", and the Centering Clip View outlet "clipView".

When you are done,  your Swift ViewController class should look something like the following:

class ViewController: NSViewController {


@IBOutlet weak var viewHeightConstraint: NSLayoutConstraint!
@IBOutlet weak var viewWidthConstraint: NSLayoutConstraint!
@IBOutlet weak var imageView: NSImageView!
@IBOutlet weak var clipView: CenteringClipView!

override func viewDidLoad() {

super.viewDidLoad()

// Do any additional setup after loading the view.
}

}

Add zoomFactor Property

So now that we have the ability to manipulate the constraints, and can easily retrieve the image size and clip view size, we need a method to actually do the resize.  For this example, we are only going to allow the user to do a proportional resize.  Essentially we are going to allow the user to zoom in, zoom out, zoom the image to it's actual size, and zoom the image fit the current view size.

So we need a method to do this.  Swift is particularly powerful in the way that it implements variables.  Every variable can be treated as a property, with the ability to override getting and setting the property (through get and set property methods) as well as provides the ability to add get and set observers (with willSet and didSet property methods respectively).  In our case we want to provide a zoomFactor property that has a didSet observer.  This way we can easily get and set the zoomFactor by accessing it just like any variable.  In addition, when the user sets the zoomFactor property, we want to update the Document View to reflect this change.  Add the following code somewhere in your ViewController class:


var zoomFactor:CGFloat = 1.0 {

didSet {

guard imageView.image != nil else {

return

}

viewHeightConstraint.constant = imageView.image!.size.height * zoomFactor
viewWidthConstraint.constant = imageView.image!.size.width * zoomFactor

}

}

In the code above we declare the var zoomFactor as a non-optional CGFloat, and give it a default value of 1.0.  Because the view gets setup before the zoomFactor gets it's initial value, we will still have to set the value again after the view has loaded (in the viewDidLoad method).  

We then declare a didSet observer for this value.  So any time we set the zoomFactor, the didSet observer will be called after the value is set.

The first thing we do in the didSet method is set a guard to check that the value of the imageView.image (our image asset) is not nil.  While we explicitly set the image asset in the Storyboard, the ViewController class instance is set up before the Storyboard, so the image is not available at the point of instantiation .  So this will let us escape out of the didSet method if the image is not set.

Next we set the height and width constraint constants based on the image's height and width, multiplied by the zoomFactor.  So a zoomFactor of 1.0 will set the image to it's full, unmodified size.  A zoomFactor of 0.5 will be half the size, and a zoomFactor of 2.0 will be twice the size, etc.  Note, that we implicitly unwrap the imageView.image! variable, since we already know from the guard condition at the top of the method that the image is present and accounted for.  Setting the the constraint constants for the height and the width will automatically invalidate the layout for the view and force a re-draw.

You can experiment with seeing what happens when you change the zoomFactor, by adding a line in viewDidLoad to set the zoomFactor to whatever float value you like, and then run the program.  Notice that if you set it to a value that shrinks the image to be smaller than the scroll view container, the view will be centered.  If you set the zoom value to expand the image to a value larger than the scroll view container, the view will be scrollable.

Add Actions for Controlling Zoom Factor

Ultimately, we want the user to be able to have some control over the action.  So we will want to add menu items that let the user control the zoomFactor.  So we will add actions (user event responders) for Zoom In (increase zoomFactor), Zoom Out (decrease zoomFactor), Zoom to Actual Size (set zoomFactor = 1.0), and Zoom to Fit (set the zoomFactor to a size that is as large as possible while remaining fully visible in the Scroll View).  

Add the following code for Zoom In, Zoom Out, and Zoom to Actual Size (we'll do Zoom to Fit later) to your ViewController class: 


@IBAction func zoomIn(sender: NSMenuItem?) {

if zoomFactor + 0.1 > 4 {

zoomFactor = 4

} else if zoomFactor == 0.05 {

zoomFactor = 0.1

} else {

zoomFactor += 0.1

}

}

@IBAction func zoomOut(sender: NSMenuItem?) {

if zoomFactor - 0.1 < 0.05 {

zoomFactor = 0.05

} else {

zoomFactor -= 0.1

}

}

@IBAction func zoomToActual(sender: NSMenuItem?) {

zoomFactor = 1.0

}


These methods are declared as @IBAction so that we can link them to Menu Items in the First Responder (more on that later).  Each of these methods accepts an optional NSMenuItem.  This enables us to call them programmatically without having to pass a menu item (pass nil instead).  None of these methods use the sender parameter, but they are there because the menu event handlers require them.

The code for zoomIn is pretty straightforward.  Zooming in will make the image bigger, so we wan to increase the zoomFactor.  If the new zoomFactor would be greater than 4 (that is 4 times the original size of the image), then the zoomFactor is set to 4.  If the zoomFactor is very small (i.e. close to 0), the zoomFactor is set to 0.1.  This last is used in conjunction with zoomOut.  A zoomFactor of 0 is undesirable, so we limit the smallest zoom to 0.05. Finally, if the other two conditions aren't satisfied, the default is to add 0.1 to the zoomFactor.  So every call to zoomIn increments the zoomFactor by 0.1, until it reaches a maximum of 4.

The code for zoomOut is even simpler.  Zooming out makes the image smaller by decreasing the zoomFactor.  But if the zoomFactor goes to 0 the image will completely disappear.  Making the image disappear could also have deleterious effects on the rest of the layout as well.  So we want to limit the lower bounds of the zoomFactor to something reasonable.  I picked 0.05, as that is fairly small, but keeps the image visible.  So this code first checks to see if the zoomFactor is about to go less than 0.05.  If the new value would be less than 0.05, then we just set the zoomFactor to 0.05.  Otherwise, we decrement the zoomFactor by 0.1.

zoomToActual is nearly trivial.  A zoomFactor of 1.0 will set the image to it's unmodified size.  So that is what we do.

Add zoomToFit Action Method

The zoomToFit method is a little more complex, so it has it's own section.  Add the following code to your project:


@IBAction func zoomToFit(sender: NSMenuItem?) {

guard imageView!.image != nil else {

return

}

let imSize = imageView!.image!.size

var clipSize = clipView.bounds.size

guard imSize.width > 0 && imSize.height > 0 && clipSize.width > 0 && clipSize.height > 0 else {

return

}

//We want a 20 pixel gutter. To make the calculations easier, adjust the clipbounds down to account for the gutter. Use 2 * the pixel gutter, since we are adjusting only the height and width (this accounts for the left and right margin combined, and the top and bottom margin combined).
let imageMargin:CGFloat = 40

clipSize.width -= imageMargin
clipSize.height -= imageMargin

let clipAspectRatio = clipSize.width / clipSize.height
let imAspectRatio = imSize.width / imSize.height

if clipAspectRatio > imAspectRatio {

zoomFactor = clipSize.height / imSize.height

} else {

zoomFactor = clipSize.width / imSize.width

}

}

As you can see, the zoomToFit algorithm is a little more complex.  For zoomToFit to work, we need the size of the image asset, and we need the size of the Centering Clip View.  The first guard condition -


guard imageView!.image != nil else {

return

}

- ensures that there is an image to operate on.  If there is not, the method returns immediately.  The next operation is to retrieve the image size and the Centering Clip View size (imSize, and clipSize respectively).  The image is an implicitly unwrapped optional since the first guard condition ensures that for us.  

The clipSize.width and clipSize.height are then adjusted down by 40 pixels.  This is to give us a small margin, or "gutter", on either side of the image, so that it doesn't bunch right up against the edge of the Scroll View.  This is a purely aesthetic choice.  The gutter is useful if you want to add functionality later, like rulers, or drawing commands.  It is easier to adjust the clipSize to be smaller, than it is to adjust the final imageSize.  If we adjusted the final imageSize we would have to add at least one more conditional.  The effect of all this is ultimately to make the final zoomFactor slightly smaller.

A second guard condition -


guard imSize.width > 0 && imSize.height > 0 && clipSize.width > 0 && clipSize.height > 0 else {

return

}

- ensures that we won't incur any divide-by-zero runtime errors in our later calculations. 

Then next step is to compute the aspect ratio (width / height) of the clipSize and the imSize, to get the clipAspectRation and imAspectRatio respectively.  This is used to determine which scaling algorithm to use.  When clipAspectRatio is greater than the imAspectRatio, the zoomFactor is calculated by dividing the clipSize.height by the imSize.height.  When the clipAspectRatio is less than or equal to the imAspectRatio, the zoomFactor is calculated by dividing the clipSize.width by the imSize.width.  I won't go into the math about why this works, but if you try it yourself, you will find that it stays consistent.

Make Zoom To Fit the Default on Startup

Let's try out our zoomToFit method by making it the default when you run the program.  Add the following code to you viewDidLoad method:


override func viewDidLoad() {

super.viewDidLoad()

// Do any additional setup after loading the view.
dispatch_async(dispatch_get_main_queue()) { weak self -> Void in

self?.zoomToFit(nil)

}

}

The code above quite simply calls zoomToFit(nil) after the view has loaded.  The dispatch_async(...) is used to delay the operation slightly.  by the time viewDidLoad is called the window and views are setup.  But by default the window remembers it's last position and size when the program was last run.  If the size is larger (or smaller) than the default in the Storyboard, the window will be resized to that when the program is run again.  The viewDidLoad method gets called after the view is set up, but before the window is resized.  If we were to just call zoomToFit(nil) without the delay, we would resize based on the default view size as set in the Storyboard, rather than the final size based on the window final startup size.  The dispatch operation is placed on the main queue, and will happen after everything the window has been resized.

Test Run

Run the program.  If everything has been entered properly, you should see the image defaults to fitting proportionally within the view when the program initially starts.  But we still need add some way for the user to change the zoom factor.

Add Menu Items for Zoom In, Zoom Out, Zoom to Actual Size, and Zoom to Fit

Let's add menu items.

We no longer need the Assistant Editor.  You can go back to the Standard Editor by clicking the Standard Editor button on the right of the Toolbar, or choosing View Menu->Standard Editor->Show Standard Editor.

Open the Main.storyboard from the Project Navigator.  Expand the Application Scene in the Document Outline, then expand Application, and finally expand the Main Menu.  Unless you are making a text editor, you may want to delete the Format Menu.  It uses up a lot of the shortcut keys (Key Equivalents) that are handy for other purposes.  You can delete it by selecting it in the Document Outline, and then pressing the Delete key.

Image 17 - Menu Item in the Object Library

Open the View Menu on the Menu Bar in the Storyboard.  Drag a new Menu Item from the Object Library, and drop it just above the Show Toolbar Menu Item.  Double-click the new Menu Item where it says "Item".  This will let you edit the name of the Menu Item.  Name this one "Zoom In".  Press Return when you are done editing.  Double-click to the far right of the new Menu Item you just added.  A little box should appear.  This will let you add a Key Equivalent.  Type Cmd-= (command key and equal key).  

Do this same process three more times.  Name the next one "Zoom Out", and give it a Key Equivalent of Cmd-- (command key and minus key).  The next should be "Zoom to Fit", and give it Cmd-9 (command key and 9 key), and the final one should be "Zoom to Actual Size" with a Key Equivalent of Cmd-0 (command key and 0 key).  You may want to add a Separator Menu Item between the Zoom to Actual Size and Show Toolbar Menu Items by dragging a Separator Menu Item from the Object Library and placing it between the two Menu Items.

Image 18 - Ctrl-click and drag to connect the Menu Item to the First Responder

Image 19 - Select action in First Responder that corresponds the Menu Item you want to connect.

Now we need to link our new Menu Items to their appropriate action in the First Responder.  Look here if you need more information about how the First Responder works.  Ctrl-click on the Zoom In Menu Item, and drag a blue line to the First Responder that you can find under the Application Scene in the Document Outline.  Release the mouse button and a popup will appear that contains a list of actions you can link to.  Find the one that is listed as zoomIn:, and select it.   The Zoom In Menu Item is now connected to the First Responder.  Since our ViewController.zoomIn(sender:) method has the @IBAction attribute,  any click on this Menu Item (or selection by using its Key Equivalent) will call the ViewController.zoomIn(sender:) method as long as something higher on the responder chain cancels the action.

Link the rest of the zoom Menu Items to the First Responder using the same process as above.  Link the Zoom Out Menu Item to the zoomOut: action, the Zoom to Actual Size Menu Item to the zoomToActual: action, and the Zoom to Fit Menu Item to the zoomToFit: action.

Run the Program

When you run the program now, everything should work.  When the program first opens, the image should be zoomed to fit the Scroll View.  Selecting Zoom In from the View Menu will make the image bigger, selecting Zoom Out will make the image smaller, selecting Zoom to Actual Size will set the image to its default size, and Zoom to Fit will fit the image by scaling it proportionally to be fully visible in the Scroll View.  When the image is smaller than the Scroll View content area it will be centered.  When you expand the image to be larger than the Scroll View content area, the image will scroll.

Where to Go From Here   

This is only a snippet of what may be useful for many larger programs.  This program only gives the most rudimentary start for what would be a full fledged program.  For a complete program you would want to add file loading capability, maybe some editing controls, or other user interaction.  Of course, this all depends on your needs.  Using Cocoa Bindings and Core Data would be recommended for anything more complex than a simple image loader and viewer.  

Hopefully this tutorial is useful to someone.  I tried to be descriptive and complete in giving step-by-step directions.  An advanced user can skim to find what they want, or just grab the Github Project and see how things are setup from there.  But hopefully this is also useful for someone who is bringing up their skill-set.