-
Notifications
You must be signed in to change notification settings - Fork 560
Getting started
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.
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)
}
}
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
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.
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.
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)
}
}
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.
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 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.
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
.
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 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.
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