Русский
Русский
English
Статистика
Реклама

Animation

StickyMate frontend анимация из HTML разметки

03.08.2020 22:10:54 | Автор: admin
StickyMate это инструмент, который поможет веб-разработчикам создавать анимации без знания JavaScript, позволяя задавать параметры анимации в формате JSON прямо в HTML разметке. StickyMate объединяет функции расширенного липкого позиционирования и анимации, основанной на прокрутке, что позволяет поймать элемент на экране и анимировать его, пока пользователь прокручивает страницу. Используйте ваше воображения для воплощения отличных идей, которые презентуют ваш контент наиболее эффектным образом.

Демо 1: https://rafaylik.github.io/stickymate/
Демо 2 (реализованный проект): https://brand.sumy.ua
Демо 3 (реализованный проект): http://mysliest.com.ua
Readme по внедрению на русском: https://github.com/rafaylik/stickymate/blob/master/README-RU.md
Страница на GitHub: https://github.com/rafaylik/stickymate

Данный инструмент вдохновлён лендинг-пейдж девайсов Apple, создан с нуля и имеет MIT License, работает с любые числовыми значениями CSS. Я не являюсь профильным разработчиком, но стараюсь придерживаться принципов чистоты кода и ES6, изучение чистого JS моё хобби. Любая критика и советы приветствуются.
Подробнее..
Категории: Html , Javascript , Animation , Frontend , Sticky

Анимация в Android плавные переходы фрагментов внутри Bottom Sheet

08.07.2020 18:05:40 | Автор: admin
Написано огромное количество документации и статей о важной визуальной составляющей приложений анимации. Несмотря на это мы смогли вляпаться в проблемы столкнулись с загвоздками при её реализации.

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



Бриллиантовый чекаут: предыстория


Бриллиантовый чекаут кодовое название нашего проекта. Смысл его очень прост сократить время, затрачиваемое клиентом на последнем этапе оформления заказа. Если в старой версии для оформления заказа требовалось минимум четыре клика на двух экранах (а каждый новый экран это потенциальная потеря контекста пользователем), бриллиантовый чекаут в идеальном случае требует всего один клик на одном экране.


Сравнение старого и нового чекаута

Между собой мы называем новый экран шторка. На рисунке вы видите, в каком виде мы получили задание от дизайнеров. Данное дизайнерское решение является стандартным, известно оно под именем Bottom Sheet, описано в Material Design (в том числе для Android) и в разных вариациях используется во многих приложениях. Google предлагает нам два готовых варианта реализации: модальный (Modal) и постоянный (Persistent). Разница между этими подходами описана во многих и многих статьях.


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

Смотри, какая классная анимация на iOS. Давай так же сделаем?


Такой вызов не принять мы не могли! Ладно, шучу по поводу дизайнеры неожиданно пришли с предложением сделать анимацию, но часть про iOS чистая правда.

Стандартные переходы между экранами (то есть, отсутствие переходов) выглядели хоть и не слишком коряво, но до соответствия званию бриллиантовый чекаут не дотягивали. Хотя, кого я обманываю, это действительно было ужасно:


Что имеем из коробки

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

  1. Клиент нажимал на поле адреса пиццерии -> в ответ открывался фрагмент Самовывоз. Открывался он на весь экран (так было задумано) с резким скачком, при этом список пиццерий появлялся с небольшой задержкой.
  2. Когда клиент нажимал Назад -> возврат на предыдущий экран происходил с резким скачком.
  3. При нажатии на поле способа оплаты -> снизу с резким скачком открывался фрагмент Способ оплаты. Список способов оплаты появлялся с задержкой, при их появлении экран увеличивался со скачком.
  4. При нажатии Назад -> возврат обратно с резким скачком.

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

В чём, собственно, проблема: где клиенту хорошо, там у нас ограничения


Пользователям не нравится, когда на экране происходит слишком много резких движений. Это отвлекает и смущает. Кроме того, всегда хочется видеть плавный отклик на своё действие, а не судороги.

Это привело нас к техническому ограничению: мы решили, что нам нельзя на каждую смену экрана закрывать текущий bottom sheet и показывать новый, а также будет плохо показывать несколько bottom sheet один над другим. Так, в рамках нашей реализации (каждый экран новый фрагмент), можно сделать только один bottom sheet, который должен двигаться максимально плавно в ответ на действия пользователей.

Это означает, что у нас будет контейнер для фрагментов, который будет динамическим по высоте (поскольку все фрагменты имеют разную высоту), и мы должны анимировать изменение его высоты.

Предварительная разметка


Корневой элемент шторки очень простой это всего лишь прямоугольный фон с закруглёнными сверху углами и контейнер, в который помещаются фрагменты.

<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:background="@drawable/dialog_gray200_background"    >   <androidx.fragment.app.FragmentContainerView      android:id="@+id/container"      android:layout_width="match_parent"      android:layout_height="match_parent"      /> </FrameLayout>

И файл dialog_gray200_background.xml выглядит так:

<?xml version="1.0" encoding="utf-8"?><selector xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android">  <item>    <shape android:shape="rectangle">      <solid android:color="@color/gray200" />      <corners android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" />    </shape>  </item></selector>

Каждый новый экран представляет собой отдельный фрагмент, фрагменты сменяются с помощью метода replace, тут всё стандартно.

Первые попытки реализовать анимацию


animateLayoutChanges


Вспоминаем о древней эльфийской магии animateLayoutChanges, которая на самом деле представляет собой дефолтный LayoutTransition. Хотя animateLayoutChanges совершенно не рассчитан на смену фрагментов, есть надежда, что это поможет с анимацией высоты. Также FragmentContainerView не поддерживает animateLayoutChanges, поэтому меняем его на старый добрый FrameLayout.

<?xml version="1.0" encoding="utf-8"?><FrameLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:background="@drawable/dialog_gray200_background"    >   <FrameLayout      android:id="@+id/container"      android:layout_width="match_parent"      android:layout_height="match_parent"      android:animateLayoutChanges="true"      /> </FrameLayout>

Запускаем:

animateLayoutChanges

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

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

setCustomAnimations


FragmentTransaction позволяет задать анимацию, описанную в xml-формате с помощью метода setCustomAnimation. Для этого в ресурсах создаём папку с названием anim и складываем туда четыре файла анимации:

to_right_out.xml

<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:duration="500"    android:interpolator="@android:anim/accelerate_interpolator">  <translate android:toXDelta="100%" /></set>

to_right_in.xml

<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:duration="500"    android:interpolator="@android:anim/accelerate_interpolator">  <translate android:fromXDelta="-100%" /></set>

to_left_out.xml

<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:duration="500"    android:interpolator="@android:anim/accelerate_interpolator">  <translate android:toXDelta="-100%" /></set>

to_left_in.xml

<?xml version="1.0" encoding="utf-8"?><set xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    android:duration="500"    android:interpolator="@android:anim/accelerate_interpolator">  <translate android:fromXDelta="100%" /></set>

И затем устанавливаем эти анимации в транзакцию:

fragmentManager    .beginTransaction()    .setCustomAnimations(R.anim.to_left_in, R.anim.to_left_out, R.anim.to_right_in, R.anim.to_right_out)    .replace(containerId, newFragment)    .addToBackStack(newFragment.tag)    .commit()

Получаем вот такой результат:


setCustomAnimation

Что мы имеем при такой реализации:

  • Уже стало лучше видно как экраны сменяют друг друга в ответ на действие пользователя.
  • Но всё равно есть скачок из-за разной высоты фрагментов. Так происходит из-за того, что при переходе фрагментов в иерархии есть только один фрагмент. Именно он подстраивает высоту контейнера под себя, а второй отображается как получилось.
  • Всё ещё есть проблема с асинхронной загрузкой данных о способах оплаты экран появляется сначала пустым, а потом со скачком наполняется контентом.

Это никуда не годится. Вывод: нужно что-то другое.

А может попробуем что-то внезапное: Shared Element Transition


Большинство Android-разработчиков знает про Shared Element Transition. Однако, хотя этот инструмент очень гибкий, многие сталкиваются с проблемами при его использовании и поэтому не очень любят применять его.


Суть его довольно проста мы можем анимировать переход элементов одного фрагмента в другой. Например, можем элемент на первом фрагменте (назовём его начальным элементом) с анимацией переместить на место элемента на втором фрагменте (этот элемент назовём конечным элементом), при этом с фэйдом скрыть остальные элементы первого фрагмента и с фэйдом показать второй фрагмент. Элемент, который должен анимироваться с одного фрагмента на другой, называется Shared Element.

Чтобы задать Shared Element, нам нужно:

  • пометить начальный элемент и конечный элемент атрибутом transitionName с одинаковым значением;
  • указать sharedElementEnterTransition для второго фрагмента.

А что, если использовать корневую View фрагмента в качестве Shared Element? Возможно Shared Element Transition придумывали не для этого. Хотя если подумать, сложно найти аргумент, почему это решение не подойдёт. Мы хотим анимировать начальный элемент в конечный элемент между двумя фрагментами. Не вижу идеологического противоречия. Давайте попробуем сделать так!

Для каждого фрагмента, который находится внутри шторки, для корневой View указываем атрибут transitionName с одинаковым значением:

<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout     xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:transitionName="checkoutTransition"    >

Важно: это будет работать, поскольку мы используем REPLACE в транзакции фрагментов. Если вы используете ADD (или используете ADD и скрываете предыдущий фрагмент с помощью previousFragment.hide() [не надо так делать]), то transitionName придётся задавать динамически и очищать после завершения анимации. Так приходится делать, потому что в один момент времени в текущей иерархии View не может быть две View с одинаковым transitionName. Осуществить это можно, но будет лучше, если вы сможете обойтись без такого хака. Если вам всё-таки очень нужно использовать ADD, вдохновение для реализации можно найти в этой статье.

Далее нужно указать класс Transition'а, который будет отвечать за то, как будет протекать наш переход. Для начала проверим, что есть из коробки используем AutoTransition.

newFragment.sharedElementEnterTransition = AutoTransition()

И мы должны задать Shared Element, который хотим анимировать, в транзакции фрагментов. В нашем случае это будет корневая View фрагмента:

fragmentManager    .beginTransaction()    .apply{      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {        addSharedElement(currentFragment.requireView(), currentFragment.requireView().transitionName)        setReorderingAllowed(true)      }    }    .replace(containerId, newFragment)    .addToBackStack(newFragment.tag)    .commit()

Важно: обратите внимание, что transitionName (как и весь Transition API) доступен начиная с версии Android Lollipop.

Посмотрим, что получилось:


AutoTransition

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

Раз стандартная реализация нам не подошла, что нужно сделать? Конечно же, нужно переписать всё на Flutter написать свой Transition!

Пишем свой Transition


Transition это класс из Transition API, который отвечает за создание анимации между двумя сценами (Scene). Основные элементы этого API:

  • Scene это расположение элементов на экране в определённый момент времени (layout) и ViewGroup, в которой происходит анимация (sceneRoot).
  • Начальная сцена (Start Scene) это Scene в начальный момент времени.
  • Конечная сцена (End Scene) это Scene в конечный момент времени.
  • Transition класс, который собирает свойства начальной и конечной сцены и создаёт аниматор для анимации между ними.

В классе Transition мы будем использовать четыре метода:

  • fun getTransitionProperties(): Array. Данный метод должен вернуть набор свойств, которые будут анимироваться. Из этого метода нужно вернуть массив строк (ключей) в свободном виде, главное, чтобы методы captureStartValues и captureEndValues (описанные далее) записали свойства с этими ключами. Пример будет далее.
  • fun captureStartValues(transitionValues: TransitionValues). В данном методе мы получаем нужные свойства layout'а начальной сцены. Например, мы можем получить начальное расположение элементов, высоту, прозрачность и так далее.
  • fun captureEndValues(transitionValues: TransitionValues). Такой же метод, только для получения свойств layout'а конечной сцены.
  • fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator?. Этот метод должен использовать свойства начальной и конечной сцены, собранные ранее, чтобы создать анимацию между этими свойствами. Обратите внимание, что если свойства между начальной и конечной сценой не поменялись, то данный метод не вызовется вовсе.

Реализуем свой Transition за девять шагов


  1. Создаём класс, который представляет Transition.

    @TargetApi(VERSION_CODES.LOLLIPOP)class BottomSheetSharedTransition : Transition {@Suppress("unused")constructor() : super() @Suppress("unused")constructor(      context: Context?,       attrs: AttributeSet?) : super(context, attrs)}
    
    Напоминаю, что Transition API доступен с версии Android Lollipop.
  2. Реализуем getTransitionProperties.

    Поскольку мы хотим анимировать высоту View, заведём константу PROP_HEIGHT, соответствующую этому свойству (значение может быть любым) и вернём массив с этой константой:

    companion object {  private const val PROP_HEIGHT = "heightTransition:height"   private val TransitionProperties = arrayOf(PROP_HEIGHT)} override fun getTransitionProperties(): Array<String> = TransitionProperties
    
  3. Реализуем captureStartValues.

    Нам нужно запомнить высоту той View, которая хранится в параметре transitionValues. Значение высоты нам нужно записать в поле transitionValues.values (он имеет тип Map) c ключом PROP_HEIGHT:

    override fun captureStartValues(transitionValues: TransitionValues) {  transitionValues.values[PROP_HEIGHT] = transitionValues.view.height}
    

    Всё просто, но есть нюанс. Вспомните, что во всех случаях ранее высота контейнера резко менялась в соответствии с высотой нового фрагмента. Чтобы такого не происходило, придётся что-то придумать. Проще всего просто прибить гвоздями контейнер фрагментов, то есть просто задать ему константную высоту, равную текущей высоте. При этом на экране ничего не произойдёт, но при смене фрагмента высота останется той же. В итоге метод будет выглядеть следующим образом:

    override fun captureStartValues(transitionValues: TransitionValues) {  // Запоминаем начальную высоту View...  transitionValues.values[PROP_HEIGHT] = transitionValues.view.height   // ... и затем закрепляем высоту контейнера фрагмента  transitionValues.view.parent    .let { it as? View }    ?.also { view ->        view.updateLayoutParams<ViewGroup.LayoutParams> {            height = view.height        }    } }
    
  4. Реализуем captureEndValues.

    Аналогично предыдущему методу, нужно запомнить высоту View. Но не всё так просто. На предыдущем шаге мы зафиксировали высоту контейнера. Новый фрагмент по высоте может быть меньше, равным или больше предыдущего фрагмента. В первых двух случаях мы можем просто взять высоту нового фрагмента. Однако в случае, когда новый фрагмент должен занять больше места, чем старый, значение высоты будет ограничено высотой контейнера. Поэтому придётся пойти на небольшую хитрость мы просто измерим view, чтобы определить, сколько места на самом деле ей требуется. Реализация будет выглядеть так:

    override fun captureEndValues(transitionValues: TransitionValues) {  // Измеряем и запоминаем высоту View  transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)}
    

    И метод getViewHeight:

    private fun getViewHeight(view: View): Int {  // Получаем ширину экрана  val deviceWidth = getScreenWidth(view)   // Попросим View измерить себя при указанной ширине экрана  val widthMeasureSpec = MeasureSpec.makeMeasureSpec(deviceWidth, MeasureSpec.EXACTLY)  val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)   return view      // измеряем      .apply { measure(widthMeasureSpec, heightMeasureSpec) }      // получаем измеренную высоту      .measuredHeight      // если View хочет занять высоту больше доступной высоты экрана, мы должны вернуть высоту экрана      .coerceAtMost(getScreenHeight(view))} private fun getScreenHeight(view: View) =  getDisplaySize(view).y - getStatusBarHeight(view.context) private fun getScreenWidth(view: View) =  getDisplaySize(view).x private fun getDisplaySize(view: View) =  Point().also {    (view.context.getSystemService(        Context.WINDOW_SERVICE    ) as WindowManager).defaultDisplay.getSize(it)  } private fun getStatusBarHeight(context: Context): Int =  context.resources      .getIdentifier("status_bar_height", "dimen", "android")      .takeIf { resourceId -> resourceId > 0 }      ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }      ?: 0
    

    Таким образом, мы знаем начальную и конечную высоту контейнера, и теперь дело за малым создать анимацию.
  5. Реализация анимации. Fade in.

    Начальный фрагмент нам анимировать не нужно, так как при старте транзакции он удалится из иерархии. Будем показывать конечный фрагмент с фэйдом. Добавляем метод в класс BottomSheetSharedTransition, ничего хитрого:

    private fun prepareFadeInAnimator(view: View): Animator =   ObjectAnimator.ofFloat(view, "alpha", 0f, 1f) 
    
  6. Реализация анимации. Анимация высоты.

    Ранее мы запомнили начальную и конечную высоту, теперь мы можем анимировать высоту контейнера фрагментов:

    private fun prepareHeightAnimator(    startHeight: Int,    endHeight: Int,    view: View) = ValueAnimator.ofInt(startHeight, endHeight)    .apply {        val container = view.parent.let { it as View }                // изменяем высоту контейнера фрагментов        addUpdateListener { animation ->            container.updateLayoutParams<ViewGroup.LayoutParams> {                height = animation.animatedValue as Int            }        }    }
    

    Создаём ValueAnimator и обновляем высоту конечного фрагмента. Снова ничего сложного, но есть нюанс. Поскольку мы меняем высоту контейнера, после анимации его высота будет фиксированной. Это означает, что если фрагмент в ходе своей работы будет менять высоту, то контейнер не будет подстраиваться под это изменение. Чтобы этого избежать, по окончании анимации нужно установить высоту контейнера в значение WRAP_CONTENT. Таким образом, метод для анимации высоты контейнера будет выглядеть так:

    private fun prepareHeightAnimator(    startHeight: Int,    endHeight: Int,    view: View) = ValueAnimator.ofInt(startHeight, endHeight)    .apply {        val container = view.parent.let { it as View }                // изменяем высоту контейнера фрагментов        addUpdateListener { animation ->            container.updateLayoutParams<ViewGroup.LayoutParams> {                height = animation.animatedValue as Int            }        }                // окончании анимации устанавливаем высоту контейнера WRAP_CONTENT         doOnEnd {            container.updateLayoutParams<ViewGroup.LayoutParams> {                height = ViewGroup.LayoutParams.WRAP_CONTENT            }        }    }
    

    Теперь всего лишь нужно использовать аниматоры, созданные этими функциями.
  7. Реализация анимации. createAnimator.

    override fun createAnimator(    sceneRoot: ViewGroup?,    startValues: TransitionValues?,    endValues: TransitionValues?): Animator? {    if (startValues == null || endValues == null) {        return null    }     val animators = listOf<Animator>(        prepareHeightAnimator(            startValues.values[PROP_HEIGHT] as Int,            endValues.values[PROP_HEIGHT] as Int,            endValues.view        ),        prepareFadeInAnimator(endValues.view)    )     return AnimatorSet()        .apply {            interpolator = FastOutSlowInInterpolator()            duration = ANIMATION_DURATION            playTogether(animators)        }}
    
  8. Всегда анимируем переход.

    Последний нюанс касательно реализации данного Transititon'а. Звёзды могут сойтись таким образом, что высота начального фрагмента будет точно равна высоте конечного фрагмента. Такое вполне может быть, если оба фрагмента занимают всю высоту экрана. В таком случае метод createAnimator не будет вызван совсем. Что же произойдёт?

    • Не будет Fade'а нового фрагмента, он просто резко появится на экране.
    • Поскольку в методе captureStartValues мы зафиксировали высоту контейнера, а анимации не произойдёт, высота контейнера никогда не станет равной WRAP_CONTENT.

    Неприятно, но не смертельно. Можно обойти это поведение простым трюком: нужно добавить любое значение, которое будет отличаться для начальной сцены и конечной сцены, в список свойств Transition'а. Можно просто добавить строки с разными значениями:

    companion object {    private const val PROP_HEIGHT = "heightTransition:height"    private const val PROP_VIEW_TYPE = "heightTransition:viewType"     private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)} override fun getTransitionProperties(): Array<String> = TransitionProperties override fun captureStartValues(transitionValues: TransitionValues) {    // Запоминаем начальную высоту View...    transitionValues.values[PROP_HEIGHT] = transitionValues.view.height    transitionValues.values[PROP_VIEW_TYPE] = "start"     // ... и затем закрепляем высоту контейнера фрагмента    transitionValues.view.parent        .let { it as? View }        ?.also { view ->            view.updateLayoutParams<ViewGroup.LayoutParams> {                height = view.height            }        } } override fun captureEndValues(transitionValues: TransitionValues) {    // Измеряем и запоминаем высоту View    transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)    transitionValues.values[PROP_VIEW_TYPE] = "end"}
    

    Обратите внимание, добавилось свойство PROP_VIEW_TYPE, и в методах captureStartValues и captureEndValues записываем разные значения этого свойства. Всё, транзишн готов!
  9. Применяем Transition.

    newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()
    

Асинхронная загрузка данных


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

Всё вместе: что в итоге получилось


С новым BottomSheetSharedTransition и использованием postponeEnterTransition при асинхронной загрузке данных у нас получилась такая анимация:

Готовый transition

Под спойлером готовый класс BottomSheetSharedTransition
package com.maleev.bottomsheetanimation import android.animation.Animatorimport android.animation.AnimatorSetimport android.animation.ObjectAnimatorimport android.animation.ValueAnimatorimport android.annotation.TargetApiimport android.content.Contextimport android.graphics.Pointimport android.os.Buildimport android.transition.Transitionimport android.transition.TransitionValuesimport android.util.AttributeSetimport android.view.Viewimport android.view.ViewGroupimport android.view.WindowManagerimport android.view.animation.AccelerateInterpolatorimport androidx.core.animation.doOnEndimport androidx.core.view.updateLayoutParams @TargetApi(Build.VERSION_CODES.LOLLIPOP)class BottomSheetSharedTransition : Transition {     @Suppress("unused")    constructor() : super()     @Suppress("unused")    constructor(        context: Context?,        attrs: AttributeSet?    ) : super(context, attrs)     companion object {        private const val PROP_HEIGHT = "heightTransition:height"         // the property PROP_VIEW_TYPE is workaround that allows to run transition always        // even if height was not changed. It's required as we should set container height        // to WRAP_CONTENT after animation complete        private const val PROP_VIEW_TYPE = "heightTransition:viewType"        private const val ANIMATION_DURATION = 400L         private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)    }     override fun getTransitionProperties(): Array<String> = TransitionProperties     override fun captureStartValues(transitionValues: TransitionValues) {        // Запоминаем начальную высоту View...        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height        transitionValues.values[PROP_VIEW_TYPE] = "start"         // ... и затем закрепляем высоту контейнера фрагмента        transitionValues.view.parent            .let { it as? View }            ?.also { view ->                view.updateLayoutParams<ViewGroup.LayoutParams> {                    height = view.height                }            }     }     override fun captureEndValues(transitionValues: TransitionValues) {        // Измеряем и запоминаем высоту View        transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)        transitionValues.values[PROP_VIEW_TYPE] = "end"    }     override fun createAnimator(        sceneRoot: ViewGroup?,        startValues: TransitionValues?,        endValues: TransitionValues?    ): Animator? {        if (startValues == null || endValues == null) {            return null        }         val animators = listOf<Animator>(            prepareHeightAnimator(                startValues.values[PROP_HEIGHT] as Int,                endValues.values[PROP_HEIGHT] as Int,                endValues.view            ),            prepareFadeInAnimator(endValues.view)        )         return AnimatorSet()            .apply {                duration = ANIMATION_DURATION                playTogether(animators)            }    }     private fun prepareFadeInAnimator(view: View): Animator =        ObjectAnimator            .ofFloat(view, "alpha", 0f, 1f)            .apply { interpolator = AccelerateInterpolator() }     private fun prepareHeightAnimator(        startHeight: Int,        endHeight: Int,        view: View    ) = ValueAnimator.ofInt(startHeight, endHeight)        .apply {            val container = view.parent.let { it as View }             // изменяем высоту контейнера фрагментов            addUpdateListener { animation ->                container.updateLayoutParams<ViewGroup.LayoutParams> {                    height = animation.animatedValue as Int                }            }             // окончании анимации устанавливаем высоту контейнера WRAP_CONTENT            doOnEnd {                container.updateLayoutParams<ViewGroup.LayoutParams> {                    height = ViewGroup.LayoutParams.WRAP_CONTENT                }            }        }     private fun getViewHeight(view: View): Int {        // Получаем ширину экрана        val deviceWidth = getScreenWidth(view)         // Попросим View измерить себя при указанной ширине экрана        val widthMeasureSpec =            View.MeasureSpec.makeMeasureSpec(deviceWidth, View.MeasureSpec.EXACTLY)        val heightMeasureSpec =            View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)         return view            // измеряем:            .apply { measure(widthMeasureSpec, heightMeasureSpec) }            // получаем измеренную высоту:            .measuredHeight            // если View хочет занять высоту больше доступной высоты экрана, мы должны вернуть высоту экрана:            .coerceAtMost(getScreenHeight(view))    }     private fun getScreenHeight(view: View) =        getDisplaySize(view).y - getStatusBarHeight(view.context)     private fun getScreenWidth(view: View) =        getDisplaySize(view).x     private fun getDisplaySize(view: View) =        Point().also { point ->            view.context.getSystemService(Context.WINDOW_SERVICE)                .let { it as WindowManager }                .defaultDisplay                .getSize(point)        }     private fun getStatusBarHeight(context: Context): Int =        context.resources            .getIdentifier("status_bar_height", "dimen", "android")            .takeIf { resourceId -> resourceId > 0 }            ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }            ?: 0}


Когда у нас есть готовый класс Transition'а, его применение сводится к простым шагам:

Шаг 1. При транзакции фрагмента добавляем Shared Element и устанавливаем Transition:

private fun transitToFragment(newFragment: Fragment) {    val currentFragmentRoot = childFragmentManager.fragments[0].requireView()     childFragmentManager        .beginTransaction()        .apply {            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {                addSharedElement(currentFragmentRoot, currentFragmentRoot.transitionName)                setReorderingAllowed(true)                 newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()            }        }        .replace(R.id.container, newFragment)        .addToBackStack(newFragment.javaClass.name)        .commit()}

Шаг 2. В разметке фрагментов (текущего фрагмента и следующего), которые должны анимироваться внутри BottomSheetDialogFragment, устанавливаем transitionName:

<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout     xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:transitionName="checkoutTransition"    >

На этом всё, конец.

А можно было сделать всё иначе?


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

  • Отказаться от фрагментов, использовать один фрагмент с множеством View и анимировать конкретные View. Так вы получите больший контроль над анимацией, но потеряете преимущества фрагментов: нативную поддержку навигации и готовую обработку жизненного цикла (придётся реализовывать это самостоятельно).
  • Использовать MotionLayout. Технология MotionLayout на данный момент всё ещё находится на стадии бета, но выглядит очень многообещающе, и уже есть официальные примеры, демонстрирующие красивые переходы между фрагментами.
  • Не использовать анимацию. Да, наш дизайн является частным случаем, и вы вполне можете счесть анимацию в данном случае избыточной. Вместо этого можно показывать один Bottom Sheet поверх другого или скрывать один Bottom Sheet и следом показывать другой.
  • Отказаться от Bottom Sheet совсем. Нет изменения высоты контейнера фрагментов нет проблем.
Демо проект можно найти вот тут на GitHub. А вакансию Android-разработчика (Нижний Новгород) вот здесь на Хабр Карьера.
Подробнее..

Из песочницы Разжимаем древний формат сжатия анимаций

13.07.2020 14:10:08 | Автор: admin
image

В один день я просматривал различные видео на YouTube, связанные с персонажами программы Vocaloid (не совсем точное описание, но дальше буду называть просто вокалоидами). Одним из таких видео было так называемое PV из игры Hatsune Miku: Project DIVA 2nd. А именно песня relations из The Idolmaster, которую исполняли вокалоиды Megurine Luka и Kagamine Rin. Оба персонажа от Crypton Future Media. Порыскав по сети я понял, что никто так и не смог сконвертировать анимации из этой игры? Но почему? Об этом под катом.

Сама игра использует Alchemy Engine, который разработала Intrinsic Graphics, а позже купила Vicarious Visions. Это можно увидеть по файлам, имеющим расширение ".igb" (далее IGB), а также соответствующим строкам в них. Сами файлы бинарные. Погуглив немного я нашёл скрипт от тов. minmode для известной в определённых кругах программы Noesis. Запускаем её, с перекинутым в папку скриптом, пытаемся открыть файл анимаций и Получаем тыкву.

image

Как объяснил тов. minmode в своём посте на DeviantArt, этот скрипт не может прочитать анимацию, сжатую некоей Enbaya. В Google Patents я смог найти только подобное. Самим патентам уже лет 19-20, поэтому я и предполагаю, что сам алгоритм сжатия тоже древний. Да и сам сайт на это тоже намекает (доступен только через веб-архив). Поискав ещё немного я понял, что этот алгоритм был в составе некоего ProGATE от компании Enbaya. Но это ничего нам не даёт.

Вернёмся же к IGB. Переписав код для IGB, который я смог найти, а также воспользовавшись скриптом для Noesis, на C#, картина начала проясняться.

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

Уточнение *List массив из элементов *

igAnimationDatabase--igSkeletonList---igSkeleton - Скелет, который, возможно, будет использоваться нами----igSkeletonBoneInfoList-----igSkeletonBoneInfo - Нода скелета--igAnimationList---igAnimation - Наша анимация----igAnimationBindingList-----igAnimationBinding - Ссылается на igSkeleton. Служит для линковки скелета к анимации----igAnimationTrackList-----igAnimationTrack - Сам трек анимации------igEnbayaTransformSource-------igEnbayaAnimationSource--------igData - Тут уже хранятся сырые данные EnbayaigData - Нода с данными, которые уже не являются нодами.

Таким образом я смог достать сырые данные для дальнейшего изучения. С помощью PPSSPP, Ghidra и плагина для неё я начал изучать бинарник игры. Я уже не особо помню как именно нашёл нужные функции, но приведу конкретные функции из EBOOT.BIT из ULJM05681 (в данном случае это не первая, а вторая, так называемая Bargain Version или же Project Diva 2nd#):

0x08A08050 инициализация функции декомпрессии на основе заголовка из igData
0x08A0876C запрос данных по конкретному времени (да. Enbaya работает со временем, не кадрами).

Сам код декомпилирован и выложен на GitLab. Написан он на Си. Компилируется как в Visual Studio, так и в gcc. Работает как в x86, так и в x64.

Я не стану углубляться в сам алгоритм. За меня лучше расскажет мой код.

Но если кратко, то Enbaya использует дельту для данных о перемещении и кватернионах. Дельту оно применяет, просто прибавляя/отнимая её к/от предыдущим/текущих данных. Перемещение остаётся как есть, а кватернион нормализуется для дальнейшего использования. Алгоритм позволяет вернуться назад во времени, не перезагружая файл. При этом он оперирует не частотой кадров, а семплами в секунду. Для этого он в памяти хранит два состояния предыдущий семпл и следующий, а движок сам интерполирует значение между ними. Однако в следствии того, что у нас данные в файле везде в целочисленном виде, мы должны их на что-то делить (точнее умножить. например на 0.0002), чтобы получить дробное число. Это число указывается в заголовке. Из-за этого деления (на самом деле умножения, но не суть) с каждым сложением и вычитанием точность немного уплывает.

А на этом всё. Честно говоря, мне было весело реверсить всё это. Надеюсь, что мои труды не прошли даром.

P.S. Используя данные igSkeleton мы уже можем получить готовую анимацию и экспортировать её, например в Maya. Через тот же Noesis.

Подробнее..

Из песочницы Jungle town как мы хотели изменить мир к лучшему, создавая детскую игру

12.10.2020 00:17:51 | Автор: admin

image




История нашей игры началась в 2016 году с покупки приставки Apple TV. Это был очень желанный девайс, на котором я обнаружил несколько игр, в том числе детских. Одна из них была от знаменитой студии, и она настолько мне понравилась, что я сказал своей супруге и музе: Дорогая, я хочу сделать игру для нашей дочери! Она меня поддержала.


Мысль о создании своей игры или приложения появлялась у меня и раньше, но мне казалось это нереальным, просто запредельным. А в тот момент загорелся огонь, и я решил идти только вперед. Нужны ведь деньги и единомышленники, чтобы создать игру, скажете вы. Да, все верно. На тот момент я был топовым видеографом и хорошо зарабатывал, поэтому наивно думал, что мы с супругой найдем команду и будем просто финансировать и руководить проектом.


Сценарий игры


Первым делом мы должны были найти сценариста. Нам повезло, поиск был недолгим. Мы объяснили автору свои цели и задачи, рассказали, какие игры нам импонируют и стали с нетерпением ждать результат. Получив на руки текст, мы были удивлены супер кратким содержанием написанного. Оказалось, что вместо ожидаемого сценария, нам сделали так называемую Библию игры, в которой были описаны 6 характеров персонажей, игровой мир и практически тезисно игровой процесс, можно сказать, идея игровой механики. Так как, оформляя заказ на библию, в тот момент мы не понимали, чем она отличается от сценария, это стало хорошим уроком для нас впредь обговаривать до мелочей за что мы платим деньги.
Получив на руки этот документ, мы не совсем понимали, что с ним делать, но содержание настолько вдохновляло, что мы приступили к поиску следующего исполнителя, который мог бы в деталях расписать все взаимодействия в игровом мире. В тот момент я был настолько возбужден процессом создания игры, что у меня уже не было страха и сомнений двигаться вперед, начало было увлекательным, но если бы я только знал, что ждет меня впереди...


Первые депрессии


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


При дальнейшем общении с разными исполнителями мы слышали много негатива и пессимизма по поводу наших планов, целей, многие люди говорили нам: Вы ничего не знаете, у вас ничего не выйдет, игры не делаются за короткие сроки (к слову, мы рассчитывали уложиться в 6 месяцев), на детском сегменте не заработаешь и т.д. Слыша подобные разговоры, мы понимали, что с такими людьми работать не имеет смысла, ничего творческого они нам не предложат. Все это вгоняло в депрессию, и меня стали посещать мысли, типа: кто я такой, куда я полез, это мне не под силу, нужно остановиться. Не знаю можно ли верить в гороскопы, которые говорят, что овны очень упрямые и целеустремленные, но такие черты в моем характере есть, поэтому, поразмыслив несколько дней, я решил послать всех подальше и предложил супруге самим расписать дизайн-документ, как мы расписывали сценарии для съемок.


Диздок и поиск референсов


Основываясь на сеттинге игры и характерах персонажей, мы приступили к написанию дизайн-документа, создав в нем 3 категории: визуальный ряд, механика, действия героя. Мы не знали, как это делают профи, но найденные в интернете образцы диздоков показались нам не совсем понятными для исполнителей, поэтому решили остановиться на своей схеме. Шаг за шагом мы перебирали в голове идеи как насытить игровой мир незаурядной механикой, пересмотрели много игр детской тематики, нашли в них минусы и попытались избавиться от этих минусов в сценарии своей будущей игры.


Вдохновляясь Зеленой шапочкой, Спокойной ночи, цирк, сериями игр Sago Mini, Cut the rope, мы хотели сделать что-то простое в исполнении, но при этом функциональное и красивое. Помимо анализа игр, мы нашли множество референсов для исполнителей. Выбор был сложным, попадалось много иллюстраций бэкграундов и персонажей как во flat стиле, так и full, предпочтение отдали flat стилю, потому что его можно было быстрее отрисовать.


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


В итоге мы написали дизайн документ на 40 страниц. Это было вау!, мы преодолели еще одну полосу препятствий. Моя упёртость меня не подвела.


Поиск исполнителей и снова трудности


Первым этапом мы решили найти иллюстратора персонажника. Базой для поиска служили сайты Behance, Illustrator.ru и Artstation. На 2016 год таких специалистов было не очень много на постсоветском пространстве, а из тех, кто нравился, многие были заняты или не походили под наш бюджет.


Наконец, мы остановились на одной армянской студии, которая предложила полный цикл разработки персонажей, начиная от скетчей до финального цветного рисунка, а также создание бэкграундов и анимацию. От них мы узнали про среду разработки Unity 3D и замечательный пакет Spine 2D для анимации будущей игры. В ходе переговоров оказалось, что их цены слишком высоки для нас, и мы остановились на паре других исполнителей. Ребята нарисовали несколько вариантов пробного персонажа и бэкраунд, но это было не то, что нам хотелось. Мы заплатили за работу, хотя по договору могли этого не делать, но сами исполнители настолько мне импонировали, что хотелось отблагодарить их за труд. В последующем я понял, что помимо отличного результата мне нужны люди, с которыми будет комфортно работать, ведь у меня появился опыт взаимодействия с разными ребятами, и, бывало, я чувствовал себя дискомфортно из-за того, что мои хотелки рубили на корню или демонстрировали звездный характер.


Мы решили снова переговорить с армянской студией и пересмотреть их полный цикл, в итоге успешно договорились на разработку персонажей и setup их в Spine 2D без анимации. С этого момента началось все самое интересное. Ребята очень понравились мне в работе и общении, мы стали получать первые скетчи слоненка и это было словно появление ребенка на свет. Скетчи были нарисованы разными художниками одной студии, и перед нами встал выбор стилистики персонажа.




image




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




image




Итак, художник приступил рисовать скетчи остальных персонажей на основе выбранной стилистики. Их нужно было править, и я понял, что совсем не мог объяснить чего хочу, пришлось подключать свои способности и дорисовывать те детали, которые нужно было откорректировать. Это был интересный опыт, было скомкано очень много бумаги, потому что скетчи я поправлял карандашом. Работа была очень вдохновляющей, и мы порхали от счастья, глядя на своих милашек в карандашной технике.


Но вот мы получили цветные версии персонажей, и тут над моей головой сгустились тучи. То, что мы увидели, на мой взгляд, было ужасно.




image




image




Простите, ребята, если читаете меня, но реально персонажи в скетчах и в цвете были совершенно разными. Как оказалось, из студии ушел специалист, который отрисовывал цветные версии. Я решил посмотреть, что будет, когда они настроят setup персонажей в Spine 2D, надеялся, что все недостатки сгладятся и в анимации будут выглядеть иначе, но, увы, и этот результат меня не устроил.


Пришлось снова садиться и искать исполнителей для того, чтобы из скетчей получить красивых цветных персонажей. Это затянулось на долгие 3 месяца, я очень тщательно перебирал специалистов, просил пробные отрисовки, но все было безуспешно. Решил попробовать нарисовать персонажей в цвете сам. Я думал, что с моими навыками дизайнера это будет легко, но не тут-то было. Пришлось покупать графический планшет и осваивать его, в процессе отрисовки менять внешний вид персонажей, их пропорции и одежду, изучать уроки рисования, искать реферы, чтобы доработать картинку, и это было не просто, ведь дизайн это одно, а быть художником совсем другое, к тому же за очень маленький срок я хотел добиться нужного мне результата. Первый герой дикобраза рисовалась мучительно и долго, работать с глазами было особо тяжело, я никак не мог добиться того, чтобы она выглядела по-детски милой, плюс ко всему по ее образу я должен был рисовать всех остальных персонажей. Как только работа над ней закончилась, остальное пошло как по маслу, герой за героем.




image




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




image




Работа над героями была закончена в конце августа, художником за лето я не стал, но со своей задачей справился. В октябре мы, наконец, нашли иллюстратора игрового мира (тоже не с первой попытки), а еще через месяц аниматора, который должен был приступить к своей работе в январе 2017. Параллельно я изучал Spine 2D, начал анимировать персонажей и предметы, но не планировал дальше этим заниматься, мне просто было интересно. К тому же эти знания помогали понять процесс работы в Spine 2D и интеграцию с Unity 3D, чтобы в дальнейшем я мог общаться с аниматором на понятном языке и ставить ему правильные задачи.




Январь 2017. Работа шла полным ходом, я продолжал заниматься бизнесом, зарабатывая деньги на игру, и параллельно был на связи с ребятами, ставя задачи и контролируя процесс.


С художником Антоном мы сразу нашли общий язык, он проникся нашим проектом и пообещал нарисовать фоны за 3-4 месяца, но, к сожалению, из-за имеющейся у него параллельной работы все растянулось примерно на 14 месяцев. Конечно, это не входило в мои планы, но работать с ним было комфортно, поэтому я решил проигнорировать этот момент. Та же самая история повторилась и с аниматором Андреем. Ох уж этот фриланс! Как легко все может выйти из под контроля! Время, к сожалению, беспощадно, и все планы закончить игру как можно скорее рушились на глазах.


В процессе работы Антон изучал каждый уровень и вносил много своих корректировок. К примеру, в диздоке было прописано, что игрок должен накачать шину для велосипеда, но Антон предложил подумать над более интересным методом (у нас же джунгли, где используются экоматериалы), и мы начали фантазировать. Первая идея: вместо шин использовать змею, долго думали как ее накачать, много смеялись, потом Антон предложил вместо змеи взять гусениц и кормить их соком тыквы, а когда они наедятся и распухнут, то просто упадут, а далее игрок перетащит их на обод колеса. Так и сделали. Этот уровень дети просто обожают.




image




По стилистике персонажей игра должна была быть во Flat стиле, но бэкграунды получились сложнее, и, на мой взгляд, Антону удалось гармонично совместить два разных стиля. UI решили нарисовать тоже во Flat и сделать его очень простым. С подсказками пришлось помучиться, изначально предполагалось лишь графически изобразить задание в отдельном окошке, но, как оказалось, найти и вызвать подсказку было затруднительно для игрока, поэтому решили добавить стрелочки и пальчики. В некоторых моментах и этого оказалось недостаточно, так родилась мысль добавить лампочку Эврика!, поэтому в нашей игре много самых разнообразных подсказок.




image




image




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




image




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


3 состояния ожидания действий игрока idle;
1 анимация наблюдения за действием игрока look;
1 реакция на правильное действие correctly;
1 реакция на неправильное действие wrong;
3 состояния радостных эмоций при завершении каждого задания emotion;
около 5 анимаций, связанных с заданиями, интро, финальным мультфильмом.




image




image




Анимированные персонажи получились очень классными, это была потрясающая работа. С предметами тоже было все хорошо, но большую часть пришлось переделывать самому при импорте в Unity 3D. Если бы работа по программированию велась одновременно с анимациями, я мог бы ставить Андрею правильные задачи, а так как работа шла вслепую, впоследствии пришлось закрыть глаза на необходимость переделок и надеяться на то, что правок будет не так много.




image




Параллельно с художником и аниматором я планировал работать с программистом, но поняв, что работа затягивается больше, чем на год, я начал изучать Unity, в частности, интеграцию со Spine 2D. Это был кошмар, я смотрел в монитор и хлопал глазами: что? куда? зачем? Попробовал закинуть в программу один анимированный уровень, и, когда я вывел первую анимацию на телефон без программирования, просто анимацию, над моей головой образовался ангельский нимб. Это звучит смешно, но я почувствовал себя программистом.




image




image




Вспомнились мои слова супруге о том, что если мы начнем делать игру, я буду только ставить задачи и контролировать процесс. И тут наступает момент, когда я говорю ей: Представляешь, я нашел способ сделать игру в Unity без кода! Я сейчас просто попробую собрать один уровень, чтобы дальше быть умнее в глазах программиста, на что она мне ответила: Я даже не сомневалась, что ты обязательно сделаешь что-то своими руками. Обожаю свою супругу за то, что она всегда позволяет мне двигаться вперед и творить, не тушит во мне этот огонь.


Я выбрал метод визуального программирования PlayMaker.




image




Просидел месяц и собрал в первом уровне один подуровень. Этот период чуть не сломил меня, хотелось бросить игру, все казалось очень сложным, в голове постоянно крутились страшные мысли, что я все соберу, а оно не будет работать. В итоге я собрался духом и сказал себе: все получится, я справлюсь, к тому же ребята радовали результатом, да и мои успехи в плане анимации и программной части с каждым этапом становились все больше, и я чувствовал себя увереннее. Очень хорошо, что я начал именно с визуального программирования, потому что вся та механика, которая была расписана в диздоке, на практике очень сильно поменялась, я даже думать стал немного по-другому. Со временем мои знания подросли настолько, что я научился писать экшены для Playmaker, необходимые, по большей части, для работы со Spine 2D.


В 2018 году пришло время писать музыку для игры, и я познакомился с замечательным композитором Геннадием. Ему понравилась наша игра, и он тоже, как говорится, вложил в нее частичку своей души. Я много лет работал в продакшн с видео и музыкой непосредственно, но, честно говоря, не мог толком объяснить какая музыка мне нужна. Мы доверились Геннадию, и он написал такие классные мелодии, которые дети напевают каждый раз, играя в Jungle Town.


Следующим этапом я начал искать исполнителей по озвучке персонажей и предметов. С 4-5 раза голоса персонажей мы утвердили, а вот со звуками не сложилось, пришлось снова взять нагрузку на себя. Я не знал как подойти к этому процессу и первым делом решил записать всю игру с музыкой, а поверх записи наложить звуки, которые скачивал в виде демо с Аudiojungle. После того, как я все смонтировал в Premiere Pro, купил используемые звуки, меня ожидал сюрприз: демо звуки шли одним файлом, а купленные отдельными файлами, пришлось искать и вручную синхронизировать нужный файл на таймлайне. Затем выяснилось, что звуки нужно экспортировать именно так, чтобы они совпадали с анимацией, иначе их не синхронизировать в Unity. Тут на моем жизненном пути повстречался еще один замечательный человек Илья, который хорошо знает C#, он помог мне написать код для Animator State в Unity. На этом эпопея со звуками не закончилась После их тестирования на мобильном устройстве, я обнаружил, что звуки скрипят, шумят, в общем, совсем не такие классные, как на компьютере. Выяснилось, что производители телефонов срезают частоты, тем самым повышая громкость звуков. Пришлось делать контроль для прослушивания звуков из Unity в реальном времени, работать с частотами, найти приемлемый вариант и применить его ко всем звукам.


Ура! На этом долгая-долгая разработка закончилась!


Релиз и ожидания дохода


В феврале 2020 пришло время релиза. Были подготовлены версии для iOS и Android, при публикации в App Store возникли небольшие трудности с аналитикой в детских приложениях. Много раз игру отклоняли, но после всех исправлений и удаления аналитики нас, наконец, опубликовали.


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


Доходы были очень низкими, первые покупки делали наши друзья и знакомые, а потом все затихло. В апреле появились активные продажи, не так много, как хотелось бы, но они были. Оказалось, мы попали в фичеринг детской категории App We Love на iPad в США, пробыли там около двух месяцев, и снова остались почти без продаж.


Долгое время пытались понять, что происходит, настраивали ASO, меняли картинки, проводили эксперименты со скидками и бесплатной раздачей, строили и проверяли гипотезы. В итоге пришли к выводу, что модель премиум в нашем случае не работает. И что сделать продукт это одно, а вот продать его это совсем другое. Это целая наука, которую нам еще предстоит осваивать, прежде чем взяться за продолжение нашего замечательного Jungle Townа


Спасибо моей супруге за нашу прекрасную дочь, если бы не она, то возможно не родилась бы такая прекрасная история как Jungle town, за ее терпение и веру в меня!




image




На данный момент игра запущена в следующих магазинах: App Store, Mac App Store, Apple TV, Google Play, Amazon Store, HUAWEI AppGallery. [Прим. модератора: ссылки убраны, чтобы не нарушать правила. Ищите игру в сторах по названию]


Игра работает как на десктопных так и на мобильных платформах, есть адаптация под игровые приставки. В наших планах выпустить серию игр Jungle town для детишек. Мы очень надеемся, что результат наших трудов порадует вас и ваших детей.


Продолжение следует!

Подробнее..

Программа для physics-based анимации персонажей Cascadeur вышла в ранний доступ

14.04.2021 20:10:55 | Автор: admin


Спустя 10 лет разработки и 2 года бета-тестирования Cascadeur, программа для создания физически корректной персонажной анимации, вышел в ранний доступ! Пользователям доступны 4 варианта подписки, один из которых совершенно бесплатный.

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


Данный релиз является итогом двухлетнего бета-тестирования, в котором приняли участие более 70.000 человек из индустрии по созданию компьютерной графики и визуальных эффектов. Однако на этом разработка Cascadeur не заканчивается.

В будущем в программу планируется добавить ряд новых функций, включая поддержку циклов в анимации, возможность создавать кастомные пользовательские риги, а также Python-скриптинг, который призван облегчить создание рига и автоматизировать анимационные задачи. С планами на развитие Cascadeur можно познакомиться по ссылке.



Скачать программу можно на официальном сайте проекта cascadeur.com/ru. Здесь же вы найдете все необходимые материалы для того, чтобы начать анимировать прямо сейчас.

Узнать о Cascadeur больше:

Вложенность нейросетей инструмента автопозинга в Cascadeur
Почему 12 принципов Диснея недостаточно
Cascadeur: будущее игровой анимации
Подробнее..

SwiftUI по полочкам Анимация, часть 2

16.06.2020 00:11:35 | Автор: admin
image

Это вторая часть повествования об анимации в SwiftUI. В первой статье мы на простых примерах разбирали основы, как вообще в SwiftUI работает анимация. В данной части пойдем дальше, и подробно разберем анонсированный пример с радужной анимацией.

image

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

Напомню, что идею реализовать такую анимацию я подсмотрел вот тут. Там авторы пошли по пути наименьшего сопротивления, и реализовали самый очевидный способ реализации этого концепта, анимировав shape. Принцип прост: каждая волна представляет собой контур очерченный кривой Безье, а параметр время влияет на положение правой границы этой волны.

Вот так выглядит анимация в готовом виде:

image

Для наглядности я немного разнесу волны по вертикали. Это несложно сделать, я просто добавил в конце NotchWave перемещение всего контура вниз на высоту волны, в соответствии с текущей фазой анимации:

return p.applying(.init(translationX: 0, y: height * self.phase))

Ну и еще .drowingGroup() надо не забыть отключить он не подразумевает выход изображения за границы фиксированного фрейма.



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

Если вы еще не очень хорошо представляете себе принципы работы анимации в SwiftUI, настоятельно рекомендую прочитать предыдущую статью. А желающих покопаться в исходниках я приветствую на гитхабе.

Работа с градиентами


В самом начале, я выбрал другой подход. Я посчитал, что намного полезнее разобраться с анимацией смещения объектов, чем с анимацией форм (я по-прежнему называю shape-структуру формой). Смещение может работать с чем угодно различными фигурами, изображениями, другими View. В качестве учебной задачи, я захотел реализовать сглаженный цветовой переход от одной волны к другой. В оригинальном концепте что-то такое вроде бы такое было.

Всего SwiftIUI предлагает три возможных использования градиента линейный, круговой и угловой.



В коде это выглядит каким-то блочным конструктором. Обратите внимание, как вся волна разбита на сегменты.



Линейный градиент характеризуется точкой начала и окончания градиента. Это не CGPoint точки с абсолютными координатами (x:y:), а UnitPoint точки, т.е. относительными координатами, где x:y: задаются в долях от ширины и высоты области, выделенной под данную View. Также есть предопределенные точки, соответствующие углам(.topLeading, .bottomTrailing и т.д.) и серединам сторон (.top, .trailing и т.д.).

Rectangle()    .fill(LinearGradient(         gradient: Gradient(stops: [             .init(color: self.end, location: 0),             .init(color: self.middle, location: 1 - self.middleGradientStop),             .init(color: self.start, location: 1)]),          startPoint: .leading,          endPoint: .trailing))    .frame(width: self.gradientLength)

Сегмент с линейным градиентом резиновый. Он имеет фиксированную ширину, но его высота не указана. Таким образом он заполнит весь предоставленный объем.

Чуть сложнее история с круговым градиентом. Мы указываем точку центр окружности, радиус начала градиента и радиус окончания градиента. Радиусы уже в абсолютном выражении, т.е. поинтах.
Центр окружности все еще задается в виде UnitPoint.

Rectangle()     .fill(RadialGradient(          gradient: Gradient(stops: [              .init(color: self.start, location: 0),              .init(color: self.middle, location: self.middleGradientStop),              .init(color: self.end, location: 1)]),          center: .bottomTrailing,          startRadius: self.topRadius,          endRadius: self.topRadius + self.gradientLength)                )    .frame(height: self.topRadius)

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

Сложнее всего применять угловой градиент. Он задается начальным и конечным углом, а так же направлением. Как можно видеть по листингу кода, работать с углами не очень удобно.
Приходится вспоминать школьный курс тригонометрии, чтобы вычислить требуемый угол, если тебе нужно начать угловой градиент в одной точке и закончить в другой.

       Rectangle()      .fill(AngularGradient(            gradient: Gradient(stops: [                  .init(color: self.end, location: 0),                  .init(color: self.middle, location: 1 - self.angularGradientMiddleStop(blockWidth: geometry.size.width)),                  .init(color: self.start, location: 1)]),            center: .bottomLeading,            startAngle: self.directionTo(gradientPart: self.start, blockWidth: geometry.size.width),            endAngle: Angle(degrees: 360)))...   func directionTo(gradientPart: Color, blockWidth: CGFloat) -> Angle{        let angleOf = gradientAngles(blockWidth: blockWidth)        var angle = Angle.zero        switch gradientPart{            case start: angle = angleOf.start            case middle: angle = angleOf.middle            case end: angle = angleOf.end            default: fatalError("there is no gradient stop with that color: \(gradientPart)")        }        return angle    }    func gradientAngles(blockWidth: CGFloat) -> (start: Angle, middle: Angle, end: Angle){        let blockHeight = self.bottomRadius        let center = CGPoint(x: 0, y: blockHeight)        let topRight = CGPoint(x: blockWidth, y: blockHeight - self.gradientLength)        let topGradientStarts = CGPoint(x: blockWidth, y: blockHeight - self.gradientLength * (1 - self.middleGradientStop))        let startAngle = center.radialDirection(to: topRight)        let middleAngle = center.radialDirection(to: topGradientStarts)        let endAngle = Angle(degrees: 360)        return (start: startAngle, middle: middleAngle, end: endAngle)    }...extension CGPoint{    func radialDirection(to point: CGPoint) -> Angle{        let deltaX =  point.x - self.x        let deltaY =  point.y - self.y        var angle = Angle(degrees: 0)        if deltaX == 0{            if deltaY > 0{                angle = Angle(degrees: 90)            }else{                angle = Angle(degrees: 270)            }        }else if deltaY == 0{            if deltaX > 0{                angle = Angle(degrees: 0)            }else{                angle = Angle(degrees: 180)            }        }else if deltaX > 0 && deltaY > 0{                angle = Angle(radians: atan(Double(deltaY / deltaX)))        }else if deltaX > 0 && deltaY < 0{                angle = Angle(degrees: 270) + Angle(radians: atan(Double(deltaX / -deltaY)))        }else if deltaX < 0 && deltaY > 0{                angle = Angle(degrees: 90) + Angle(radians: atan(Double(-deltaX / deltaY)))        }else if deltaX < 0 && deltaY < 0{                angle = Angle(degrees: 180) + Angle(radians: atan(Double(deltaY / deltaX)))        }        return angle    }}

Угловой градиент, представляющий собой хвост волны, тоже резиновый, но уже по ширине он занимает половину оставшегося размера. Собственно, поэтому столько тригонометрии. Я знаю положение конкретных точек на границе экрана, где должен быть определенный цвет (т.к. я склеиваю круговой градиент с угловым). При этом, значения углов зависят не только от радиусов нижнего градиента, но и от ширины самого блока с градиентом. Впервые со школы я брал в руки карандаш и вспоминал алгебру:)

Все три варианта используют единое описание цветов внутри градиента. Это простое перечисление цветов в массиве. Их распределение будет равномерным на заданном отрезке. Можно уточнить распределение, используя массив элементов Stop. Фактически, это то же перечисление цветов, только для каждого из них указывается еще и координата на единичном отрезке, где он будет расположен.

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

Color это не то чем кажется


Есть один момент связанный с Color. Для создания эффекта накатывающей волны, я решил сделать переход между цветами не совсем линейным. Если присмотреться, можно заметить, что у самой кромки градиент резче, а чем дальше от кромки, тем плавнее он перетекает в основной цвет. Я добился этого вычислением промежуточного цвета, и созданием градиента с таким распределением:
начальный цвет 0
промежуточный цвет 0.3
конечный цвет 1

Проблема возникла с вычислением среднего двух цветов. SwiftUI подразумевает использование объекта Color для градиентов, а я и повелся. На самом деле, если вы хотите работать с цветами именно как с RGB-объектами, закладывайте изначально в свою модель использование UIColor, потому что в Color нет доступа непосредственно к цвету. Обратно в UIColor его тоже так просто не конвертируешь. Единственное (не)адекватное решение, которое я нашел вот тут подразумевает получение Mirror reflection с разбором его строкового представления. Такой себе бойлерплейт, но других вариантов пока нет.

И это не ошибка, не упущение. Смысл в том, что SwiftUI в объекте Color не дублирует функционал UIColor. Если вам нужна работа с rgb каналами используйте именно его. Color в SwiftUI это View имеющая некоторый базовый цвет, конкретное значение которого может несколько изменяться в зависимости от расположения звезд на небе конкретный rgb цвет определяется только в момент отрисовки на экране. В документации сказано
SwiftUI only resolves it to a concrete value just before using it in a given environment.
, но что имеется в виду под environment: цвет стенки за спиной пользователя, или тема оформления IOS непонятно. Если вам это не подходит, используйте UIColor изначально.

Бесконечная анимация


Анимация указывается модификатором .animation() и запускается после инициализации View в модификаторе .onAppear() путем изменения @State переменной. В результате, модификатор .rotationEffect() подписывается на получение animatableData в промежутке от было к стало, согласованных с системным таймером. Мы говорили об этом в прошлой части.

struct AnimatedRectObservedObject: View{    @State var angle: Double = 0    var body: some View{       return VStack{            Spacer()            Rectangle()                .fill(Color.green)                .frame(width: 200, height: 200)                .rotationEffect(Angle(degrees: angle))                .animation(Animation.linear(duration: 1).repeatForever(autoreverses: false))            Spacer()       }       .onAppear{            self.angle += 90        }    }}



Запуская бесконечную анимацию таким образом, вы должны понимать, что любое последующее изменение анимированных модификаторов ее сломает. Например, если после запуска, вы по какой-то причине измените значение angle на 0, получите вот такой эффект:



Почему? Ответ в механизме анимации перехода от одной анимации к другой. В нашем примере, мы при первом появлении View на экране запустили анимированный переход от 0 к 90. View на самом деле хранит в себе только конечное значение 90, а исходное значение 0 вообще нигде не хранится. Механизм анимации знает текущее положение во времени анимации, и текущее значение AnimatableData. В точке времени 0.5 оно будет 45. Что произойдет, если в этот момент пользователь изменит значение на 0? Ответ: начнется анимация изменения значения с 45 до 0. Все так же зацикленная. Вот только визуально, цикл получается не замкнутым, а разорванным.

Кроме того, есть случаи, когда анимация так же ломается если в вашей View используется @ObservedObject, или иные параметры, вызывающие повторную отрисовку View. Для решения этой проблемы, у модификатора .animation есть параметр .animation(: value:). Передавая туда значение, мы указываем рендеру, что рестарт анимации нужен только при изменении этого значения.

Однако, давайте разбираться, как же остановить анимацию, если она нам больше не требуется. Для этого, нужно обеспечить две вещи. Очевидно, что мы должны сообщить SwiftUI что бесконечная анимация более не нужна, заменив ее обычной, конечной. Но этого недостаточно. Модификатор .animation() представляет собой инструкцию, какие тайминги нужно использовать, но само вращение описано внутри модификатора .rotationEffect(), и он уже подписан на получение новых значений угла поворота по таймеру. Для остановки вращения нам потребуется изменить еще и его значение.

struct AnimatedRectStopButton: View{    @State var angle: Double = 0    @State var animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)    var body: some View{        VStack{            Spacer()            Rectangle()                .fill(Color.green)                .frame(width: 200, height: 200)                .rotationEffect(Angle(degrees: angle))                .animation(animation)            Spacer()            Text("toggle Animation").onTapGesture {                if self.angle == 90{                    self.angle = 0                    self.animation = .default                }else{                    self.angle = 90                    self.animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)                }            }        }        .onAppear{            self.angle = 90        }    }}



Так же как ранее, изменение значения angle приводило к старту новой анимации (которая выглядела сломанной), это изменение тоже будет анимировано, но теперь уже с таймингами обычной анимации. Именно поэтому, наш квадрат прекращает вращаться не мгновенно, а как бы с замедлением.

То же самое можно написать чуть более лаконично, если подвесить весь функционал на одну @Stateпеременную типа вкл/выкл:

struct AnimatedRectStopButton: View{    @State var isStarted = false    let animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)    var body: some View{        VStack{            Spacer()            Rectangle()                .fill(Color.green)                .frame(width: 200, height: 200)                .rotationEffect(Angle(degrees: isStarted ? 90 : 0))                .animation(isStarted ? animation : .default)            Spacer()            Text("toggle Animation").onTapGesture {                self.isStarted.toggle()            }        }        .onAppear{            self.isStarted.toggle()        }    }}

До этого момента все было относительно легко. Но обратите внимание, что все события здесь генерируются внутри View. .onAppear{} вызывается системой, а onTapGesture{}, понятно пользователем. Однако, как быть, если вы хотите инкапсулировать всю анимацию внутри одной View, передавая в нее лишь вкл/выкл? SwiftUI не предполагает возможности из родительской view каких-то методов дочерних. Теоретически, вы можете хранить дочернюю View как структуру, и вызывать ее mutating-методы, но вот @State переменные дочерних View таким образом поменять не получится, я пробовал не работает. Единственный способ сделать что-то подобное, это воспользоваться PassthroughSubject из Combine, как это и сделали в упомянутой статье.

На самом деле все намного проще. Если четко уложить по полочкам в голове, что @State это внутреннее состояние View, и не пытаться манипулировать им извне, то правильное решение окажется очень простым:

struct AnimatedRectStopButtonFromOutside: View{    var isStarted: Bool    let animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)    var body: some View{        VStack{            Spacer()            Rectangle()                .fill(Color.green)                .frame(width: 200, height: 200)                .rotationEffect(Angle(degrees: isStarted ? 90 : 0))                .animation(isStarted ? animation : .default)            Spacer()        }    }}struct AnimatedRectParentView: View{    @State var isOn = false    var body: some View{        VStack{            AnimatedRectStopButtonFromOutside(isStarted: isOn)            Text("toggle Animation").onTapGesture {                self.isOn.toggle()            }        }.onAppear(){            self.isOn = true        }    }}

Нам не нужна в данном случае @State подписка на обновление View внутри дочерней view она и так обновляется целиком при изменении внешнего для нее параметра. Иногда это не удобно. Иногда мы не хотели бы лишний раз инициализировать дочернюю view например, чтобы не сломать анимацию, или в init() происходят какие-то сложные и ресурсоемкие вычисления (запросы например). В этих случаях лучше пользоваться объектными сущностями, за изменениями которых можно следить с помощью модификатора onReceive.

Modifying state during view update


Есть еще с одним момент, достойный освещения. Мы оперировали только двумя значениями 0 и 90 градусов. Выключение анимации приводило к сбросу угла на 0. Но можно ли поставить анимацию на паузу прямо в тот момент, когда мы нажали кнопку, и снова ее продолжить с того же места при возобновлении? Давайте рассмотрим код, позволяющий это:

struct AnimatedRect: View{    @State var startTime: Date = Date()    @State var angle: Double = 0    @State var internalStarted: Bool    let externalStarted = true    var animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)    init(started externalStarted: Bool){        self.externalStarted = externalStarted        self._internalStarted = State(initialValue: !externalStarted)//forse to start animation    }    var body: some View{        //thats wrong. It just hiding a problem from SwiftUI not solving it        DispatchQueue.main.async {            if self.internalStarted && self.externalStarted == false{                // print("stop animation")                 let timePassed = Date().timeIntervalSince(self.startTime)                 let fullSecondsPassed = Double(Int(timePassed))                 let currentStage = timePassed - fullSecondsPassed                 self.internalStarted = false                 let newAngle = self.angle - 90 + currentStage * 90                 self.angle = newAngle            }else if self.internalStarted == false && self.externalStarted {             //    print("start animation")                 self.startTime = Date()                 self.internalStarted = true                 self.angle += 90             }       }                return  VStack{            Rectangle()                .fill(Color.red)                .frame(width: 200, height: 200)                .rotationEffect(Angle(degrees: angle))                .animation(internalStarted ? animation : .default)            Spacer()        }    }}



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

Для работы такого подхода, нам нужно отследить момент старта и окончания анимации. Init() не подойдет. Мы не можем обращаться к уже существующим @State параметрам для получения предыдущего времени старта анимации. Это фишка @State переменных. Внутри init вы можете установить лишь начальное состояние этой переменной, но по окончании инициализации, если View уже существовала до init(), значение @State переменных будет восстановлено.

Поэтому тут реализована параметр let externalStarted (которым мы управляем извне), и внутренний параметр @State var internalStarted, с помощью которого мы управляем непосредственно анимацией.

Не хватало только одного какого-то модификатора, который бы проверял их соответствие и обновлял при необходимости, наподобие .onRecieve(), только чтобы отрабатывал при каждой отрисовке. И тут я подумал ведь body и так вызывается для каждой отрисовке, почему бы прямо в нем не делать эту проверку?

Оказалось, что SwiftUI очень ругается, если в процессе отрисовки View менять значение @State переменных, выдает
Modifying state during view update, this will cause undefined behavior.
и блокирует такое изменение. Тогда я пошел на грязный хак, и использовал DispatchQueue.main.async. Но давайте разберемся, почему же это грязный хак, и почему так делать не следует никогда?

На самом деле, проблема вот в чем. Если мы напишем внутри body какую-то очевидную глупость вроде i += 1, где i это какая-то @State переменная, то мы получим бесконечный цикл. В момент рендера мы делаем View в памяти не актуальной ведь мы изменили исходные данные для отрисовки. Значит, сразу по окончании отрисовки наша View попадет в очередь на повторный рендер, но и тогда мы тут же снова сделаем ее неактуальной. Мы своими руками создаем бесконечный цикл. Асинхронный вызов в данном случае вообще ничего не меняет. Он лишь немного сдвигает инициирование очередного витка на момент сразу после рендера. Таким образом, асинхронный вызов не решает проблему, а лишь маскирует ее, не давая SwiftUI ткнуть нас в нее носом. Это как в автомобиле лампочку check engine на приборке обрывать.

С одной стороны, да что тут такого? В любом языке программирования есть куча способов выстрелить себе в ногу, если ты не понимаешь что делаешь. Но с другой стороны, этот подход вообще не то, как должен работать SwiftUI. Работа с View в рантайме плохо. Да, вы можете бить себя пяткой в грудь, гарантируя что вы всегда пишете правильный код, который никогда не попадет в бесконечный цикл Но это как с не безопасным извлечением опционала это просто плохая практика.

Как я уже говорил, в таких случаях следует использовать Combine, передавая новое значение с помощью PassthroughSubject. Но можно и обойтись обычным ObservableObject, подписавшись на его willChange событие.

struct AnimatedRectObservedObject: View{    @State var startTime: Date = Date()    @State var angle: Double = 0    @ObservedObject var animationHandler: AnimationHandlerTest    let animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)    init(animationHandler: AnimationHandlerTest){        self.animationHandler = animationHandler    }    var body: some View{        VStack{            Rectangle()                .fill(Color.green)                .frame(width: 200, height: 200)                .rotationEffect(Angle(degrees: angle))                .animation(animationHandler.isStarted ? animation : .default)       }.onReceive(animationHandler.objectWillChange){            let newValue = self.animationHandler.isStarted            if newValue == false{                 let timePassed = Date().timeIntervalSince(self.startTime)                 let fullSecondsPassed = Double(Int(timePassed))                 let currentStage = timePassed - fullSecondsPassed                 let newAngle = self.angle - 90 + currentStage * 90                withAnimation(.none){//not working:(((                 self.angle = newAngle                }            }else {                 self.startTime = Date()                 self.angle += 90             }        }       .onAppear{            self.angle += 90            self.startTime = Date()        }    }}

Спорный момент здесь это использование непосредственно значений самого объекта в подписке на уведомление, что объект только будет изменен. Пока, в моих примерах, я имею уже измененный объект. Видимо, уведомления приходят с некоторым лагом. Но не могу гарантировать что так будет всегда, потому советую либо использовать самописные didChange уведомления, либо PassthroughSubject, передавая новое значение через него.



Анимация изменения анимации


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

.animation(animationHandler.isStarted ? animation : .default)

Это ведет к необычному поведению. Несмотря на относительную простоту всего того, что я описываю, под капотом остается куча нюансов по взаимодействию, чтобы картинка выглядела плавно всегда, даже если безалаберный программист чего-то не указал. Поэтому дефолтная анимация это spring. Она больше всего подходит под имитацию законов физики. Причем это довольно короткая анимация, порядка 0.2 секунды. Если анимация перехода в новое состояние (вычисленный и указанный нами угло) не успевает красиво уложиться в дефолтные 0.2 секунды так, чтобы перейти от текущей скорости анимации с красивым замедлением до полной остановки, SwiftUI сама добавит еще один цикл бесконечной анимации, проигранный в ускоренном варианте.



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

                .animation(animationHandler.isStarted ? animation : Animation.linear(duration: 0))

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

Движение нескольких волн одной анимацией


Вернемся к радужной анимации. Итак, у нас есть сами волны, давайте научим их двигаться. Первое что я хотел бы сделать это запустить бесконечную анимацию. Я решил сделать следующим образом: у меня будет одна @State переменная, отвечающая за текущее положение анимации. При состоянии 0 первая волна будет в самом начале, а последняя в самом конце. Сами волны будут накладываться друг на друга. Таким образом, длина видимой части каждой волны будет зависеть от количества волн. Я реализовал это с помощью ZStack, в котором перечислены все волны, и собственным модификатором .wavePosition, внутри которого я вычисляю текущее положение каждой волны в данный момент анимации, и порядок их наложения друг на друга.

много кода
struct SharpWavePosition: AnimatableModifier {    let wave: WaveDescription    let animationHandler: AnimationHandler    var time: CGFloat    var currentPosition: CGFloat    public var animatableData: CGFloat {        get { time}        set {                self.time = newValue            let currentTime = newValue - CGFloat(Int(newValue))            self.currentPosition = SharpWavePosition.calculate(forWave: wave.ind, ofWaves: wave.totalWavesCount, overTime: currentTime)        }    }    init(wave: WaveDescription, time: CGFloat, animationHandler: AnimationHandler){        self.wave = wave        self.time = time        self.animationHandler = animationHandler        self.currentPosition = 0    }        static func calculate(forWave: Int, ofWaves: Int, overTime: CGFloat) -> CGFloat{        let time = overTime - CGFloat(Int(overTime))        let oneWaveWidth = CGFloat(1) / CGFloat(ofWaves)        let initialPosition = oneWaveWidth * CGFloat(forWave)        let currentPosition = initialPosition + time        let fullRounds = Int(currentPosition)        var result = currentPosition - CGFloat(fullRounds)        if fullRounds > 0 && result == 0{            // at the end of the round it should be 1, not 0            result = 1        } //       print("wave \(forWave) in time \(overTime) was at position \(result)")        return result    }    func body(content: Content) -> some View {        let oneWaveWidth = CGFloat(1) / CGFloat(wave.totalWavesCount)        var thisIsFirstWave = false        if currentPosition < oneWaveWidth{            thisIsFirstWave = true        }        return            Group{                content                            .offset(x: -wave.width + currentPosition * (wave.width +  wave.gradientLength),                            //to watch how waves move uncoment this                           // y: CGFloat(self.waveInd * 20))                        y:0)                    .zIndex(-Double(currentPosition))                    .transition(.identity)                    .animation(nil)                if thisIsFirstWave{                    content                        .offset(x: wave.gradientLength, y: 0)                        .zIndex(-2)                        .transition(.identity)                        .animation(nil)                }            }    }}extension View{    func positionOfSharp(wave: WaveDescription, inTime: CGFloat, animationHandler: AnimationHandler) -> some View {        return self.modifier(SharpWavePosition(wave: wave, time: inTime, animationHandler: animationHandler))    }}struct SharpRainbowView: View{    let waves: [SharpGradientBorder]    var animation: Animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)    //@ObservedObject    var animationHandler: AnimationHandler    @State var rainbowPosition: CGFloat = 0    init(animationHandler: AnimationHandler,        backgroundColor: Color = .clear    ){        self.animationHandler = animationHandler        let bottomRadius = animationHandler.waveGeometry.bottomRadius        let topRadius = animationHandler.waveGeometry.topRadius        let gradientLength = animationHandler.waveGeometry.gradientLength        let rainbowColors = animationHandler.rainbowColors        guard var lastColor = rainbowColors.last else {fatalError("no colors to display in rainbow")}        var allWaves = [SharpGradientBorder]()        for color in rainbowColors{            let view = SharpGradientBorder(start: color,                                      end: lastColor,                                      bottomRadius: bottomRadius,                                      topRadius: topRadius,                                      gradientLength: gradientLength)            allWaves.append(view)            lastColor = color        }        self.waves = allWaves    }    var body: some View{        GeometryReader{geometry in            VStack{                ZStack{                    ForEach(self.waves.indices, id: \.self){ind in                        self.waves[ind]                            .positionOfSharp(wave: WaveDescription(ind: ind,                                                    totalWavesCount: self.waves.count,                                                    width: geometry.size.width,                                                    baseColor: self.waves[ind].end,                                                    gradientLength: self.waves[ind].bottomRadius + self.waves[ind].topRadius),                                             inTime: self.rainbowPosition,                                             animationHandler: self.animationHandler)                            .animation(self.animationHandler.isStarted ? self.animation : .linear(duration: 0))                    }                }     //           .clipped()            }        }        .onAppear(){            if self.animationHandler.isStarted{                self.rainbowPosition = 1            }        }        .onReceive(animationHandler.objectWillChange){             let newValue = self.animationHandler.isStarted             if newValue == false{                let newPosition = self.animationHandler.currentAnimationPosition                print("animated from \(self.rainbowPosition - 1) to \(self.rainbowPosition) stopped at \(newPosition)")                    self.rainbowPosition = newPosition             }else {                  self.rainbowPosition += 1            }        }    }} 


При первом отображении View на экране, мы меняем состояние с 0 на 1, что с учетом объявленной линейной анимации заставляет волны бесконечно бегать по экрану.



Когда край волны достигнет правого края, следующая ее позиция будет находится в самом начале, за краем экрана. Выглядеть это будет так, как будто из конца волны она просто исчезнет, обнажив фоновую подложку, чего мы хотели бы избежать. Именно для этой цели в модификаторе, в функции body() появился блок if{...}, в котором отрисовывается копия первой волны, пропавшей с экрана.



Обратите внимание, что мне пришлось передавать геометрические характеристики каждой волны в виде параметров модификатора. Проблема была в том, что мне требуется переопределять z-index каждой волны исходя из текущей фазы анимации. Я мог бы извлечь ширину видимой области, использовав внутри модификатора GeometryReader{}, однако столкнулся с тем, что он блокирует изменение порядка наложения волн. Модификатор .zIndex() работает только в контексте первого ZStack{} контейнера вверх по иерархии View.

GeometryReader обрубает эту связь, и zIndex() перестает работать. Если до этого момента вы думали, что GeometryReader это безобидный способ получить данные о размере текущей View это не совсем так.

Анимация потока


Еще один важный момент, это понимание анимации встроенных модификаторов внутри ваших собственных модификаторов. В какой-то момент, каждая волна подходит к краю экрана, получая максимальный offset по горизонтали, а затем переходит в стартовое положение, получая минимальный offset. Это такое же изменение как и любое другое, и оно анимируется, даже внутри анимации. Ведь анимация это просто очень частая передача значений в модификатор, и она тоже будет выполняться с анимацией, как бы странно это не звучало. Получается, что внутри анимации, передавая новое значение внутрь этого модификатора, мы лишь меняем конечную точку текущей анимации движения. Получается, что каждая волна, дойдя до края экрана, разворачивается и плывет обратно. Но до начала она тоже не доходит, а рано или поздно догоняет требуемую позицию, и тогда снова начинает двигаться слева направо.



Решить эту проблему можно вставив модификатор .animation(nil). Таким образом, я отключаю анимацию всех модификаторов, примененных выше по тексту. Это общая логика работы любого модификатора: он меняет то, что у него на входе, а на входе у нас результат работы всех предыдущих модификаторов.

Вообще говоря, здесь работает целая иерархия разных анимаций. Дефолтной считается та, которая будет использована для отображения изменений всех модификаторов, если только вы не указали иное для вашей View. У нас есть поток исполнения (допустим, это main), в рамках которого вызывается body. Будем считать, что у него есть параметр .animation, который проверяется каждый раз, когда какой-то модификатор получает новое значение. Если модификатор поддерживает анимацию (удовлетворяет протоколу AnimatableModifier), и для потока включена анимация (используется какая-то конкретная анимация, а не .none и не nil), то изменение будет анимировано. Именно это мы делаем, заключая какой-то код по изменению @State параметров в блок withAnimation{} прописываем определенную анимацию в текущем потоке, а затем выполняем изменение какой-то @State переменной. В этом случае withAnimation{}, это своего рода эквивалент транзакции, и все изменения выполненные в этой транзакции будут анимированы. В результате, внутри этого же потока запускается цикл трансляции этих изменений во все зависимые View, модификаторы этих View получают новое значение, и подписываются на получение промежуточных значений AnimatableData.

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

Таким образом, withAnimation() это способ запустить анимацию по-умолчанию для выполняемого действия. Но для определенных View вы можете в явном виде указать собственную анимацию с помощью модификатора .animation(). В этом случае, модификаторы и формы, которые составляют контент, передаваемый в .animation() (все что перечислено в коде до применения .animation()) получают описанный вами тайминг, и игнорируют анимацию потока.

В своем модификаторе WavePosition, я использую .animation(nil) для того чтобы избавиться от встроенной анимации offset, дав таким образом указание игнорировать текущую анимацию потока.

Та же история и с концевой заглушкой. Напомню выводы из прошлой статьи. Фактически, у нас в памяти N структур-модификаторов WavePosition, по одной на каждую волну. И все они получают новое значение position по системному таймеру, вычисляя положение каждой волны в данный момент времени. Это значит, что у нас так же N концевых заглушек под каждую волну. Просто в каждый момент времени показывается только один из них, благодаря блоку if{}. Однако, этот же блок подкладывает нам свинью. Исчезновение и появление View также выполняются с анимацией потока. Это значит, что задействуется модификатор .transition для анимации появления и исчезновения View после изменения условия. Обычно, по-умолчанию используется .opacity, однако у меня почему-то вместо этого использовался .slide. Ни то ни другое мне не подходит, потому я просто отменил эту анимацию, используя .transition(.identity).

upd. Пока я готовил статью, вышел XCode 11.4, в котором, похоже, transition по-умолчанию переработали. По крайней мере, сейчас я без проблем закомментировал .transition(.identity) и не получил той проблемы, из-за которой мне пришлось его добавлять.

Тайминги анимации


И вот мы наконец подошли к самому интересному вопросу: а как заставить волны двигаться ускоренно? Как обычно, я зайду издалека.

На самом деле Animation это описание скорости данной анимации. Маршрут из пункта А в пункт Б мы можем определить внутри самого модификатора, но SwiftUI сам решает, когда и какое именно значение AnimatableData передать в него. Делает он это с помощью тайминговой кривой. Предположим, пункт А мы возьмем за начало координат, а пункт Б отметим как точку с координатами (1; 1). По горизонтали мы будем отмечать прошедшее время (движение из пункта А в пункт Б занимает ровно 1 единицу времени). По вертикали пройденное расстояние в долях единицы. При использовании линейной анимации, мы получим прямую. Если же мы хотим получить движение хоть чуть-чуть похожее на настоящее, то вначале нам нужно потратить немного времени на разгон, а в конце на торможение. Вот тут можно поиграться с разными вариантами, рисуя свою кривую и сравнивая ее анимацию движения со стандартными.

В SwiftUI, для различных типов анимации используются кривые Безье. В любом случае она должна начинаться в (0;0) и заканчиваться в (1;1). Кривизна линии определяется контрольными точками.

Вложенная тайминговая кривая внутри линейной бесконечной анимации


Так вот, что нам нужно сделать, чтобы добиться ускоренного движения каждой волны (easeIn) внутри линейной зацикленной анимации?

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

Идея простая. Мы сделаем свой класс, в основе которого будет path кривая Bezier с помощью которой мы будем определять расстояние из пункта А в пункт Б, которое нужно показать в каждый определенный момент времени. На самом деле именно так и работает анимация в SwiftUI, да и в любом другом фреймворке с подобным функционалом. С помощью тайминговой кривой, реальные секунды и миллисекунды (которые чаще всего текут, все же, линейно) превращаются в доли расстояния(точнее, доли анимируемого отрезка вектора AnimatableData, который представляет собой выполненное изменение).

Вот моя первая реализация:

    func getActualPosition(of position: CGFloat) -> CGFloat{        let correctPosition = max(min(position, 1), 0) / duration        let trimmingCurve = TimingCurve.superEaseInPath        if correctPosition < 0.0000001{            let reversedCurve = Path(UIBezierPath(cgPath: trimmingCurve.cgPath).reversing().cgPath)            //trim to start point is impossible, so reverce the curve and get last point            guard let point = reversedCurve.currentPoint else{fatalError("cant get current timing curve start point")}            return point.y        }        guard let point = trimmingCurve.trimmedPath(from: 0, to: correctPosition).currentPoint else{fatalError("cant get current timing curve point at \(position)")}        return point.y * self.duration    }

Как видно, я немного схалтурил. По определению, я должен найти точку на кривой, соответствующую данному значению X, и вернуть ее Y. Мне не удалось найти какого-то стандартного встроенного метода для решения этой задачи, или популярного паттерна, потому я решил адаптировать для этих целей имеющийся метод trim(). Я решил что я вполне могу немного пересмотреть подход, и получить искомую точку с помощью получения части пути, соответствующей доли пройденного расстояния. Для нуля этот метод вернет 0, для 1 вернет 1, ну и в середине, наверное, все тоже будет примерно правильно.

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



Мало того, что точки находятся далеко от требуемой кривой, так и сам характер кривой искажается. Появляется лишний перегиб, что мне вообще не нужно. В итоге, мне все же пришлось придумывать относительно точный способ поиска точки на кривой Безье, зная только одну координату:

Мне пришлось хранить в памяти множество отрезков, концы которых лежат на заданной кривой. Поиск Y по данной X при таком подходе представляет собой поиск соответствующего отрезка, его деление пополам при необходимости, до тех пор, пока одна из границ такого отрезка не будет лежать достаточно близко к искомой точке. В этом случае, мы находим точку с требуемой координатой X, лежащую на данном отрезке, и возвращаем ее Y. Если вам известны более простые пути нахождения точки, лежащей на произвольной кривой Безье, по одной заданной координате напишите в комментариях. Думаю это может быть полезно многим.

В итоге, точки лежат довольно близко к графику:



Я сделал singlton объект для создания timing кривой по контрольным точкам и кэширования массива отрезков, с помощью которых происходит поиск Y. Таким образом, все что мне нужно для поиска конкретной точки на кривой найти отрезок, внутри которого будет лежать эта точка, поделить его попалам до тех пор, пока один из концов отрезка не будет достаточно близко к этой точке, для попадания в заданную погрешность, и затем, линейно интерполировать значение Y по заданной X, подменив часть кривой этим отрезком. Не утверждаю что это лучший способ, и что он будет работать во всех случаях. Опять же, пишите в комментариях, если знаете способы лучше.

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

        self.timing = TimingCurve.superEaseIn(duration: 1)let animatedPosition = timing.getY(onX: currentPosition)

Вот так в итоге выглядит анимация вместе с нашей тайминговой кривой:



За полным кодом добро пожаловать на гитхаб, смотреть файл TimingCurveView.

Разобравшись с этим примером, вы на 100% поймете как устроены тайминги анимации. Вы, кстати, сможете использовать это знание, ведь SwiftUI позволяет создавать свою тайминговую кривую по контрольным точкам с помощью функции timingCurve(), и использовать ее как любую другую анимацию.

Transition


Теперь, мы имеем сносную бесконечную анимацию. Осталось только придумать, как она должна включаться и выключаться.

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

   let animation = Animation.timingCurve(Double(TimingCurve.control.point1.x),                                Double(TimingCurve.control.point1.y),                                Double(TimingCurve.control.point2.x),                                Double(TimingCurve.control.point2.y),                                duration: 1)

Animation.timingCurve(x:y:) позволяет задать свой тайминг анимации на основе контрольных точек кривых Безье. На выходе мы получим полноценный объект Animation, как например привычный .linear(duration: 1), который можно использовать без ограничений. А учитывая, что я для анимации волн использую ровно те же контрольные точки, анимация будет синхронной.

можно было бы поудобнее организовать код...
Но я ленивая жопа.

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

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

Первая проблема возникла в тот момент, когда мне потребовалось получить текущее состояние анимации, чтобы подобрать цвет шторки. Для этого я создал ObservableObject, который передаю внутрь модификатора, и который я изменяю внутри сеттера AnimatableData. Здесь важно понимать, что изменение @Published свойства 60 раз в секунду это совсем не то что вам нужно. Это как изменять @State переменные внутри блока body. Нам не нужно инициировать что-либо при изменении состояния анимации. Но вот если нам потребуется, мы сможем узнать состояние анимации в любой момент.

  public var animatableData: CGFloat {        get { time}        set {            if animationHandler.isStarted{                self.time = newValue                if self.time != self.animationHandler.currentAnimationPosition{                    self.animationHandler.currentAnimationPosition = self.time                }            }            let currentTime = newValue - CGFloat(Int(newValue))            self.currentPosition = SharpWavePosition.calculate(forWave: wave.ind, ofWaves: wave.totalWavesCount, overTime: currentTime)            if currentPosition < 0.01{                animationHandler.currentWaveBaseColor = wave.baseColor            }        }

Именно поэтому я не подписывал SharpRainbowView на отслеживание изменений AnimationHendler:

   //@ObservedObject    var animationHandler: AnimationHandler

Для иллюстрации работы этих шторок, я включу отображение того, что происходит за границами View, закомментировав .clipped(). Кроме того, я сдвину шторки чуть выше анимации, для наглядности. Теперь стало понятнее, неправда ли?



Шторки должны были быть короткими, чтобы путь проходимый каждой волной и шторкой совпадал (иначе получится рассинхрон по анимации), но при этом, она успела полностью пропасть с экрана. При этом, шторка устроена как градиент из трех цветов. Это хорошо видно по замыкающей шторке. Это по причине длинных хвостов, которые мне так понравились в начале.

Все дело в том, что первая волна представляет собой градиент последнего и первого цвета переданного массива. Ни один из них может не совпадать с цветом фона. Поэтому, и возникла необходимость в шторках вообще. Сделать шторку двухцветной, от фонового цвета к базовому цвету первой волны, мне помешала длина шторки. Она просто не перекрывает длинный хвост первой волны, и получается граница одного цвета, а хвост другого. Поэтому мне и пришлось делать шторку аж трехцветной сначала от фона к цвету последней волны, а затем от цвета последней волны к первой. Тогда хвост первой волны хорошо стыкуется с границей шторки.

Примерно те же причины заставили сделать замыкающую шторку так же трехцветной. Иначе граница смотрелась неестественной. Но в результате, первая волна (а теперь мы знаем, что на самом деле это шторка, описанная в Transition.truncate) получилась короткой. И хоть она движется полностью синхронно с первой волной, т.е. ускоренно, а не линейно, ее ширина не меняется со временем, хотя ширина последующих волн визуально увеличивается. Это можно победить, динамически изменяя ширину шторки, но мне откровенно было уже лень. Давайте расценим это как домашнее задание для тех, кто хочет погрузиться в эти механизмы, и раскурить код (заранее прошу у таких людей прощения, там полно следов моих экспериментов, которые могут вас запутать).

Есть одна проблема. Если вы нажмете кнопку в неудачное время, шторка закрытия едва-едва прикроет очередную волну. Выглядит это не очень. Решить проблему можно путем введения небольшой задержки сокрытия View с анимацией. У нас есть способ узнать текущее положение анимации, нам нужно всего лишь немного отложить начало transition, и последняя волна всегда будет полностью показана.

    Text("toggle animation").onTapGesture {                withAnimation(){                    var delay: Double = 0                    if self.isShown{                        let waveChangeTime: Double = Double(1) /  Double(self.animationHandler.rainbowColors.count)                        let currentTime = Double(self.animationHandler.currentAnimationPosition)                        let wavesPassed = Double(Int(currentTime / waveChangeTime))                        delay =  (wavesPassed + 1) * waveChangeTime - currentTime                        delay = max(delay - 0.05, 0)                        print("currentTime: \(currentTime); delay \(delay)")                    }                    DispatchQueue.main.asyncAfter(deadline: .now() + delay) {                        self.isShown.toggle()                    }                }            }

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

Transition любит подкладывать свинью


Вообще, анимации появления и исчезновения, как по мне, очень прикольный инструмент. Но похоже, эта концепция нова, и нормально обрабатывается далеко не всеми инструментами.

Так, .rotation3DEffect() модификатор вполне успешно переворачивает View с какими-то сложными анимациями внутри, но вот .transition она переворачивать не умеет. Так обидно было написать лаконичный код, увидеть как твои волны зеркально разбегаются от центра экрана, а потом увидеть, что на левой View шторки так и бегают слева направо. Пришлось писать кучу бойлерплейта, дабы transition модификатор умел работать в обе стороны.



Еще, drowingGroup() модификатор, с помощью которого вы можете подключить Metal переложить на GPU отрисовку ZStack с большим количеством вложенных View, особенно View с градиентами, не умеет в transition. Он не понимает описанную вами анимацию появления и исчезновения и заменяет ее какой-то своей.

А как же мы прячем статус-бар?


Очень просто. В SwiftUI есть модификатор .statisBar(hidden:). Вот только api для управлением transition для статус-бара SwiftUI не предоставляет. Для этого нам придется воспользоваться возможностями UIKit. В файле SceneDelegate используется UIHostingController для превращения SwiftUI View в UIKit ViewController. Именно на этом этапе удобнее всего использовать какие-то глобальные функции UIKit, как то работа со статусбаром, или отключение системных жестов связанных с краем экрана (preferredScreenEdgesDeferringSystemGestures). Вы можете наследоваться от UIHostingController, переопределив значения каких-то системных свойств, и использовать этого наследника для передачи своей View в rootViewController. К сожалению, статусбар может принимать только ограниченное число transition: .fade, .slide и .none. По-умолчанию используется fade, и он сюда подходит лучше всего, так что оставим как есть. Будем надеяться, что этот функционал все же будут расширять.

Для того чтобы синхронизировать скрытие и появление статусбара с движением волны я создал отдельную View, внутри которой с паузой меняю значение этого параметра:

struct StatusBarHider: View{    var isShown: Bool    @State var internalIsShown = true    var body: some View{        if isShown == false && self.internalIsShown == true{            DispatchQueue.main.asyncAfter(deadline: .now() + 0.7){                self.internalIsShown = self.isShown            }        }else if isShown == true && self.internalIsShown == false{            DispatchQueue.main.async(){                self.internalIsShown = self.isShown            }        }        return Spacer()            .statusBar(hidden: internalIsShown)            .animation(Animation.linear(duration: 0.3))    }}

По сути, здесь формируется простой Spacer(), который мне и так был нужен, но на него сверху накручен модификатор statusBar. Вообще, конечно, со стороны Apple было нелогично выводить этот функционал в модификаторы, т.к. это не относится к конкретной View, которую ты модифицируешь, но, видимо, просто не придумал как сделать это лучше. Куда интереснее другое.

Здесь используется тот же грязный хак, что мы рассматривали ранее: внутри body я выполняю какие-то действия в рантайме, меняя при этом значение @State переменной. Логика в целом та же, есть параметр структуры, который передается извне, и @State переменная, которая модифицируется спустя какое-то время, вызывая исчезновение или появление статусбара.

В данном случае, этот хак мне понадобился потому, что управление анимацией статусбара это функционал UIKit, который еще довольно плохо проработан в SwiftUI. По идее, я бы прикрутил анимацию с отложенным стартом к модификатору .statusBar(hidden:), но это не работает. Анимация скрытия и появления статусбара фиксирована, и не подлежит изменению со стороны SwiftUI.

На самом деле, мне ничего не мешало выполнить то же самое изменение асинхронно с нужной задержкой не внутри этой View, а в родительской, еще при нажатии кнопки пользователем но оставим это как памятник моим попыткам разобраться что к чему (и еще одним поводом рассказать, что так делать не следует).

А как запихнули размеры и положение статусбара в @Environment?


@Environment (не путать с @EnvironmentObject) это обертка, дающая доступ к фиксированному перечню переменных, отражающих окружение нашего приложения. Например, ориентацию экрана, или цветовую тему ОС. Эта же обертка позволяет вашим view быть подписанными на изменение этих параметров.

Этот перечень можно расширить. Я посчитал, что иметь в @Environment доступ к размеру и положению статусбара это было бы правильно. Вот как я это сделал:

struct StatusBarFrame: EnvironmentKey {    static var defaultValue: CGRect {        CGRect()    }}extension EnvironmentValues {    var statusBarFrame: CGRect{        get {            return self[StatusBarFrame.self]        }        set {            self[StatusBarFrame.self] = newValue        }    }}

Я создал структуру со статическим default значением. Затем, я расширил системную структуру EnvironmentValues, добавив в нее свое вычислимое свойство, геттер и сеттер которого ссылаются на созданный мной тип. Здесь используется именно тип, поскольку в EnvironmentValues реализован сабскрипт, в который ты передаешь тип, и получаешь хранимое значение, соответствующее этому типу. Не значение этого типа, а значение, хранимое в условном словаре, где ключом является сам тип.

Доступ к значению в @Environment осуществляется с помощью keyPath \.statusBarFrame. Например, для для передачи environment-значения всем view вниз по иерархии:

.environment(\.statusBarFrame, statusBarFrame) 

И в самих View для извлечения значения из хранилища:

@Environment(\.statusBarFrame) var statusBarframe: CGRect 

Кстати, для работы со статусбаром в объекте UIWindowScene в IOS 13 появился реквизит statusBarManager. Из него можно вытянуть некоторые параметры. А вот управлять ими теперь нельзя. Насколько я понял, раньше можно было получить доступ к ViewController-у статусбара, и добавить в него subView. Видимо, лавочку прикрыли.

Вообще говоря, я бы перенес функционал модификатора .statusBar(hidden:) именно сюда, тут он был бы более уместен, как по мне. Думаю рано или поздно, у разработчиков дойдут до этого руки.

Заключение


Что же, думаю на этом увлекательном примере экскурс в Анимацию средствами SwiftUI можно считать завершенным. На простых примерах я рассказал об основных инструментах анимации, рассказал как именно они работают под капотом, как их следует использовать и почему именно так. На более сложных примерах я показал типичные трудности, с которыми вы можете столкнуться и пути их преодоления.

Получился ли мой код, по сравнению с примером из упомянутой статьи, чище, понятнее, короче или может быть, его легче поддерживать? Пожалуй что нет. Потребляет ли он меньше ресурсов? Увы, тоже нет. Возможно, мой подход более универсален, но это не точно. Однако есть одна задача, которую он точно выполняет лучше. Он намного глубже иллюстрирует работу SwiftUI под капотом. С его помощью, вы сможете разложить анимацию по полочкам в своей голове и свободно применять в ваших приложениях.

SwiftUI активно пилится. Даже сейчас, две недели спустя написания кода к первой статье, сейчас, проверяя работу кода на текущей версии XCode, я вижу как некоторые баги, которые я вынужден был обходить, уже пофиксили. Поэтому я со статьей немного задержался. Кое что пришлось вырезать за неактуальностью, а все остальное перепроверять. И это радует. SwiftUI это определенно будущее нативной IOS разработки, и это очень познавательно, стоять у его истоков, наблюдать как он развивается.

Послесловие


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

Перевод Полируем UI в Android StateListAnimator

08.09.2020 18:17:15 | Автор: admin
Привет, хабр! В преддверии старта курса Android Developer. Professional мы подготовили для вас перевод еще одного интересного материала.




Большую часть времени разработки нашего Android-приложения мы тратим отнюдь не на работу над пользовательским интерфейсом мы просто накидываем вьюх и начинаем писать код. Я заметил, что большинство из нас не особо заботится о пользовательском интерфейсе. И я считаю, что это в корне неправильно. Разработчики мобильных приложений должны заботиться также и о UI/UX. Я не говорю будьте экспертом в мобильном UI, но вы должны понимать язык дизайна и его концепции.

Ранее я написал статью о тенях в материальном дизайне и получил много хороших отзывов. Я хочу поблагодарить всех вас. Освоение теней в Android рассказывает о высоте (elevation) и тени (shadow) в Android. Там же я показал, как дополнял ими свою UI библиотеку с открытым исходным кодом. (Scaling Layout).

В этой статье я хочу усовершенствовать свою библиотеку с помощью StateListAnimator и шаг за шагом показать вам, как я это буду делать.

Содержание


В этой статье рассматриваются следующие темы:


Состояния Drawable


В Android есть 17 различных состояний для Drawable.



Возможно, мы даже никогда не встречали некоторые из них. Я не собираюсь углубляться в каждое состояние. В большинстве случаев мы используем pressed, enabled, windows focused, checked и т. д. Если мы не объявляем состояние для drawable, то подразумевается, что это состояние по умолчанию в Android.

Нам нужно понимать эти состояния, чтобы написать наш собственный StateListDrawable.

StateListDrawable


Это, по сути, список drawable элементов, где каждый элемент имеет свое собственное состояние. Для создания StateListDrawable нам нужно создать XML-файл в папке res/drawable.

<item android:drawable="@drawable/i" android:state_pressed="true"/>


Это элемент (item). Он имеет два свойства. Drawable и State.

<selector>   <item       android:drawable="@drawable/p"       android:state_pressed="true"/>   <item       android:drawable="@drawable/default"/></selector>


Это StateListDrawable. Если мы не объявляем состояние (state) для элемента, как я уже упоминал ранее, это означает, что это состояние по умолчанию.

Могу ли я использовать ShapeDrawable?


Да. Вместо использования android:drawable вы можете добавить к своему элементу произвольную форму. Вот элемент с ShapeDrawable.


StateListDrawable

Вы можете использовать StateListDrawable с уровня API 1. Таким образом, для StateListDrawable нет ограничений уровня API.

<View   android:layout_width="50dp"   android:layout_height="50dp"   android:foreground="@drawable/state_list_drawable"   android:clickable="true"/>


Вот и все. Теперь у нашего вью есть состояние. Когда пользователь нажимает на него, его цвет будет изменен. Когда пользователь отпустит его, он будет иметь состояние и цвет по умолчанию.

Но погодите секундочку. Clickable? Зачем мы добавили этот атрибут? Нам что, еще и его добавлять нужно? Да. Но только для пользовательских вью. Чтобы это выяснить, нужно время. Кнопки отлично работают без добавления clickable, потому что они по умолчанию clickable. Но если вы хотите использовать StateListDrawable для View, ImageView, Custom View и т. д., вам необходимо добавить атрибут clickable.



StateListDrawable

Я добавил StateListDrawable в этом коммите. Он похож на пример, который я привел выше. Когда пользователь нажимает на лейаут, он окрашивается. Но давайте улучшим это с помощью StateListAnimator.

StateListAnimator


Помните, что когда вы нажимаете FloatingActionButton, его Z значение увеличивается из-за анимации. Это StateListAnimator так сказать за кадром. У некоторых виджетов материального дизайна есть собственный StateListAnimator внутри.

Давайте проясним это с помощью вопроса на StackOverflow.



(Как удалить границу/тень с кнопок lollipop).

Если у виджетов материального дизайна есть собственный StateListAnimator внутри, мы можем установить для них значение null, чтобы удалить эти функцию (не рекомендую, она разработана не просто так.) И сейчас ответ звучит куда более логично.


(Lollipop имеет небольшую неприятную функцию, называемую stateListAnimator, которая обрабатывает высоту кнопок, производя тени.

Удалите stateListAnimator, чтобы избавиться от теней.

У вас есть несколько вариантов как сделать это:

В коде:

button.setStateListAnimator(null);)




Итак, а как мы можем создать его?

Чтобы понять StateListAnimator, нам нужно понять анимацию свойств объекта (property animation). Я не собираюсь углубляться в анимацию свойств в этой статье. Но по крайней мере, я хочу показать вам основы.

Анимация свойств


Вот самый простой пример свойства в объекте. X это свойство.

class MyObject{    private int x;    public int getX() {       return x;   }    public void setX(int x) {       this.x = x;   }}


Система property animation это надежный фреймворк, который позволяет анимировать практически все. Вы можете определить анимацию для изменения любого свойства объекта с течением времени, независимо от того, отображается ли он на экране или нет. Анимация свойства изменяет значение свойства (поля в объекте) в течение заданного периода времени.



X это свойство. Т время. Во время анимации свойство X обновляется в заданное время. В целом так работает анимация свойств. Вместо коробки может быть вью или любой объект.

ValueAnimator это базовый класс для анимации свойств. Вы можете настроить слушатель на обновления ValueAnimator и наблюдать за изменениями свойств.

ObjectAnimator это класс, который является наследником ValueAnimator. Вы можете использовать ObjectAnimator, если для вас больше подходит следующие:

  • У вас есть объект (любой класс с каким-нибудь свойством).
  • Вы не хотите наблюдать за слушателем ValueAnimator.
  • Вы хотите обновлять свойство объекта автоматически.


Итак, если у нас есть вью (которое является объектом), и мы хотим обновить свойство вью (координата x, координата y, rotation, translation или любое другое свойство, для которого у вью есть геттер/сеттер), мы можем использовать ObjectAnimator. Продолжим создание StateListAnimator.

<selector>    <item android:state_pressed="true">      <objectAnimator           android:duration="200"           android:propertyName="translationZ"           android:valueTo="6dp"           android:valueType="floatType" />   </item>    <item>      <objectAnimator           android:duration="200"           android:propertyName="translationZ"           android:valueTo="0dp"           android:valueType="floatType"/>   </item> </selector>



Кнопка FAB анимирует свое свойство translationZ при нажатии и разжатии.

Как я сказал ранее, мы можем использовать свойство объекта напрямую, не наблюдая за изменениями в аниматоре. Каждый View имеет свойство translationZ. Таким образом, мы можем напрямую анимировать translationZ с помощью ObjectAnimator.

Мы можем также объединить несколько <objectAnimator>-ов в <set>. Изменим еще одно свойство View. Scale X и Scale Y.

Вот результат! Теперь она также увеличивается при нажатии пользователем. А вот коммит.



Вы также можете определить другие свойства в своем animator.xml. Здесь вы можете найти больше информации об использовании ObjectAnimator.

Вот и все. Я планирую написать еще что-нибудь о ValueAnimator и ObjectAnimator. Это отличное API для анимации объекта.

Успешного вам кодинга!
Подробнее..

Вложенность нейросетей инструмента автопозинга в Cascadeur

17.06.2020 16:18:48 | Автор: admin

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

В этой статье будет рассмотрен подход, позволивший нам реализовать достаточно продвинутый функционал, используя лишь стандартные deep learning методы.

Постановка задачи


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



Использование полносвязных нейросетей предполагает фиксированные вход и выход, поэтому мы сделали несколько нейросетей с разным количеством входных точек: 6, 15, 20, 28 точек из всех 43 точек персонажа. На картинках ниже в зеленый окрашены те точки, которые подаются на вход нейросети соответствующего уровня детализации.


В чем же проблема использования уровней детализации? Если мы хотим подвинуть точку из 4-го уровня, то нам нужно подать на вход все 28 точек. Но мы не хотим заставлять пользователя ставить их все. Наша цель дать ему возможность подвинуть только несколько из них. Как в таком случае добиться хорошего результата? Наше решение предполагает вложенность входных данных, комбинирование результатов и использование физической модели.

Вложенность входных данных


Мы выбрали такие уровни детализации, которые имеют особое свойство иерархической вложенности.


Множество входных точек нейросети каждого уровня содержит в себе все точки с предыдущего уровня и добавляет к ним несколько новых. Это позволяет нам использовать выходные данные с одной сети как входные для следующей.

Комбинирование результатов


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


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


Сперва используется сеть первого уровня она выставляет все 43 точки персонажа по 6 основным. Затем по очереди вызываются сети более детальных уровней. Каждая последующая принимает на вход все более детальные входные данные либо уточненные пользователем, либо из результата работы предыдущего уровня. Таким образом мы получаем возможность использовать нескольких нейросетей с разной детализацией одновременно.

Физическая корректность


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


Этот процесс вызывается после работы каждого из уровней, чтобы не допустить ситуации, когда на вход нейросети подаются точки из некорректной позы.

Заключение и планы


Итак, созданный нами инструмент доказал свою пользу на практике. Он помогает нам при создании анимации уже на самом первом этапе, когда необходимо видеть приблизительные позы. В будущем мы планируем добавить поддержку пользовательских гуманоидных скелетов, а также сделать инструмент более точным и устойчивым.

Также мы исследуем возможности, которые дарят нам более универсальные deep learning подходы. Например, уже сегодня можно восстанавливать части фотографий с заданными характеристиками, а также переносить стиль и другие характеристики между изображениями. В будущем мы могли бы использовать этот метод и при создании анимации, например, чтобы добавить в нее или же в позу желаемые характеристики.

Мы продолжаем развивать наш инструмент автопозинга. Уже в ближайшее время Cascadeur войдет в стадию открытого бета-теста. Обязательно следите за новостями на cascadeur.com и в социальных сетях проекта.

Узнать больше о Cascadeur и других проектах студии Banzai Games:

Почему 12 принципов Диснея недостаточно
Cascadeur: задача о падающей кошке
Физика в Unity-проекте на примере мобильного файтинга
Cascadeur: будущее игровой анимации
Искусственный интеллект в файтинге Shadow Fight 3

В команду Banzai Games требуется Qt GUI программист. Подробнее о вакансии можно прочитать здесь.
Подробнее..

Стартовал открытый бета-тест Cascadeur

28.07.2020 18:07:12 | Автор: admin


Со времени первого анонса Cascadeur в начале 2019 года уже более 18 000 пользователей приняли участие в закрытом бета-тестировании программы. У аниматоров, занимающихся разработкой игр и созданием фильмов, было более 12 месяцев, чтобы испытать в работе наш инструментарий.

Сегодня мы рады сообщить о запуске открытого бета-теста (ОБТ) своей анимационной программы для физически корректной анимации персонажей. Благодаря ОБТ протестировать и оценить ее возможности сможет более широкий круг пользователей.

Подход Cascadeur к анимации значительно облегчает достижение точной механики тела. Теперь я уверен, что инструменты для физически корректной анимации станут ожидаемым стандартом в индустрии, поделился мнением один из первых пользователей Cascadeur, анимационный директор Polyarc Ричард Лико, ранее работавший над Destiny 2.

Опрос, проведенный издателем Nekki в апреле 2020 года, показал, что 85% бета-пользователей Cascadeur считают его инструментом, который будет играть важную роль в их будущих проектах. В январе 2020 года Nekki и Cascadeur были номинированы на премию Pocket Gamer Mobile Games в номинациях Best Innovation и Best Tool Provider, что является редким достижением для еще неизданного продукта.





Над открытой бета-версией Cascadeur мы работали в течение года. Большинство изменений могут быть невидимыми на первый взгляд, но внутри программы был сделан глобальный редизайн всей архитектуры. Основные особенности новой версии включают:

  • Новую архитектуру, которая делает Cascadeur намного быстрее и эффективнее

  • Улучшения рига, такие как способность перетаскивать или вращать центр масс без его фиксации и улучшенная интерполяция

  • Доработку инструментов создания рига

Новая архитектура Cascadeur значительно ускорит процесс разработки и оптимизации программы. Следующие шаги будут включать в себя:

  • Дальнейшее улучшение риггинга, с возможностью создания кастомных ригов

  • Поддержка Python для автоматизации процессов

  • Бета-версия инструмента Graph Editor






Чтобы сделать использование Cascadeur привлекательным для профессиональных аниматоров, Nekki разрешает бесплатное коммерческое использование бета-версии. Любая анимация, созданная в ОБТ-версии Cascadeur, может быть бесплатно использована в играх и фильмах без предварительного разрешения Nekki.

Мы хотим наглядно продемонстрировать вам основные особенности и инструменты Cascadeur в новом 5-минутном видео:



Получить более подробную информацию и скачать ОБТ-версию Cascadeur вы можете на cascadeur.com

Узнать о Cascadeur больше:

Вложенность нейросетей инструмента автопозинга в Cascadeur
Почему 12 принципов Диснея недостаточно
Cascadeur: задача о падающей кошке
Cascadeur: будущее игровой анимации
Подробнее..

Наши попытки процедурной анимации движения персонажа

20.01.2021 12:12:03 | Автор: admin

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

Возникла необходимость сделать анимацию персонажа для игры, которую мы делаем на коленке". Чем нам не угодили mocap - анимации и анимации с mixamo.com:

  • нужно искать наиболее подходящие анимации

  • нужно много анимаций

  • визуально анимации должны сочетаться

  • очень трудно сделать что-то качественное из разношерстного набора анимационных сэмплов

Побираясь, как бездомный в чьем-то ведре, в поисках нужных анимаций в течении недели, мне удалось собрать некого Франкенштейна. Именно Франкенштейна потому, что анимаций надыбал отовсюду. Ходил персонаж как офисный работник, крался как эльф 80-го уровня, приседал как человек-паук. Шучу, все было не так уж и ужасно, конечно, для обывателя может и пойдет, а вот меня все же неустраивало разношерстность анимаций. Хотя блендинг и прочие процедурные фишки сильно улучшали дело... Да и ноги не прилипали к земле как надо. Меня это жутко раздражает, когда анимация персонажа не на 100% соответствует тому, что он делает, ноги проходят сквозь пол, руки сквозь стены... ну вы поняли, 21-й век как никак.

Что важно для анимации персонажа: нужно передать ощущение того как персонаж передвигается с учетом физики. Короче, анимация нужна процедурная. Это крайне важно для нашего 3D пазла от первого лица.

Итак поехали. Обобщенно задача состоит в разработке системы анимации персонажа таким образом, чтобы можно было относительно гибко настраивать анимацию под свои нужды (корче, еще раз, чтобы анимация была процедурной). Для реализации этого, нужно знать что есть инверсная кинематика и уметь ее реализовывать. Погуглив и ознакомившись собрал простейшую IK, подключил пару костей. Работает. Добавили ограничения, чтобы локти не выворачивались в другие стороны и чтобы кости были похожи на кости. Сделано.

Далее, настало время для машинного обучения. Подключаем обучалку к ногам и обучаем ноги ходить, а руки крутиться. На Github был очень хороший пример с какими-то бегающими человечками с разным количеством ног. Этот проект лег в основу. Далее не помню как закончил этот адский ад.

В итоге после 2-х недель возни по 12 часов со всей этой ерундой у меня на столе лежал набор как-то дергающихся тестовых конечностей. Кстати, некоторые видео сохранил. Тогда я не думал что решу писать об этом пост:

Отлично, цепляем все к телу, еще немного IK и получаем что-то типа (одно из приближений):

Обратите внимание на лодыжку. C ней вечно были проблемы. Дело в том, что наш робот сделан инженерами-конструкторами и соединения должны гнуться строго по оси и никак иначе. Хотя это далеко не последний вариант робота, но в целом все примерно так. (Спойлер: соединение ложыжки мы все же переделали. Инженеры поставили его на 3 гидропривода, что дало нужное число степеней свободы).

Далее используем наш ИИ для ноги. Не зря же нога у нас обучалась ходить сама по себе без тела XD Подключаем ИИ к ногам и говорим им болтаться:

А теперь ногам приказываем ходить. Здесь нет отклика от пола, ноги не воспринимают коллайдер пола. Иначе роботу на самом деле пришлось бы пойти.

Ну вот все что сделано выглядит вроде и ничего, по отдельности. Когда я соединил все в одно тело, то понял: емое Позиция ноги, руки, головы (короче, каждой части тела) должна вносить изменение в позицию всего тела и следовательно влиять на другие конечности. Иначе робот ходил как деревянный буратино. Ничего не поделаешь, видать именно так ходят "идеальные" машины лишенные уникальности (уровень робота "Вертер" достигнут!):

Повозившись еще с инверсной кинематикой и мозгами всех частей тела, все же удалось сделать анимацию более естественной.

Здесь экспериментирую с тем как робота придавливает плита. Робот должен корректно анимировать позицию своего тела:

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

Что хотелось бы добавить. Полностью избежать использования mocap анимации не удалось. Почему так? Дело в том, что роботу нужна индивидуальность, стиль перемещения. именно для этого ему даются наборы анимаций с которых он перенимает пластику движения и использует ее при расчете процедурной анимации перемещения. Как-то так.В тестах использовалась модель-аналог робота Федора. Извините, это неточная копия. Чертежей не было, собрали "на глаз" :D

Ссылки на наши некоторые наработки по игре (в виде скетчей):

Twitter

Instagram

VK

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru