Фреймер-кейс №2: анимация SVG-пути

Саша Окунев
/designer

--

В этом уроке мы сделаем прототип анимации приложения Activity для Apple Watch. При нажатии на кнопку будет срабатывать анимация графика, который показывает статистику фитнес-трекера.

Мы научимся использовать SVG-слои, зададим атрибуты SVG через новый метод setAttribute(). На наглядном примере научимся использовать modulate() и привяжем изменение одного значения к другому.

Этот кейс основан на прототипе Бенджамина Ден Боера из команды Фреймера. В оригинальном примере был ужасно написанный код, который мне пришлось заново переписать.

Фреймер-проект

Ссылка на Фреймер Клауд, смотреть в Safari или Chrome

Скачай исходник и открой его в Фреймере. Я прокомментировал каждую строку.

Этот кейс рассчитан на тех, кто знаком с базовыми основами Фреймера: переменными, свойствами, массивами, циклами, функциями и состояниями. Если ты никогда не имел дела с Фреймером, возможно, лучше сначала пройти замечательный бесплатный видеокурс Руслана Шарипова:

В этом кейсе получилось больше программирования, чем в первом, где мы в основном двигали объекты.

Если у тебя не получается воспроизвести этот урок, задавай вопросы в Фреймер-чате:

Для тех, кто хочет по-быстрому внедрить анимацию в свой проект, а не ковыряться в этом вашем программировании

Я сделал упрощённый пример, в котором анимируется только один график и нет массивов:

Открыть прототип в Фреймер Клауде

Как его использовать:

  1. Поменять стиль слоя piechart в режиме дизайна.
  2. Задать цвет градиента вместо красного.
  3. В режиме кода подстроить время анимации и значение в процентах.
  4. Не трогать функции, всё уже настроено. Если нужно сместить начало графика, ищи stroke-dashoffset и задавай компенсацию в пикселях.
  5. Скрыть служебный слой progress при помощи visible:false.
  6. В складке Ивенты задать слой, по клику на который будет происходить анимация. Сейчас это слой back. У слоя должен стоять таргет.

Для любопытных

Если же тебе мало экспресс-примера и ты хочешь досконально разобрать весь кейс, я дам такую возможность.

План действий

  1. Рисуем макет в режиме дизайна в Фреймере и расставляем таргеты.
  2. Настраиваем цвет фона, время анимации и значения пайчарта в процентах в массиве values. В дальнейшем мы будем использовать эти значения в анимации кругов.
  3. Создаём и наполняем массив pathNames, в котором будем хранить имена svg-контейнеров. Это нужно, поскольку на них нельзя повесить таргеты. Мы будем обращаться к svg-слоям по названию, чтобы выяснить их длины, а впоследствии задавать нужную длину.
  4. Создаём и наполняем массив maxValues, чтобы ограничить длину графиков максимальными значениями длины окружности в пикселях.
  5. Пишем функцию convertValueToPixels(), которая будет конвертировать значения процентов в аналогичные в пикселях.
  6. Создаём и наполняем массив pixelValues, используя convertValueToPixels(). На этом шаге мы подготовим все необходимые данные для анимации SVG-путей.
  7. Готовим слои: делаем фрейм overlay размытым, пишем скрытые состояния кнопке button, слою overlay и слою back.
  8. Пишем функцию setViewbox(), которая задаёт координаты и размеры svg-контейнера. Можно взять уже готовую и просто использовать.
  9. Пишем функцию reset(), которая обнуляет значения пайчарта.
  10. Делаем вспомогательный слой progress, от ширины которого будет зависеть длина SVG-путей. Делаем ему состояние final.
  11. Пишем функцию animatePath(), которая будет привязывать изменение ширины слоя progress к длине SVG-путей.
  12. Пишем ивент on “change:width”, который будет отслеживать, как меняется ширина слоя progress в процессе анимации. В нём используем animatePath(). В неё передаём имя пути, текущую ширину progress и значение графика, к которому хотим прийти.
  13. Пишем ивенты кликов (триггеры) по кнопке button и по слою back.
    После этого этапа прототип будет работать.
  14. В приложении к этому посту мы напишем функцию setGradient(). При помощи неё задаём градиенты в качестве заливки линий графигка, Используя слои g1, g2 и g3.
  15. Оживляем время на часах функцией setRealTime(), раз в 30 секунд обновляем время.

Для удобства анализа кода, я описываю действия в том порядке в котором их выполняет Фреймер, а не в порядке написания кода. Пункт номер 13, в котором пишутся ивенты кликов, в реальной работе пишется почти сразу, поскольку надо постоянно тестировать прототип, вызывая триггеры.

Шаг 1. Рисуем макет

Можно использовать мой фреймер-проект и удалить из него весь код.

Корректный дизайн проекта со всеми расставленными таргетами выглядит так:

Шаг 2. Настройки

Переключаемся в режим кода. Если не выбран, выбираем в качестве мокапа Apple Watch 42mm.

Дальше пишем код. Задаём время анимации в секундах. В дальнейшем будем его использовать в настройках анимации.

time = 1

Задаём свойство глобального чёрного фона, на который накладывается мокап Apple Watch. Иначе фон будет белым:

Canvas.backgroundColor = "black"

В виде массива values задаём длину линий, до которых хотим анимировать графики. Значения графиков удобно измерять в процентах, хотя здесь это условно: для Фреймера это лишь цифры. Желательно, чтобы одно из значений было равно 100, чтобы график занимал максимум доступного места.

В моём примере я использую такие значения:

values = [ 100, 70, 40]

Подробнее о массивах.

За 100% я здесь считаю длину синего графика MOVE.

Шаг 2 готов, пишем название складки в виде комментария:

#1. Настройки

Выделяем весь код вместе с названием и складываем его в складку (Cmd + Enter). Подробнее о складках — в Фреймер-кейсе №1.

Шаг 3. Массив pathNames

В режиме дизайна есть три svg-слоя:

Это окружности, которые теперь можно нарисовать инструментом Oval. Под капотом это три слоя нового типа SVGLayer, в которых нарисованы окружности. SVGLayer оборачивает тег <svg>. Увы, пока на слои этого типа нельзя повесить таргеты и это вызывает недоумение.

Делаем массив, в котором будем хранить строковые значения имён этих слоёв:

pathNames = ["outer", "mid", "inner"]

Делаем заголовок складки комментарием и кладём строку в складку. Все последующие шаги я тоже рекомендую складывать в складки, иначе можно будет легко потеряться.

Шаг 4. Массив maxValues

У каждой из трёх окружностей есть полная длина в пикселах, которую можно узнать и принять за 100%. Если нужно, чтобы линия не была замкнутой, за 100% нужно принять только её часть. Я использовал это, чтобы графики не наезжали на надпись STAND.

Создаём пустой массив:

maxValues = []

Наша задача — наполнить его максимальными значениями длины, которые имеют svg-окружности.

Чтобы узнать длину окружности в слое inner, можно использовать такой синтаксис:

test = Layer.select("inner").pathprint test.getTotalLength()

В консоль вернётся 109 и много цифр после запятой. Это значение в пикселях.

Что произошло: Мы объявили переменную test и положили в неё объект типа SVGPathElement, который выделили по названию “inner”. Тип объекта важен, потому что можно легко перепутать его с названием слоя. Вот такой кривой способ поставить таргет на SVG предлагает дорогой Фреймер. Следующей строкой мы применяем новый метод getTotalLength(), который позволяет вернуть значение длины. Команда print подхватывает результат и выводит его в консоль.

Теперь ту же операцию нужно проделать со всеми остальными слоями. Я сделал это через цикл, который проходит по всем значениям массива pathNames и увеличивает переменную i на 1 на каждом срабатывании цикла:

for pathName, i in pathNames  SVGPathElement = Layer.select(pathName).path  # максимальная длина svg-пути в пикселях
pathLength = SVGPathElement.getTotalLength()

Подробнее о циклах.

Возможно, тебе покажется слишком сложным такое название переменной как SVGPathElement. Я считаю это важным, поскольку по нему однозначно можно определить тип данных. Это название гораздо лучше, чем, например, shape, поскольку в последнем может лежать как название шейпа, так и SVG-элемент. Мой подход делает код более наглядным и предотвращает ошибки.

Следующее, что мы делаем — наполняем массив maxValues методом push(), передавая в него pathLength всех трёх элементов.

for pathName, i in pathNames

...

maxValues.push(pathLength)

Такой массив будет содержать полные длины всех трёх окружностей, а значит, наш график будет наезжать на надпись STAND, чего хотелось бы избежать. Для этого мы вводим новый массив, в котором будут храниться компенсации длин. Я подобрал значения, при которых графики выстраиваются в ровную линию перед надписью.

Пишем массив компенсаций над циклом:

compensations = [52, 36, 20.5]

Перед тем как положить pathLength в maxValues, вычитаем компенсации. Чтобы обращаться к ним, нам пригодится переменная i:

pathLength = pathLength — compensations[i]

Полный код шага 4:

Проверяем, чем наполнен maxValues:

print maxValues

Шаг 5. Пишем функцию convertValueToPixels()

Любой работал с круговыми графиками в Экселе, выставляя значения в процентах. Они понятны и привычны. Но svg-пути измеряются в пикселях и им нельзя просто так взять и передать значение из массива values. Чтобы решить эту проблему, мы напишем конвертирующую функцию.

Подробнее о функциях.

Она будет требовать два аргумента, value и maxValue.

convertValueToPixels = (value, maxValue) ->

Через аргументы мы передаём в функцию какие-либо данные. Они будут доступны внутри кода функции как обычные переменные.

В value мы будем передавать процентное значение из массива values. В maxValue — пиксельное значение максимальной длины, которое будем брать за 100%.

Внутри функции совершаем нехитрое вычисление: берём процентное значение, умножаем его на максимальное в пикселях и делим на 100.

pixelValue = value * maxValue / 100

Возврат

Полученное значение можно использовать в SVG-путях. Передадим его наверх в качестве возврата функции:

return pixelValue

Переменная, которой мы присвоим функцию, будет получать то, что в возврате. Пример простейшей функции с возвратом:

myFunction = ->
return "Значение возврата"
box = myFunction()print box # в консоль попадёт "Значение возврата"

Аргументы и возврат — два способа, при помощи которых функция взаимодействует с внешним миром.

Полный код шага 5:

Шаг 6. Массив pixelValues

Сконвертированные длины в пикселях будут храниться в массиве pixelValues:

pixelValues = []

Делаем цикл для всех трёх значений массива values.

Используем функцию convertValueToPixels(). В переменную pixelValue кладём то что она возвращает. В аргументы передаём процентное значение value и максимальное значение, обращаясь к массиву по индексу i.

for value, i in values  pixelValue = convertValueToPixels(value, maxValues[i])

Полученное число pixelValue кладём в массив pixelValues:

  pixelValues.push(pixelValue)

Проверяем, что оказалось в pixelValues:

print pixelValues

В случае, если в values = [100, 100, 100], корректные значения pixelValues такие: 195.6, 145.3…, 93,9….

Полный код шага 6:

Шаг 7. Готовим слои

Мне не нравится, когда анимация срабатывает сразу же при загрузке прототипа, потому что иногда загрузка может занять бóльшее время, чем сама анимация. Поэтому я обычно делаю кнопку-триггер, на которую должен нажать пользователь. На этом шаге мы создадим все необходимые состояния слоёв-триггеров.

В этом прототипе будет два триггера: на начало и реверс. Функцию первого будет выполнять слой button, который в режиме дизайна выглядит как синяя кнопка с надписью Activity.

Делаем ему состояние hidden, в котором он сдвинут на 20 пикселей вниз и невидим:

button.states.hidden =  y: button.y + 20  opacity: 0

Слой overlay занимает всю площадь экрана и будет выполнять функцию размывающего фона для кнопки. Для этого настраиваем свойство backgroundBlur:

overlay.backgroundBlur = 20

Ему тоже делаем состояние hidden:

overlay.states.hidden = 
opacity: 0

Вторым триггером, запускающим реверс, будет невидимый слой back, который мы делаем некликабельным, задавая ему visible:false.

back.visible = false

У него будет состояние chart, на котором он будет, когда на экране будут графики.

back.states.chart =
visible: true

Весь код шага 7:

Чтобы проверить, что все состояния переключаются как задумано, пишем ивенты, в которых переключаем состояния:

button.onTap ->
button.stateCycle()

К ивентам мы вернёмся на шаге 13.

Шаг 8. Пишем функцию setViewbox()

SVG-пути могут вести себя непредсказуемо. У них есть толщина, которая выходит за пределы их SVG-контейнеров. Из-за этого может возникать ситуация, когда контейнер режет часть линии по своей форме.

Чтобы избежать этого, можно использовать функцию, которая будет увеличивать размер контейнера и смещать его на половину ширины линии.

Больше про svg-viewbox — в исчерпывающем посте Юлии Бухваловой Размеры в SVG.

Шаг 9. Пишем функцию reset()

Когда анимация начинается, окружности графика должны быть в нулевом положении. Функция reset() будет принимать имя svg-слоя в виде аргумента pathName и задавать слою с таким именем нулевое положение графика.

reset = (pathName) ->

Как и на шаге 4, снова сохраняем объект типа SVGPathElement

  SVGPathElement = Layer.select(pathName).path

Определяем точную длину svg-пути в пикселях:

  pathLength = SVGPathElement.getTotalLength()

Мы подобрались к самому главному фокусу этого урока.

Как показать только часть графика

Влиять на длину svg-пути мы будем через настройку пунктирного штриха. На самом деле линия не будет выглядеть как пунктирная. Для рисования графика мы лишь будем использовать первый штрих в пунктире, а промежуток между штрихами заполним значением pathLength. Напомню, что в нём лежит вся длина окружности. Это гарантирует, что второго штриха не будет.

Если бы в пунктире было три линии

SVG-пути (тегу <path>) можно задать параметр stroke-dasharray, в который можно передать настройки пунктира в виде двух чисел. Первое число будет означать длину штриха, а второе длину промежутка между штрихами.

Если залезть в код прототипа в браузере, svg-элемент и путь в нём будут выглядеть примерно так:

<svg ... >
<path d=”M 17.5 0 ... ”>
</path>
</svg>

Задавать атрибуты мы будем новым методом setAttribute().

Задаём атрибут с нулевым значением штриха:

SVGPathElement.setAttribute(“stroke-dasharray”, “0 #{pathLength}”)

После этого путь получит новый svg-атрибут:

<svg ... >
<path d=”M 17.5 0 ... ” stroke-dasharray=”0 109">
</path>
</svg>

По умолчанию штрих начинался на 12 часов, потому что его параметр stroke-dashoffset имеет значение 0. Можно смещать положение первого штриха, задавая stroke-dashoffset в пикселах. В нашем случае эту строку можно не писать, но для тех, кто захочет сместить начало графика, я оставлю её:

SVGPathElement.setAttribute(“stroke-dashoffset”, “0”)

Счётчики с цифрами

На макете присутствуют три текстовых слоя. Им тоже нужно задать нулевые значения.

Проходим простейшим циклом по трём дочерним слоям в фрейме labels и задаём контент надписи через свойство text.

for label in labels.children
label.text = "0"

При загрузке прототипа сразу же запускаем reset() для всех элементов в pathNames:

for pathName in pathNames
reset(pathName)

Результат:

Весь код шага 9:

Шаг 10. Слой progress

Слой progress — серый кардинал этого урока. Он очень важен и рождён быть невидимым. Его функция в том, чтобы расширяться от нулевого размера до ширины экрана. Пока он расширяется, мы будем следить за его шириной и каждый пиксель переписывать stroke-dasharray у всех графиков. В твоём проекте, скорее всего, ты решишь сделать progress невидимым, но в целях демонстрации я оставил ему половину прозрачности.

Создаём слой с маленькой шириной:

progress = new Layer
opacity: .5
height: 4
width: 4

Делаем состояние final, в котором его ширина равна ширине экрана. Задаём опции анимации.

progress.states.final =
width: Screen.width
options:
time: time
curve: Spring(1)

Шаг 11. Пишем функцию animatePath()

Это ключевая функция, которая при помощи встроенной в Фреймер функции modulate() будет наблюдать за изменением ширины слоя progress и подстраивать длину штриха в svg-пути.

animatePath = (pathName, input, pixelValue) ->

animatePath() будет требовать три аргумента: pathName — имя пути, input — ширину progress и pixelValue — желаемое значение графика, которое мы возьмём из массива pixelValues.

Первым делом получаем SVG-путь по названию:

  SVGPathElement = Layer.select(pathName).path

Затем, определяем его полную длину. Она понадобится, чтобы задавать размер промежутков в stroke-dasharray и не допустить ситуации, когда в пути виден второй штрих.

  pathLength = SVGPathElement.getTotalLength()

Как работает функция modulate()

Понять, как она работает легче всего на визуальном примере. Представим два слайдера:

Открыть прототип

Первый слайдер в значении 50, второй в 100. Диапазон первого — от 0 до 100, а второго — от 0 до 200. Если значение первого слайдера меняется, второй подстроится пропорционально. Пользователь двигает слайдер от 0 до 100. Изменившееся значение первого слайдера — источник данных, или input. Диапазон первого слайдера называется диапазоном ввода (inputRange), а второй — диапазоном вывода (outputRange).

Задача modulate() — предоставить пропорциональное значение второго слайдера, если первый подвинули.

Прототип со слайдерами содержит подробные комментарии. Если возникли вопросы по этому моменту, рекомендую открыть его в Фреймере и прочитать код.

Возвращаемся к функции animatePath(), внутри которой мы будем использовать modulate().

Готовим аргументы для modulate().

1. Переменная input у нас уже есть, мы её получили в виде второго аргумента функции animatePath(), это текущая ширина слоя progress.

Остальные аргументы мы объявим в виде переменных.

2. inputRange это обычный массив, содержащий диапазон ввода. Первое значение — минимальная ширина progress, второе — максимальная. Поскольку progress растягивается до ширины экрана, мы используем её напрямую через объект Screen.

  inputRange = [4, Screen.width]

3. outputRange — массив с диапзоном вывода. По аналогии с вводом. От 0 до желаемого значения, которое мы получили в виде третьего аргумента функции animatePath().

  outputRange = [0, pixelValue]

Теперь при помощи modulate() отслеживаем, какую ширину имеет progress и на основе этого рассчитываем текущую длину пути. Передаём все аргументы:

  value = Utils.modulate(input, inputRange, outputRange)

И наконец, задаём атрибут актуальным значением длины:

  SVGPathElement.setAttribute(“stroke-dasharray”, “#{value} #{pathLength}”)

Полный код шага 11:

modulate() — мощный инструмент Фреймера и мы о ней как-нибудь подробно поговорим.

Шаг 12. Пишем ивент изменения ширины progress

Каждый раз, когда ширина progress будет меняться хоть на пиксель, мы должны об этом знать. В этом поможет ивент on “change:width”.

progress.on "change:width", ->

Поскольку у нас три svg-пути, которые надо анимировать, мы используем цикл, в котором будет вызываться функция animatePath().

  for pathName, i in pathNames

Как мы помним, она требует три аргумента: имя пути pathName, текущую ширину progress и желаемое значение графика из массива pixelValues.

Важно, что в pathNames столько же элементов, сколько в pixelValues, иначе не удастся сопоставить индекс i. Первый элемент pathNames[0] под индексом 0 — “inner”, ему соответствует pixelValues[0].

    animatePath(pathName, progress.width, pixelValues[i])

Полный код шага 12:

Шаг 13. Ивенты кликов

Простой шаг со сменой экрана.

При нажатии на кнопку Activity:

  • Двигаем кнопку вниз и скрываем.
  • Скрываем слой overlay.
  • Делаем кликабельным фон back.
button.onTap ->
button.stateCycle()
overlay.stateCycle()
back.stateCycle()
  • Внутри дилея развернём progress.
  Utils.delay time/6, ->
progress.stateCycle()

Пока видим слой overlay, он загораживает графики. На то чтобы он исчез нужно некоторое время. Нужно отложить начало анимации при помощи функции delay(). В качестве аргумента передадим ей переменную time, разделённую на 6.

Задаём значения счётчикам

Передадим актуальные значения счётчикам. Для этого зададим свойство text у каждого из лейблов. Используем проценты из values. Поскольку свойство text должно принимать строку, а не число, обернём значение кавычками и фигурными скобками.

  for label, i in labels.children
label.text = “#{values[i]}”

Того же эффекта можно было бы добиться, используя функцию toString():

label.text = values[i].toString()

Реверс

Когда графики развёрнуты, их надо свернуть, чтобы пользователь мог проиграть прототип ещё раз, не перезагружая страницу.

Для этого я использую невидимый слой back, который имеет ту же площадь, что и экран.

По клику на back, возвращаем все слои в состояние “default”:

  • На дилее вполовину time поднимаем кнопку вверх и лишаем её кликабельности, устанавливая visible в false. Я не стал этого делать в свойствах, потому что иначе кнопка пропала бы без анимации.
back.onTap ->
button.stateCycle()
Utils.delay time/2, ->
button.visible = false
  • Проявляем overlay.
overlay.stateCycle()
  • Делаем back снова некликабельным.
back.stateCycle()
  • На дилее в половину time ужимаем слой progress до 4 пикселей.
Utils.delay time/2, ->
progress.stateCycle()

Полный код шага 13:

На этом прототип считаем функционирующим.

На шаге 14 мы напишем функцию, натягивающую градиент слоя на svg-линию, а на 15 оживим часы, чтобы показывали реальное текущее время.

Шаги 14 и 15 я выделил в отдельный пост, потому что они слишком технические и вряд ли пригодятся большинству дизайнеров.

Предыдущий Фреймер-кейс:

Я веду Скетч-дизайнер, канал о дизайне интерфейсов в Скетче, плагинах и горячих клавишах. Также пишу о дизайн-системах, проектировании и UI-анимации в Фреймере.

Поддержать проект

Ссылка на Киви Кошелёк. Деньги будут вложены в развитие Скетч-дизайнера.

--

--

Саша Окунев
/designer

Дизайн-лид в Kaspi.kz. Автор проекта /designer.