Skip to content
Yuriy Kashnikov edited this page Sep 20, 2017 · 72 revisions

This tutorial explains core Macaw concepts and introduces its API. By the end of the tutorial, we'll create simple application with animated bar chart.

Table of contents

MacawView

MacawView is a main class used to embed Macaw UI into your Cocoa interface. It extends UIView and can be used as a custom class for a view. Usually you create your own view extended from the MacawView with predefined structure:

import Macaw

class MyView: MacawView {

	required init?(coder aDecoder: NSCoder) {
		let text = Text(text: "Hello, World!", place: .move(dx: 145, dy: 100))
		super.init(node: text, coder: aDecoder)
	}

}

Scene

Macaw allows you to describe your interface as a combination of text, images and geometry objects. Such combination is called a scene graph, or just a scene. Let's go through the all elements we can use to define a scene.

Shape

Shape is a node in a scene representing geometry element. It has three major properties:

  • form - is it rectangle, circle, polygon or whatever else?
  • fill - colors inside the shape
  • stroke - colors of the border around the shape

Let's take a look at a simple shape:

class MyView: MacawView {

    required init?(coder aDecoder: NSCoder) {
        let shape = Shape(form: Rect(x: 100, y: 75, w: 175, h: 30),
                          fill: Color(val: 0xfcc07c),
                          stroke: Stroke(fill: Color(val: 0xff9e4f), width: 2))
        super.init(node: shape, coder: aDecoder)
    }

}

Macaw is using Cocoa coordinate system. For instance, in the example above we draw a rectangle at x=100 and width=175 which horizontally center it on the iPhone 6/6S screen. To support various screen sizes you have two options:

  • use fixed size for your view and align it on a device using Cocoa layout manager
  • layout scene on your own using actual size. Fortunately, vector graphics used by Macaw is highly scalable.

We just draw a simple rectangle, however Macaw has various geometry primitives you can use:

For example, let's change our example to use round rectangle:

let shape = Shape(
    form: RoundRect(
        rect: Rect(x: 100, y: 75, w: 175, h: 30),
        rx: 5, ry: 5),
    fill: Color(val: 0xfcc07c))

Macaw allows to use declarative style for scene definition as well as functional style. For example, that's how the code above will look like in the functional style:

let shape = Rect(x: 100, y: 75, w: 175, h: 30).round(r: 5).fill(with: Color(val: 0xfcc07c))

Use a style which makes your code easier to read.

Text

Another basic node in our scene is Text. It has following properties:

  • text - the string we would like to display
  • fill - text color
  • font - name/size of a text font
  • align/baseline - properties used to align text on the screen

Let's draw some text:

class MyView: MacawView {

    required init?(coder aDecoder: NSCoder) {
        let text = Text(text: "Sample",
                        font: Font(name: "Serif", size: 72),
                        fill: Color.blue)
        super.init(node: text, coder: aDecoder)
    }

}

As you may notice, Text doesn't have specific position like Shape. That's because every node has property place which allows to arrange node on a scene relative to its parent, and even rotate and/or scale it. We'll discuss this property in details below, but for now we can use it in the following way:

text.place = .move(dx: 100, dy: 75)

By default, text is placed relative to its top left corner. To center text horizontally we can use align property:

text.place = .move(dx: 375 / 2, dy: 75)
text.align = .mid

We can also you baseline property to align text vertically.

Group

Now, we can combine several elements together using Group node. It has only one major property contents: array of nodes to combine.

class MyView: MacawView {

    required init?(coder aDecoder: NSCoder) {
        let shape = Shape(
            form: Rect(x: -100, y: -15, w: 200, h: 30).round(r: 3),
            fill: Color(val: 0xff9e4f),
            place: .move(dx: 375 / 2, dy: 75))
        let text = Text(
            text: "Show",
            font: Font(name: "Serif", size: 21),
            fill: Color.white,
            align: .mid,
            baseline: .mid,
            place: .move(dx: 375 / 2, dy: 75))
        let group = Group(contents: [shape, text])
        super.init(node: group, coder: aDecoder)
    }

}

Note that we align text and choose shape form so that center of each node was in the (0,0) point, and then we move each node to the center of the screen. Actually, we don't need to move each node in the group, because we can move the group itself. Let's update our code a little bit:

class MyView: MacawView {

    required init?(coder aDecoder: NSCoder) {
        let shape = Shape(
            form: Rect(x: -100, y: -15, w: 200, h: 30).round(r: 5),
            fill: Color(val: 0xff9e4f))
        let text = Text(
            text: "Show",
            font: Font(name: "Serif", size: 21),
            fill: Color.white,
            align: .mid,
            baseline: .mid)
        let group = Group(contents: [shape, text], place: .move(dx: 375 / 2, dy: 75))
        super.init(node: group, coder: aDecoder)
    }

}

Image

Final node in our arsenal is Image. It has following properties:

  • src - path to a raster image
  • w/h - width/height used to draw image on the screen
  • xAlign/yAlign/aspectRatio - alignment properties

Let's add some image on our scene:

let image = Image(src: "charts.png", w: 30, place: .move(dx: -55, dy: -15))
let group = Group(contents: [shape, text, image], place: .move(dx: 375 / 2, dy: 75))

If width and height are not specified then original image size will be used. If only width or only height specified, then another parameter will be calculated to keep proportion.

Coloring

We already saw that we can use a predefined color or specify it as a hex number:

let color1 = Color.blue
let color2 = Color(val: 0xfcc07c)

In the Color class you may find other utilities to create it:

let color3 = Color.rgb(r: 123, g: 17, b: 199)
let color4 = Color.rgba(r: 46, g: 142, b: 17, a: 0.2)

Also Macaw supports linear and radial gradients which you can use to fill/stroke your nodes. Every gradient needs direction and a set of colors with offset positions. Full gradient declaration looks as following:

let fill = LinearGradient(
    // we can define direction as a line from the (x1, y1) to the (x2, y2) points
    x1: 0, y1: 0, x2: 0, y2: 1,
    // when userSpace is true, direction line will be declared in the node coordinate system
    // otherwise, the abstract coordinate system will be used where
    // (0,0) is at the top left corner of the node bounding box
    // (1,1) is at the bottom right corner of the node bounding box
    userSpace: false,
    stops: [
        // offsets should be declared between 0 (start) and 1 (finish)
        Stop(offset: 0, color: Color(val: 0xfcc07c)),
        Stop(offset: 1, color: Color(val: 0xfc7600))])

This declaration may looks complex at the first sight. However, most of the time we don't need full declaration and can use another initializer. For example, we can update example above:

let fill = LinearGradient(degree: 90, from: Color(val: 0xfcc07c), to: Color(val: 0xfc7600))

Note that in Macaw all angles starts from 3 o'clock and increases clockwise. So 90 degrees equals to "from the top to the bottom" direction.

Let's update our button to use linear gradient instead of plain color:

let shape = Shape(
    form: Rect(x: -100, y: -15, w: 200, h: 30).round(r: 5),
    fill: LinearGradient(degree: 90, from: Color(val: 0xfcc07c), to: Color(val: 0xfc7600)),
    stroke: Stroke(fill: Color(val: 0xff9e4f), width: 1))

Events

Events allow user to interact with a scene. With Macaw you can handle tap/rotate/pan or pinch event on any node. Let's include the following line of code to the end of our init method:

shape.onTap { event in text.fill = Color.maroon }

Once user click on the button it will change title color to maroon:

If you will run this example, you may found that events don't work when you click to the center of the button. That's because you actually clicking to the text which intercept shape events. We can handle this issue by adding same event handlers to text and image. However, the better solution would be to mark text and image as nodes which should not receive any events. This can be done by using opaque property:

let text = Text(
    text: "Show", font: Font(name: "Serif", size: 21),
    fill: Color.white, align: .mid, baseline: .mid,
    place: .move(dx: 15, dy: 0), opaque: false)
        
let image = Image(src: "charts.png", w: 30, place: .move(dx: -40, dy: -15), opaque: false)

Transform

As we saw earlier, you can use place property to move a node on a scene. Actually, place is an affine transformation matrix used to map points in one coordinate system to another. Transform class used by Macaw is quite similar to the CGAffineTransform from the Core Graphics, so we will skip detailed examples in this tutorial.

Charts

We don't have charts. But it's easy to build. Let's reorganize example:

class MyView: MacawView {

    required init?(coder aDecoder: NSCoder) {
        let button = MyView.createButton()
        super.init(node: Group(contents: [button]), coder: aDecoder)
    }

    private static func createButton() -> Group {
        let shape = Shape(
            form: Rect(x: -100, y: -15, w: 200, h: 30).round(r: 5),
            fill: LinearGradient(degree: 90, from: Color(val: 0xfcc07c), to: Color(val: 0xfc7600)),
            stroke: Stroke(fill: Color(val: 0xff9e4f), width: 1))
        
        let text = Text(
            text: "Show", font: Font(name: "Serif", size: 21),
            fill: Color.white, align: .mid, baseline: .mid,
            place: .move(dx: 15, dy: 0), opaque: false)

        let image = Image(src: "charts.png", w: 30, place: .move(dx: -40, dy: -15), opaque: false)

        return Group(contents: [shape, text, image], place: .move(dx: 375 / 2, dy: 75))
    }

}

Now add axis for out chart:

required init?(coder aDecoder: NSCoder) {
    let button = MyView.createButton()
    let chart = MyView.createChart(button.contents[0])
    super.init(node: Group(contents: [button, chart]), coder: aDecoder)
}

private static func createChart(_ button: Node) -> Group {
    var items: [Node] = []
    for i in 1...6 {
        let y = 200 - Double(i) * 30.0
        items.append(Line(x1: -5, y1: y, x2: 275, y2: y).stroke(fill: Color(val: 0xF0F0F0)))
        items.append(Text(text: "\(i*30)", align: .max, baseline: .mid, place: .move(dx: -10, dy: y)))
    }
    items.append(createBars(button))
    items.append(Line(x1: 0, y1: 200, x2: 275, y2: 200).stroke())
    items.append(Line(x1: 0, y1: 0, x2: 0, y2: 200).stroke())
    return Group(contents: items, place: .move(dx: 50, dy: 200))
}

private static func createBars(_ button: Node) -> Group {
    // leave it empty for now
    return Group()
}

Add bar chart:

static let data: [Double] = [101, 142, 66, 178, 92]    
static let palette = [0xf08c00, 0xbf1a04, 0xffd505, 0x8fcc16, 0xd1aae3].map { val in Color(val: val)}

private static func createBars(_ button: Node) -> Group {
    var items: [Node] = []
    for (i, item) in data.enumerated() {
        let bar = Shape(
            form: Rect(x: Double(i) * 50 + 25, y: 0, w: 30, h: item),
            fill: LinearGradient(degree: 90, from: palette[i], to: palette[i].with(a: 0.3)),
            place: .move(dx: 0, dy: -data[i]))
        items.append(bar)
    }
    return Group(contents: items, place: .move(dx: 0, dy: 200))
}

Animation

Animation is a process of changing scene properties in time. Every animatable property also have corresponding variable property in the same object which provides animation functions. For example, to animate opacity you can use opacityVar property, etc. The easiest way to animate a property is to use animate function:

node.opacityVar.animate(to: 0)

In this case animation will start as soon as possible to gradually hide the node during 1 second.

You can think about animation as a combination of three major parts:

  • property you would like to animate
  • duration between animation start and finish. By default it's always 1 second.
  • function used to generate values for each animation step

Macaw allows you to specify function directly, however usually it's easier to describe animation route as a combination of other 3 properties:

  • from - initial value which will be set to a property before start. By default, the current value will be used
  • to - final property value
  • easing - functions specify the rate of change of a property over time

TODO: more details on easing.

TODO: delay, repeatCount, etc.

Let's add animation to our chart. First, you need to include opacity: 0 to the initializer of each bar, so that our chart was invisible initially. Then in the bar loop we need to add following handler to the "Show" button:

button.onTap { _ in bar.opacityVar.animate(to: 1.0) }

Now we can click on our button and see this:

That's so simple and looks pretty nice! Let's try to use different effect: instead of appearance with opacity, it would be great if bars can grow right from the x axis. To implement this we can try to use following trick: scale bar by y axis to zero and on click scale it back to the original state:

let bar = Shape(
    form: Rect(x: Double(i) * 50 + 25, y: 0, w: 30, h: item),
    fill: LinearGradient(degree: 90, from: palette[i], to: palette[i].with(a: 0.3)),
    // scale y axis to 0 initially
    place: .scale(sx: 1, sy: 0))
items.append(bar)
button.onTap { _ in
    // animate to the original state
    bar.placeVar.animate(to: .move(dx: 0, dy: -data[i]))
}

TODO: show current effect.

Finally let's show bars with different delays. This can be achieved by using delay parameter:

bar.placeVar.animate(to: .move(dx: 0, dy: -data[i]), delay: Double(i) * 0.1)

Sometimes it is useful to create animation once and then play/stop it based on a user event. Macaw provides animation method which creates animation and allows to manage it later.

This approach can be implemented with the following code:

var animations: [Animation] = []
for (i, item) in data.enumerated() {
    let bar = Shape(
        form: Rect(x: Double(i) * 50 + 25, y: 0, w: 30, h: item),
        fill: LinearGradient(degree: 90, from: palette[i], to: palette[i].with(a: 0.3)),
        place: .scale(sx: 1, sy: 0))
    items.append(bar)
    animations.append(bar.placeVar.animation(to: .move(dx: 0, dy: -data[i]), delay: Double(i) * 0.1))
}
button.onTap { _ in animations.combine().play() }

Check out full source code.

SVG

Macaw has built in SVG support. You can use SVGParser.parse method to read SVG file into Macaw node which you can include into your scene or pass it directly to a MacawView.

class SVGTigerView: MacawView {
    required init?(coder aDecoder: NSCoder) {
        super.init(node: try! SVGParser.parse(path: "tiger"), coder: aDecoder)
    }
}

You can also serialize Macaw nodes into an SVG document with SVGSerializer.serialize method and further manipulate with the returned String to print it out or write to a file.

let node = try! SVGParser.parse(path: "tiger")
print(SVGSerializer.serialize(node: node))

Clone this wiki locally