Macaw — зручна бібліотека для векторної графіки в Cocoa

Привіт Хабр! Сучасні інтерфейси мобільних додатків містять тонни ілюстрацій та анімацій, починаючи від хитрих градієнтів і закінчуючи діаграмами акцій. Тому мобільним розробникам доводиться витрачати величезну кількість часу, щоб перетворити гарний дизайн у функціональне додаток, яке, до того ж, буде працювати на пристроях різних розмірів.
Саме з цією проблемою ми зіткнулися під час розробки iOS додатків. Щоб спростити завдання, ми розробили графічну бібліотеку Macaw, яка дозволяє описувати складні інтерфейси у вигляді зрозумілих об'єктів сцени і навіть безпосередньо відображати SVG графіку з підтримкою подій і анімації.
Цікаво? У цій статті ми познайомимо вас з базовими поняттями Macaw і разом створимо діаграму з анімацією, використовуючи мінімум коду.
image
MacawView
MacawView
– це основний клас, який використовується для відображення всієї графіки Macaw в світі Cocoa. Щоб почати працювати з Macaw, нам необхідно створити свій клас, успадкувати його від MacawView і описати всередині необхідний інтерфейс. Оскільки MacawView вже реалізує
UIView
, створений нами клас можна буде легко інтегрувати в інтерфейс Cocoa. Ось так буде виглядати найпростіший "Hello, World!" на 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)
}
}

image
Сцена
Macaw описує інтерфейси у вигляді комбінації тексту, зображень і геометричних об'єктів. Така комбінація називається граф сцени або ж просто сцена. Давайте пройдемося по основним елементам сцени.
Shape
Shape – це елемент сцени, який представляє геометричну фігуру. У цього елемента є три основних властивості:
  • form
    – геометричне місце точок, яке визначає форму фігури. Цим властивістю ми визначаємо, що хочемо намалювати: прямокутник, коло, багатокутник або щось інше.
  • fill
    – кольору всередині фігури
  • stroke
    – кольору кордону навколо фігури
Давайте розглянемо найпростіший прямокутник:
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)
}
}

image
Macaw використовує стандартну систему координат Cocoa, тому на прикладі вище ми малюємо прямокутник 175x30 точок в центрі екрану iPhone 6/6s (ширина якого 375 пікселів). Для підтримки різних розмірів екрану у нас є кілька варіантів:
  • Використовувати фіксований розмір
    MacawView
    і відцентрувати її з допомогою autolayout в Cocoa.
  • Самостійно обчислювати позиції елементів сцени в залежності від розміру екрана. На щастя, векторна графіка чудово масштабується під будь-які розміри.
Також Macaw підтримує і інші геометричні примітиви:
image
Крім цього у Macaw є елемент
Path
, що дозволяє описувати фігури будь-якої складності у вигляді набору кривих.
Повернемося до нашого прикладу. Тепер спробуємо додати заокруглення нашому прямокутника:
let shape = Shape(
form: RoundRect(
rect: Rect(x: 100, y: 75, w: 175, h: 30),
rx: 5, ry: 5),
fill: Color(val: 0xfcc07c))

image
Таке опис сцени називається декларативним. При такому підході ми описуємо сцену, розбиваючи її на дерево примітивів. Macaw також дозволяє описувати сцену у функціональному стилі. У цьому випадку, приклад вище буде виглядати наступним чином:
let shape = Rect(x: 100, y: 75, w: 175, h: 30).round(r: 5).fill(with: Color(val: 0xfcc07c))

Ви самі вирішуєте, який підхід краще використовувати в кожному конкретному випадку, однак ми рекомендуємо використовувати той, який робить код більш читабельним.
Text
Наступний базовий елемент сцени — це текст. Ось його основні властивості:
  • text
    – текст для відображення
  • fill
    – колір тексту
  • font
    – ім'я і розмір шрифту
  • align
    /
    baseline
    — властивості, що відповідають за вирівнювання тексту
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)
}
}

image
Як ви могли помітити, у тексту немає спеціальних властивостей положення, на відміну від
Shape
. Однак у кожного елементу сцени є властивість
place
яке дозволяє розташувати елемент сцени щодо його батьків або навіть повернути і змінити його розмір. Ми повернемося до цієї властивості пізніше, а поки давайте просто додамо наступний рядок:
text.place = .move(dx: 100, dy: 75)

image
За замовчуванням, текст розташовується відносно верхнього лівого кута. Щоб відцентрувати текст, ми можемо використовувати властивість
align
:
text.place = .move(dx: 375 / 2, dy: 75)
text.align = .mid

image
Для вертикального центрування, ми також можемо використовувати властивість
baseline
.
Group
Тепер можна перейти до комбінування елементів. Найважливіше властивість групи – це
contents
: список елементів, з яких складається група:
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)
}
}

image
Зауважте, що кожен елемент у групі ми визначаємо таким чином, щоб центр елемента збігався з початком координат (0, 0), а потім переносимо цю точку в центр екрана
.move(dx: 375 / 2, dy: 75)
. Проте, нам не обов'язково робити це для кожного елемента, адже тепер ми може переміщати саму групу:
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
Останній елемент у нашому арсеналі — це зображення. У нього є наступні властивості:
  • src
    – шлях до файлу
  • w
    /
    h
    – фактична висота/ширина картинки
  • xAlign
    /
    yAlign
    /
    aspectRatio
    – властивості для центрування зображення
Давайте додамо зображення в нашу сцену:
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))

image
За замовчуванням, висота і ширина беруться з розмірів оригінального зображення. Якщо визначено тільки одну властивість, тоді друге буде розрахована автоматично відповідно до пропорцій зображення.
Кольори і градієнти
В прикладах вище ми вже використали кілька варіантів завдання кольору:
let color1 = Color.blue
let color2 = Color(val: 0xfcc07c)

В класі
Color
можна знайти і інші корисні методи:
let color3 = Color.rgb(r: 123, g: 17, b: 199)
let color4 = Color.rgba(r: 46, g: 142, b: 17, a: 0.2)

Також Macaw підтримує лінійні і радіальні градієнти, які можна використовувати для завдання властивостей
fill
/
stroke
у елементів сцени. Кожен градієнт визначається набором квітів зі зсувами. Приклад градієнта:
let fill = LinearGradient(
// визначаємо лінійне напрямок градієнта з точки (x1, y1) в точку (x2, y2)
// в даному випадку це вертикальна лінія зверху вниз
x1: 0, y1: 0, x2: 0, y2: 1,
// якщо параметр userSpace виставлений в true, то напрям буде зазначено в системі координат поточного елемента
// інакше, напрямок буде задаватися в абстрактній системі координат, де 
// (0,0) - це верхній лівий кут області поточного елемента
// (1,1) - це нижній правий кут області поточного елемента
userSpace: false,
stops: [
// значення зміщень повинні бути в діапазоні 0 (старт) і 1 (фініш)
Stop(offset: 0, color: Color(val: 0xfcc07c)),
Stop(offset: 1, color: Color(val: 0xfc7600))])

Може здатися, що таке визначення градієнта виглядає громіздко, однак для простих градієнтів можна використовувати і більш прості конструктори. Зокрема, наш приклад можна переписати наступним чином:
let fill = LinearGradient(degree: 90, from: Color(val: 0xfcc07c), to: Color(val: 0xfc7600))

Відлік всіх кутів у Macaw йде за годинниковою стрілкою і починається з 3 годин. Тому 90 градусів – це якраз напрямок зверху вниз.
image
Давайте тепер замість звичайного кольору заповнимо нашу кнопку градієнтом:
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))

image
Події
Події дозволяють користувачеві взаємодіяти зі сценою. Macaw може обробляти такі події, як
tap
,
rotate
та
pan
. Додамо наступну сходинку в кінці методу
init
:
_ = shape.onTap.subscribe(onNext: { event in text.fill = Color.maroon })

Тепер, як тільки користувач клацне на кнопці, вона змінить колір на темно-бордовий.
image
Для подій Macaw використовує дуже потужну бібліотеку RxSwift. Зокрема, кожен метод
subscribe
повертає спеціальний протокол
Disposable
, який дозволяє зручно керувати всіма зареєстрованими слухачами. Оскільки в даному випадку ми хочемо обробляти подію час життя фігури, то ми просто використовуємо
_ =
щоб це показати.
Якщо ви запустите наш приклад, то помітите, що клік в середину кнопки не працює. Це виникає з-за того, що клік на фігуру кнопки перехоплює текст. Це можна легко полагодити, додавши такий же обробник для тексту кнопки і її зображення. Однак, більш правильним рішенням буде вказати, що ці елементи не можуть отримувати події. Це можна легко зробити, використовуючи властивість
opaque
:
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

Трансформація
Як ми вже бачили, властивість place можна використовувати для розташування будь-якого елемента на сцені. Насправді, ця властивість є матрицею афінних перетворень, що дозволяє перенести точки з однієї системи координат в іншу. По суті, клас Transform в Macaw надає інтерфейс дуже схожий на CGAffineTransform з пакету Core Graphics, тому ми не будемо детально на ньому зупинятися. Для загального уявлення буде достатньо наступної анімації:
image

Діаграми
У Macaw немає прямої підтримки графіків і діаграм, тому що їх дуже легко зробити без додаткових бібліотек. Спочатку ми трохи наведемо порядок у змінах, які робили все це час. Ось що в нас у результаті вийшло:
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))
}
}

image
Тепер додамо осі координат трохи нижче нашої кнопки:
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 {
// тут ми будемо малювати нашу діаграму
return Group()
}

image
А тепер пора додати саму гістограму:
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))
}

image
По суті, метод
createBars
ми перетворили вихідні дані в красиву гістограму, і для цього нам знадобилося менше 10 рядків зрозумілого, декларативного коду! Тепер час привести цю діаграму в рух.
Анімація
З точки зору Macaw, анімація – це процес зміни властивостей сцени з плином часу. Якщо уважно подивитися на інтерфейси елементів сцени, то можна помітити, що крім таких властивостей як
opacity
або
place
, там є ще властивості
opacityVar
та
placeVar
. Ці властивості можна використовувати для анімації. Наприклад, для анімації властивості
opacity
, ми будемо використовувати властивість
opacityVar
. Найпростіший спосіб запустити анімацію – це викликати функцію
animate
:
node.opacityVar.animate(to: 0)

В цьому випадку, анімація почнеться відразу ж і елемент
node
буде поступово зникати протягом однієї секунди, поки не зникне зовсім.
Можна уявити будь-яку анімацію як набір з трьох частин:
  • Анимируемое властивість
  • Час анімації. За замовчуванням воно завжди дорівнює одній секунді
  • І функція, яка генерує значення для кожного кроку анімації
Macaw дозволяє визначити цю функцію самому, проте як правило простіше визначити її з допомогою трьох значень:
  • from
    – початкове значення, яке буде встановлено до початку анімації. Якщо не встановлено, тоді буде використовуватися поточне значення властивості
  • to
    – фінальне значення
  • easing
    – функція, яка визначає швидкість зміни значень залежно від часу
Тепер давайте додамо анімацію до нашої діаграмі. Спочатку, додамо всіх елементів гістограми
opacity: 0
, щоб приховати їх і запустимо нашу анімацію за допомогою натискання на кнопку:
_ = button.onTap.subscribe(onNext: { _ in bar.opacityVar.animate(to: 1.0) })

Результат в дії:
image
Всього одним рядком ми привели наше додаток в рух! Давайте тепер спробуємо інший ефект: замість поступового появи, наші стовпці будуть виростати з координатної осі X. Для цього ми можемо змінювати масштаб елементів від нуля до оригінального значення.
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)),
// масштабується y до 0
place: .scale(sx: 1, sy: 0))
items.append(bar)
_ = button.onTap.subscribe(onNext: { _ in
// анимируем до оригінального значення
bar.placeVar.animate(to: .move(dx: 0, dy: -data[i]))
})

Крім цього, ми можемо показувати різні стовпці з різною затримкою. Для цього ми будемо використовувати параметр
delay
:
bar.placeVar.animate(to: .move(dx: 0, dy: -data[i]), delay: Double(i) * 0.1)

Вуаля! Тепер при натисканні на кнопку Show ми побачимо бажане:
image

SVG
Як ми вже згадували раніше, в Macaw є вбудована підтримка SVG. Ви можете використовувати метод
SVGParser.parse
щоб прочитати SVG-файл як елемент сцени, який можна комбінувати з іншими елементами, або ж передати безпосередньо в MacawView.
class SVGTigerView: MacawView {
required init?(coder aDecoder: NSCoder) {
super.init(node: SVGParser.parse(path: "tiger"), coder: aDecoder)
}
}

image
Засвоївши базові концепції Macaw, можна створювати ще більш цікаві приклади. Наприклад, за кілька годин нам вдалося отримати наступне:
image

Більше інформації про проект ви можете знайти на нашій сторінці github. Ми активно працюємо над документацією й новими прикладами, чекайте оновлень!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.