Привет Хабр! Современные интерфейсы мобильных приложений содержат тонны иллюстраций и анимаций, начиная от хитрых градиентов и заканчивая диаграммами акций. Поэтому мобильным разработчикам приходится тратить огромное количество времени, чтобы превратить красивый дизайн в функциональное приложение, которое, к тому же, будет работать на устройствах различных размеров.


Именно с этой проблемой мы столкнулись во время разработки 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. Мы активно работаем над документацией и новыми примерами, ждите обновлений!

Поделиться с друзьями
-->

Комментарии (15)


  1. andrew8712
    26.10.2016 15:48

    Выглядит интересно. Как у нее с производительностью?


    1. ystrot
      26.10.2016 16:21

      С производительностью всё очень хорошо. Статическая часть сцены рисуется средствами Core Graphics. Динамические же элементы во время анимации выделяются в отдельные слои и анимируются нативно, используя Core Animation. В ближайшее время подготовим бенчмарки, чтобы не быть голословными.


      1. xGromMx
        26.10.2016 18:48

        Сделал все по github README но получаю это при сборке https://monosnap.com/file/KE6CNfG8WpQ8Ubo5CQmJpIVpQ7Rb9B


        1. ystrot
          26.10.2016 19:04

          Я так понимаю вы про секцию Building from sources? Тогда вы вероятно запустили не Example.xcworkspace, а .xcodeproject.


          1. xGromMx
            26.10.2016 19:28

            А где можно глянуть пример с таблицей Менделеева?


            1. ystrot
              26.10.2016 19:47

              Мы на этой неделе запустим отдельный репозиторий с примерами на Macaw, в том числе и с таблицей Менделеева. Могу кинуть вам ссылочку, как только он будет доступен.


              1. AKhatmullin
                01.11.2016 10:41

                Да, было бы интересно взглянуть!


  1. DmitryO
    26.10.2016 17:24

    А у вас есть, точно такая же, но для Android?


    1. xGromMx
      26.10.2016 17:46

      вам для рисования или для юзер интерфейса?


    1. ystrot
      26.10.2016 17:59

      К сожалению, андроидом мы пока не занимаемся, а потому и необходимости в подобной библиотеке не было.


      1. xGromMx
        26.10.2016 18:06

        для интерфейса круто использовать https://github.com/Kotlin/anko, для графиков https://github.com/PhilJay/MPAndroidChart (кстати есть порт для IOS https://github.com/danielgindi/Charts) также круто использовать Kotlin вместо Java.


        1. DmitryO
          26.10.2016 19:11

          Интересуюсь анимацией SVG, точнее — path morphing или что-то в этом духе.
          ЗЫ. MPAndroidChart смотрели, есть пара больщих знаков вопроса к нему.


          1. ystrot
            26.10.2016 20:14
            +1

            Морфинг у нас есть в планах, но его можно сделать средствами Cocoa: https://developer.apple.com/reference/quartzcore/cashapelayer. Если вопрос в том, как сделать морфинг самому на Android, тогда можно посмотреть в Raphael, там вроде был свой и достаточно неплохо работал: https://github.com/DmitryBaranovskiy/raphael/


  1. zagg12345
    27.10.2016 13:50

    как на счет obj-c?


    1. ystrot
      27.10.2016 13:52

      Obj-C версию не планируем, но насколько я знаю, сейчас нет никаких проблем использовать Swift из Obj-C кода.