Skip to content
Yuri Strot edited this page Mar 1, 2019 · 72 revisions

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

Table of contents

MacawView

MacawView is a main class, which is 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 a 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 a geometry element. It has three major properties:

  • form - is it rectangle, circle, polygon or something 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 uses Cocoa coordinate system. For instance, in the example above we draw a rectangle at x=100 and width=175, i.e. centering it horizontally on the iPhone X/XS screen. To support various screen sizes you have two options:

  • use fixed size for your view and align it on a device using auto layout
  • use contentMode/contentLayout to align content of a scene in a view. 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 get a 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 you to use a declarative style as well as a functional style for scene definition. 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 the 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 the 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 have noticed, while Shape has a specific position, Text doesn't have any. That's because every node has property place which allows you to arrange a node on a scene relatively to its parent, and even rotate and/or scale it. We'll discuss this property in details later, but for now we can use it in the following way:

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

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

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

We can also use baseline property to align a text vertically.

Group

Now, we can combine several elements together using a Group node. It has only one major property contents - an 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 the text and choose the shape form so that center of each node is 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

The final node in our arsenal is Image. It has the following properties:

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

Let's add an image to 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 the width and height are not specified then the original image size will be used. If either width or height is specified, then another parameter will be calculated to keep the original 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 can 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 a direction and a set of colors with offset positions. Full gradient declaration looks as following:

let fill = LinearGradient(
    // we can define the 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, the 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 look complicated at first sight. However, most of the time we don't need full declaration and can use another initializer. For example, we can update the example from the above:

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

Note that in Macaw all angles start from 3 o'clock and increase clockwise. So 90 degrees equal to "from the top to the bottom" direction.

Let's update our button to use linear gradient instead of a 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 the user to interact with the scene. With Macaw you can handle tap/rotate/pan or pinch event on every 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 the user clicks the button it will change title color to maroon:

If you run this example, you may found that events don't work when you click the center of the button. That's because you are actually clicking the text which intercepts shape events. We can handle this issue by adding same event handlers to text and image. However, a better solution would be to handle onTap on the group:

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

Group receives all child events.

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 you can read more about it there. Here is an example showing what you can do using place.

Charts

Macaw doesn't have built-in charts. But you can easily build everything you need using basic API. Let's reorganize our sample:

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))

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

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

}

Now let's add axis for our chart:

required init?(coder aDecoder: NSCoder) {
    let button = MyView.createButton()
    let chart = MyView.createChart(button)
    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()
}

Finally we can add a 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 in Macaw is a process of changing scene properties during some period of 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 immediately to gradually hide the node in 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, 1 second)
  • function used to generate values for each animation step

Macaw allows you to specify a function directly, however it's usually 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)
  • to - final property value
  • easing - functions specifying the rate of change of a property over time

There are various easing functions you can use:

Let's add animation to our chart. First, you need to include opacity: 0 to the initializer of each bar to make it initially invisible. Then in the bar loop we need to add the following handler to "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 a 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]))
}

Finally let's show bars one after another. 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 depending on the user's actions. Macaw provides animation method to create animations which can be managed later, see the following example:

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() }

And that's it! Check out full source code.

SVG

Macaw has built in SVG support. You can use SVGParser.parse method to turn a SVG file into a 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)
    }
}

Also you can use SVGView to add a SVG file to your app from a storyboard:

  • Drop View from 'Object Library'
  • Select SVGView class as a view class
  • Specify a SVG file name to render
```