Week 10—Drawing Fun

Goals

Drawing Fun

(This lab exercise is based on material from Chapters 17 and 18 of the 4th edition of Hillegass and Preble.)

Review material about custom views and the view hierarchy. This is covered in lectures, and also at the start of Chapter 17 in the 4th edition of Hillegass and Preble (Up to "Get a View to Draw Itself").

A custom view

Create a new project in Xcode that is a Cocoa Application named "DrawingFun". Unlike in past exercises, we will use storyboards within Xcode this time—i.e., the only type of Cocoa Application template provided in versions of Xcode from 8.3 up.

Select the "DrawingFun" folder in the Xcode project navigator. Then, using the File -> New menu, create a New File named "StretchView" that is a Cocoa class, and specifically a subclass of NSView.

In the Interface Builder, examine "Main.storyboard". Using the Library (bottom right-hand window) look up "Custom View", and drag and drop such a custom view into the View Controller's View (use the Document Outline view, if needed). In the Identity Inspector (right-hand-side panels), set the class of this "Custom View" to be StretchView rather than NSView.

(In the 4th ed., you should have reached the start of the "Size Inspector" section.)

Controlling StretchView's size

You want to ensure that the StretchView resizes along with its containing content view (of the window). The 4th edition of H&P shows an older version of Xcode. The 5th edition of H&P covers the Auto Layout features of Xcode 6, 7 and 8, see "Cleaning up with Auto Layout" up to "Drawing Images". We will describe the steps below, also.

In the bottom right corner of the Main.storyboard document (as shown in the Interface builder) there are four small icons. You can hover over them to see their name in their tool-tip. Select your custom view, and click the "Add New Constraints" icon. A pop-up window will appear: in the "Add New Constraints" section, click the lines that extend out from the square so that they are all solid red. You may need to change each of the four text fields to the value 20. Then click the "Add 4 constraints" button. You will see the constraints in the IB whenever your StretchView is selected, as four struts connecting the edges of StretchView to the edges of the content view.

The draw message

Your view will have its draw: (renamed from drawRect:) method called when it needs to draw or redraw itself. A rectangle passed with the method call will indicate the specific area that needs redrawing.

Note that if you need to cause some other view to be redrawn, you do not call its draw method directly. Instead you set needsDisplay to be true.

Set the draw: method in StretchView to fill the rectangle to green using the following code. (You have seen the bounds concept in the frame and bounds demo.)

StretchView.swift
01:
02:
03:
04:
05:
06:
07:
08:
09:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
//
//  StretchView.swift
//  DrawingFun
//
//  Created by David Eyers on 20/09/16.
//  Updated for Xcode 8.3 and Swift 3.x in September 2017
//

import Cocoa

class StretchView: NSView {
    
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        let bounds = self.bounds
        NSColor.green.set()
        NSBezierPath.fill(bounds)
    }
}

Run your application to check that your content view is resizing as expected.

Drawing with a NSBezierPath

We used an NSBezierPath to fill a rectangle, however they can also draw lines, ovals, curves and polygons.

Use the following code to create a random NSBezierPath when your StretchView is first initialised.

StretchView.swift
01:
02:
03:
04:
05:
06:
07:
08:
09:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
//
//  StretchView.swift
//  DrawingFun
//
//  Created by David Eyers on 20/09/16.
//  Updated for Xcode 8.3 and Swift 3.x in September 2017
//

import Cocoa

class StretchView: NSView {

    var path = NSBezierPath()

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        srandom(2)
        path.lineWidth = 3.0 
        var p:NSPoint = self.randomPoint()
        path.move(to: p)
        
        for _ in 0...14 {
            p = self.randomPoint()
            path.line(to: p)
        }
        path.close()
    }
    
    func randomPoint()->NSPoint {
        let r:NSRect = self.bounds
        let nx = r.origin.x + CGFloat(Int(arc4random()) % Int(r.size.width))
        let ny = r.origin.y + CGFloat(Int(arc4random()) % Int(r.size.height))
        return NSPoint(x: nx, y: ny)
    }
    
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        let bounds = self.bounds
        NSColor.green.set()
        NSBezierPath.fill(bounds)
        
        NSColor.white.set()
        path.stroke()
    }
}

See what happens if you change stroke() to fill().

NSScrollView

We will extend your application to allow scrolling of your artwork. In the Interface Builder, select your StretchView and from the Editor menu, select "Embed In" -> "Scroll View". Note that you will need to use the following code to override the intrinsicContentSize.

StretchView.swift
    override var intrinsicContentSize: NSSize {
        return NSSize(width: 400, height: 400)
    }

Now, we want the scroll view to resize with the window in the same manner as the StretchView used to. Repeat steps similar to the above to "Add Constraints" to the scroll view so that it resizes with the window.

Integrating Mouse Events

We will extend your Drawing Fun application to place an image using mouse events. First, you should revise the lecture material on NSResponder and NSEvent. You may wish to download and experiment with the mouse and event demonstrations on the lecture notes page. (This material is also covered in the first part of Chapter 18 in the 4th edition of Hillegass & Preble.)

Modify your StretchView.swift file to incorporate mouse event methods as follows.

StretchView.swift
01:
02:
03:
04:
05:
06:
07:
08:
09:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
46:
47:
48:
49:
50:
51:
52:
53:
54:
55:
56:
57:
58:
59:
60:
//
//  StretchView.swift
//  DrawingFun
//
//  Created by David Eyers on 20/09/16.
//  Updated for Xcode 8.3 and Swift 3.x in September 2017
//

import Cocoa

class StretchView: NSView {

    var path = NSBezierPath()

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        srandom(2)
        path.lineWidth = 3.0 
        var p:NSPoint = self.randomPoint()
        path.move(to: p)
        
        for _ in 0...14 {
            p = self.randomPoint()
            path.line(to: p)
        }
        path.close()
    }
    
    func randomPoint()->NSPoint {
        let r:NSRect = self.bounds
        let nx = r.origin.x + CGFloat(Int(arc4random()) % Int(r.size.width))
        let ny = r.origin.y + CGFloat(Int(arc4random()) % Int(r.size.height))
        return NSPoint(x: nx, y: ny)
    }
    
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        let bounds = self.bounds
        NSColor.green.set()
        NSBezierPath.fill(bounds)
        
        NSColor.white.set()
        path.stroke()
    }

    override var intrinsicContentSize: NSSize {
        return NSSize(width: 400, height: 400)
    }
    
    override func mouseDown(theEvent: NSEvent) {
        NSLog("mouseDown: \(theEvent.clickCount)")
    }
    override func mouseDragged(theEvent: NSEvent) {
        NSLog("mouseDragged: \(theEvent.locationInWindow)")
    }
    override func mouseUp(theEvent: NSEvent) {
        NSLog("mouseUp:")
    }

}

Adding the opacity slider

We will add functionality to superimpose an image on the view using the mouse. Additionally, a horizontal slider will be added to the user interface below the scroll view to control the opacity of the superimposed image.

Add an @IBOutlet named stretchView for your StretchView into the View Controller created as part of the default storyboard, and ensure that this @IBOutlet is bound to the StretchView seen in the Main.storyboard file, in the Interface Builder.

Drop a slider onto the window. It should be a child of the content view. In the inspector panel, set the slider's range from 0 to 1. Tick the check box that is labelled "Continuous".

Use "Add Constraints" to appropriately pin the left, bottom, and right of the slider to the containing window. Also fix the pinning of the Bordered Scroll View. This can be done by selecting the Bordered Scroll View, and then double-clicking the yellow warning pop-up that appears if you adjust the bottom edge of the scroll view on the canvas. The previous value (20) is shown, but a pull-down allows you to select the canvas value. Run your application to ensure that the slider and the scroll view are resizing as expected, when you resize the window.

Extending the StretchView class

Change your StretchView class to incorporate the opacity and NSImage fields that we will need.

StretchView.swift
01:
02:
03:
04:
05:
06:
07:
08:
09:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
46:
47:
48:
49:
50:
51:
52:
53:
54:
55:
56:
57:
58:
59:
60:
61:
62:
63:
64:
65:
66:
67:
68:
69:
70:
71:
72:
//
//  StretchView.swift
//  DrawingFun
//
//  Created by David Eyers on 20/09/16.
//  Updated for Xcode 8.3 and Swift 3.x in September 2017
//

import Cocoa

class StretchView: NSView {
    
    var path = NSBezierPath()
    
    dynamic var opacity:Float = 1.0 
    dynamic var image:NSImage = NSImage()
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        srandom(2)
        path.lineWidth = 3.0 
        var p:NSPoint = self.randomPoint()
        path.move(to: p)
        
        for _ in 0...14 {
            p = self.randomPoint()
            path.line(to: p)
        }
        path.close()
    }
    
    func randomPoint()->NSPoint {
        let r:NSRect = self.bounds
        let nx = r.origin.x + CGFloat(Int(arc4random()) % Int(r.size.width))
        let ny = r.origin.y + CGFloat(Int(arc4random()) % Int(r.size.height))
        return NSPoint(x: nx, y: ny)
    }
    
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        let bounds = self.bounds
        NSColor.green.set()
        NSBezierPath.fill(bounds)
        
        NSColor.white.set()
        path.stroke()
        
        // draw image
        var imageRect = NSRect()
        imageRect.origin = NSZeroPoint
        imageRect.size = image.size
        let drawingRect = imageRect
        image.draw(in: drawingRect, from: imageRect,
                         operation: NSCompositingOperation.sourceOver,
                         fraction: CGFloat(opacity))
    }
    
    override var intrinsicContentSize: NSSize {
        return NSSize(width: 400, height: 400)
    }
    
    override func mouseDown(with theEvent: NSEvent) {
        NSLog("mouseDown: \(theEvent.clickCount)")
    }
    override func mouseDragged(with theEvent: NSEvent) {
        NSLog("mouseDragged: \(theEvent.locationInWindow)")
    }
    override func mouseUp(with theEvent: NSEvent) {
        NSLog("mouseUp:")
    }
    
}

Binding the slider's value into our view controller

This is a preview of bindings: we will discuss these further in the next lecture.

In the Interface Builder (for Main.storyboard), select the slider, and show its the Bindings Inspector. Drop down the Value section, and select the "Bind to" checkbox, using the drop-down selection "View Controller". For Model Key Path, enter self.stretchView.opacity. Xcode may not be able to autocomplete or find the symbol. Save your files before running your code.

Using an NSOpenPanel

We will use an NSOpenPanel to allow selection of an image that can be superimposed onto the view. In the Application Scene's menu bar, right-click the "File" -> "Open" menu item to check that it is showing under "Sent Actions" the entry with left-hand-side "action" and right-hand-side "First Responder openDocument:". If this sent action is not present, control-drag the "Open..." menu item to the "First Responder" proxy object under the "Application Scene" in the Document Outline. Create an IBAction named openDocument in your view controller, including the implementation here.

ViewController.swift
01:
02:
03:
04:
05:
06:
07:
08:
09:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
//
//  ViewController.swift
//  DrawingFun
//
//  Created by David Eyers on 18/09/17.
//  Copyright © 2017 David Eyers. All rights reserved.
//

import Cocoa

class ViewController: NSViewController {

    @IBOutlet weak var stretchView: StretchView!
    
    override func viewDidLoad() {
        super.viewDidLoad()

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

    override var representedObject: Any? {
        didSet {
        // Update the view, if already loaded.
        }
    }

    @IBAction func openDocument(_ sender: AnyObject) {
        let panel = NSOpenPanel()
        panel.allowedFileTypes = NSImage.imageTypes()
        panel.beginSheetModal(for: stretchView.window!,
                              completionHandler: { (returnCode)-> Void in
                                if returnCode == NSModalResponseOK {
                                    let image = NSImage(byReferencing: panel.url!)
                                    self.stretchView.image = image
                                    self.stretchView.needsDisplay = true
                                } } )
    }
   
}

Test that your application can open an image, and that the image displays in the bottom-left of your scroll view. The opacity slider will cause the opacity to change whenever the image is redrawn. For example, resizing the window will trigger a redrawing event.

Incorporating Mouse events

We will now use mouseDown and mouseUp events to define where the image should be superimposed. Note that every view has its own coordinate system, and (0,0) is in the lower-left corner. You can reconfigure the coordinate system if you wish.

If a and b are both NSViews and p is an NSPoint, you can convert p from b to a's coordinates using: let pa:NSPoint = a.convertPoint(p, fromView: b). If b is nil, then the window's coordinate system is used instead. Mouse events have locations in the window's coordinate system.

Extend your StretchView to include the code below, that tracks mouse actions to position the image.

StretchView.swift
01:
02:
03:
04:
05:
06:
07:
08:
09:
10:
11:
12:
13:
14:
15:
16:
17:
18:
19:
20:
21:
22:
23:
24:
25:
26:
27:
28:
29:
30:
31:
32:
33:
34:
35:
36:
37:
38:
39:
40:
41:
42:
43:
44:
45:
46:
47:
48:
49:
50:
51:
52:
53:
54:
55:
56:
57:
58:
59:
60:
61:
62:
63:
64:
65:
66:
67:
68:
69:
70:
71:
72:
73:
74:
75:
76:
77:
78:
79:
80:
81:
82:
83:
84:
85:
86:
87:
88:
89:
90:
91:
92:
93:
//
//  StretchView.swift
//  DrawingFun
//
//  Created by David Eyers on 20/09/16.
//  Updated for Xcode 8.3 and Swift 3.x in September 2017
//

import Cocoa

class StretchView: NSView {
    
    var path = NSBezierPath()
    
    dynamic var opacity:Float = 0.5 
    dynamic var image:NSImage = NSImage()

    // where the mouse is first clicked
    var downPoint = NSPoint()
    // where it is dragged to, and released
    var currentPoint = NSPoint()
    
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        srandom(2)
        path.lineWidth = 3.0 
        var p:NSPoint = self.randomPoint()
        path.move(to: p)
        
        for _ in 0...14 {
            p = self.randomPoint()
            path.line(to: p)
        }
        path.close()
    }
    
    func randomPoint()->NSPoint {
        let r:NSRect = self.bounds
        let nx = r.origin.x + CGFloat(Int(arc4random()) % Int(r.size.width))
        let ny = r.origin.y + CGFloat(Int(arc4random()) % Int(r.size.height))
        return NSPoint(x: nx, y: ny)
    }
    
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        let bounds = self.bounds
        NSColor.green.set()
        NSBezierPath.fill(bounds)
        
        NSColor.white.set()
        path.stroke()
        
        // draw image
        var imageRect = NSRect()
        imageRect.origin = NSZeroPoint
        imageRect.size = image.size
        let drawingRect = imageRect
        image.draw(in: drawingRect, from: imageRect,
                         operation: NSCompositingOperation.sourceOver,
                         fraction: CGFloat(opacity))
    }
    
    override var intrinsicContentSize: NSSize {
        return NSSize(width: 400, height: 400)
    }
    
    override func mouseDown(with theEvent: NSEvent) {
        let p = theEvent.locationInWindow
        downPoint = convert(p, from: nil)
        currentPoint = downPoint
        self.needsDisplay = true
    }
    override func mouseDragged(with theEvent: NSEvent) {
        let p = theEvent.locationInWindow
        currentPoint = convert(p, from: nil)
        self.needsDisplay = true
    }
    override func mouseUp(with theEvent: NSEvent) {
        let p = theEvent.locationInWindow
        currentPoint = convert(p, from: nil)
        self.needsDisplay = true
    }
    
    func currentRect()->NSRect{
        let minX = min(downPoint.x, currentPoint.x)
        let maxX = max(downPoint.x, currentPoint.x)
        let minY = min(downPoint.y, currentPoint.y)
        let maxY = max(downPoint.y, currentPoint.y)
        
        return NSMakeRect(minX, minY, maxX-minX, maxY-minY)
    }
    
}

The "setter" of opacity and image should be used to trigger needsDisplay, and should set a visible rectangle for the initial image placement. This functionality has been partially implemented by augmenting the ViewController as below.

ViewController.swift
    @IBAction func openDocument(_ sender: AnyObject) {
        let panel = NSOpenPanel()
        panel.allowedFileTypes = NSImage.imageTypes()
        panel.beginSheetModal(for: stretchView.window!,
                              completionHandler: { (returnCode)-> Void in
                                if returnCode == NSModalResponseOK {
                                    let image = NSImage(byReferencing: panel.url!)
                                    self.stretchView.image = image
                                    self.stretchView.needsDisplay = true
                                    self.stretchView.downPoint = NSZeroPoint
                                    self.stretchView.currentPoint = NSPoint(x: image.size.width,
                                                                            y: image.size.height)
                                    
                                } } )
    }

Autoscrolling

When you drag beyond the edge of the scroll view, the view does not automatically scroll. To enable this functionality, add self.autoscroll(theEvent) just before the needsDisplay line in your mouseDragged handler.

Exercises

As time permits, do the following exercises. (They are derived from the exercises at the end of Chapters 17 and 18 in the 4th edition of Hillegass & Preble.)

Exercise 1

NSBezierPath can also draw Bezier curves. Replace the straight lines with randomly curved ones. (Hint: Look in the documentation for NSBezierPath.)

Exercise 2

Create a new application that allows the user to draw ovals in arbitrary locations and sizes. You might want to use the following convience initialiser method: NSBezierPath(ovalInRect: yourRect:NSRect)