Фреймер-кейс №2: анимация SVG-пути
В этом уроке мы сделаем прототип анимации приложения Activity для Apple Watch. При нажатии на кнопку будет срабатывать анимация графика, который показывает статистику фитнес-трекера.
Мы научимся использовать SVG-слои, зададим атрибуты SVG через новый метод setAttribute(). На наглядном примере научимся использовать modulate() и привяжем изменение одного значения к другому.
Этот кейс основан на прототипе Бенджамина Ден Боера из команды Фреймера. В оригинальном примере был ужасно написанный код, который мне пришлось заново переписать.
Фреймер-проект →
Ссылка на Фреймер Клауд, смотреть в Safari или Chrome
Скачай исходник и открой его в Фреймере. Я прокомментировал каждую строку.
Этот кейс рассчитан на тех, кто знаком с базовыми основами Фреймера: переменными, свойствами, массивами, циклами, функциями и состояниями. Если ты никогда не имел дела с Фреймером, возможно, лучше сначала пройти замечательный бесплатный видеокурс Руслана Шарипова:
В этом кейсе получилось больше программирования, чем в первом, где мы в основном двигали объекты.
Если у тебя не получается воспроизвести этот урок, задавай вопросы в Фреймер-чате:
Для тех, кто хочет по-быстрому внедрить анимацию в свой проект, а не ковыряться в этом вашем программировании
Я сделал упрощённый пример, в котором анимируется только один график и нет массивов:
Как его использовать:
- Поменять стиль слоя piechart в режиме дизайна.
- Задать цвет градиента вместо красного.
- В режиме кода подстроить время анимации и значение в процентах.
- Не трогать функции, всё уже настроено. Если нужно сместить начало графика, ищи stroke-dashoffset и задавай компенсацию в пикселях.
- Скрыть служебный слой progress при помощи visible:false.
- В складке Ивенты задать слой, по клику на который будет происходить анимация. Сейчас это слой back. У слоя должен стоять таргет.
Для любопытных
Если же тебе мало экспресс-примера и ты хочешь досконально разобрать весь кейс, я дам такую возможность.
План действий
- Рисуем макет в режиме дизайна в Фреймере и расставляем таргеты.
- Настраиваем цвет фона, время анимации и значения пайчарта в процентах в массиве values. В дальнейшем мы будем использовать эти значения в анимации кругов.
- Создаём и наполняем массив pathNames, в котором будем хранить имена svg-контейнеров. Это нужно, поскольку на них нельзя повесить таргеты. Мы будем обращаться к svg-слоям по названию, чтобы выяснить их длины, а впоследствии задавать нужную длину.
- Создаём и наполняем массив maxValues, чтобы ограничить длину графиков максимальными значениями длины окружности в пикселях.
- Пишем функцию convertValueToPixels(), которая будет конвертировать значения процентов в аналогичные в пикселях.
- Создаём и наполняем массив pixelValues, используя convertValueToPixels(). На этом шаге мы подготовим все необходимые данные для анимации SVG-путей.
- Готовим слои: делаем фрейм overlay размытым, пишем скрытые состояния кнопке button, слою overlay и слою back.
- Пишем функцию setViewbox(), которая задаёт координаты и размеры svg-контейнера. Можно взять уже готовую и просто использовать.
- Пишем функцию reset(), которая обнуляет значения пайчарта.
- Делаем вспомогательный слой progress, от ширины которого будет зависеть длина SVG-путей. Делаем ему состояние final.
- Пишем функцию animatePath(), которая будет привязывать изменение ширины слоя progress к длине SVG-путей.
- Пишем ивент on “change:width”, который будет отслеживать, как меняется ширина слоя progress в процессе анимации. В нём используем animatePath(). В неё передаём имя пути, текущую ширину progress и значение графика, к которому хотим прийти.
- Пишем ивенты кликов (триггеры) по кнопке button и по слою back.
После этого этапа прототип будет работать. - В приложении к этому посту мы напишем функцию setGradient(). При помощи неё задаём градиенты в качестве заливки линий графигка, Используя слои g1, g2 и g3.
- Оживляем время на часах функцией 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-анимации в Фреймере.
Поддержать проект →
Ссылка на Киви Кошелёк. Деньги будут вложены в развитие Скетч-дизайнера.