Всем привет! Мне очень нравится работать с анимациями — в каждом Android-приложении, в создании которого я участвую или на которое просто смотрю, я нашёл бы место парочке. В не таком ещё далёком апреле 2016 года с моей записи про тип классов Animation начал жить блог компании Лайв Тайпинг, а позже я выступил с докладом об анимациях на очередном омском IT-субботнике. В этой статье я хочу познакомить вас с нашей библиотекой CannyViewAnimator, а также погрузить вас в процесс её разработки. Она нужна для красивого переключения видимости View. Если вам интересна библиотека, или история её создания, ну или хотя бы интересны проблемы, с которыми я столкнулся, и их решения, то добро пожаловать в статью! Но сначала представим для наглядности ситуацию, банальную в Android-разработке. У вас есть экран, а на нём — список, который приходит от сервера. Пока прекрасные данные грузятся от прекрасного сервера, вы показываете лоадер; как только данные пришли, вы в них смотрите: если пусто — показываете заглушку, если нет — показываете, собственно, данные.
Как разрешить эту ситуацию на UI? Раньше, мы в Лайв Тайпинг пользовались следующим решением, которое когда-то подсмотрели в U2020, а затем перенесли в наш U2020 MVP — это BetterViewAnimator, View, который наследуется от ViewAnimator. Единственное, но важное отличие BetterViewAnimator от его предка — это умение работать с id ресурсов. Но он не идеален. ViewAnimator — это View, который наследуется от FrameLayout и у которого в конкретный момент времени виден только один из его child. Для переключения видимого child есть набор методов. Важным минусом BetterViewAnimator является умение работать только с устаревшим AnimationFramework. И в этой ситуации приходит на помощь CannyViewAnimator. Он поддерживает работу с Animator и AppCompat Transition.
Ссылка на проект в Github Во время разработки очередного экрана «список-лоадер-заглушка» я задумался о том, что мы, конечно, используем BetterViewAnimator, но почему-то не пользуемся его чуть ли не основной фишкой — анимациями. Настроенный оптимистично, я решил добавить анимацию и наткнулся на то, о чем позабыл: ViewAnimator может работать только с Animation. Поиски альтернативы на Github, к сожалению, не увенчались успехом — достойных не было, а был только Android View Controller, но он абсолютно не гибок и поддерживает только восемь заранее заданных в нём анимаций. Это означало только одно: придётся писать всё самому.  Первое, что я решил сделать — это продумать то, что я в итоге хочу получить: Определившись с желаниями, я начал продумывать «архитектуру» будущего проекта. Получилось три части: Выставление Animator'ов и Transition я решил сделать с помощью интерфейса с двумя параметрами: child, который будет появляться, и child, который будет исчезать. Каждый раз, когда сменяется видимый child, из реализации интерфейса будет забираться необходимая анимация. Интерфейса будет три: Интерфейс для Transition я решил сделать один, так как Transition накладывается сразу на все появляющиеся и исчезающие child. Концепция была продумана, и я приступил к разработке. Со своим базовым классом я не стал особо мудрить и решил сделать копирку с ViewAnimator из SDK. Я лишь выбросил из него работу с Animation и оптимизировал методы в нём, так как многие из них мне показались избыточными. Также я не забыл добавить и методы из BetterViewAnimator. Итоговый список важных для работы с ним методов получился таким: Немного подумав, я решил дополнительно сохранять позицию текущего видимого child в onSaveInstanceState() и восстанавливать её onRestoreInstanceState(Parcelable state), тут же отображая его.
Итоговый код получился таким: Ссылочка на Github Закончив с ViewAnimator, я приступил к довольно простой, но от этого не менее интересной задаче: сделать поддержку Transition. Суть работы такова: при вызове переопределённого метода changeVisibility (View inChild, View outChild) подготавливается анимация. Из заданного CannyTransition с помощью интерфейса забирается Transition и записывается в поле класса.  Затем в отдельном методе выполняется запуск этого Transition. Я решил сделать запуск отдельным методом с заделом на будущее — дело в том, что запуск Transition осуществляется с помощью метода TransitionManager.beginDelayedTransition, а это накладывает некоторые ограничения. Ведь Transition выполнится только для тех View, которые поменяли свои свойства за некоторый промежуток времени после вызова TransitionManager.beginDelayedTransition. Так как в дальнейшем планируется внедрение Animator’ов, которые могут длится относительно долгое время, то TransitionManager.beginDelayedTransition нужно вызывать непосредственно перед сменой Visibility. Ну, и далее я вызываю super.changeVisibility(inChild, outChild);, который меняет Visibility у нужных child. Ссылка на Github Вот я и добрался до основной прослойки. Изначально я хотел воспользоваться LayoutTransition для управления Animator'ами, но мои мечты разбились о невозможность без костылей выполнить с его помощью анимации параллельно. Также дополнительные проблемы создавали остальные минусы LayoutTransition вроде необходимости выставлять длительность для AnimatorSet, невозможности ручного прерывания и пр. Было принято решение написать свою логику работы. Все выглядело очень даже просто: запускаем Animator для исчезающего child, на его окончание выставляем ему Visibility.GONE и тут же делаем появляющийся child видимым и запускаем Animator для него. Тут я наткнулся на первую проблему: нельзя запустить Animator для неприаттаченной View (это та, у которой ещё не был выполнен onAttach или уже сработал onDetach). Это не давало мне менять видимость какого-либо child в конструкторе или любом другом методе, который срабатывает раньше onAttach. Предвидя кучу разнообразных ситуаций, где это может понадобится, и не менее маленькую кучу issues на Github, я решил попытаться исправить положение. К сожалению, самое простое решение в виде вызова метода isAttachedToWindow() упиралось в невозможность его вызова до 19 версии API, а мне очень хотелось иметь поддержку с 14 API.  Однако у View существует OnAttachStateChangeListener, и я не преминул им воспользоваться. Я переопределил метод void addView(View child, int index, ViewGroup.LayoutParams params) и на каждую добавленную View вешал этот Listener. Далее я помещал в HashMap ссылку на саму View и булеву переменную, обозначающую его состояние. Если срабатывал onViewAttachedToWindow(View v), я ставил значение true, а если onViewDetachedFromWindow(View v), то false. Теперь, перед самым запуском Animator'а, я мог проверять состояние View и мог решить, стоит ли вообще запускать Animator.  После преодоления первой «баррикады» я сделал два интерфейса для получения Animator'ов: InAnimator и OutAnimator. Всё шло гладко, пока передо мной не встала новая проблема: после выполнения Animator'а нужно восстановить состояние View. Ответа на StackOverflow я так и не нашёл. После получаса мозгового штурма я решил воспользоваться методом reverse у ValueAnimator, сделав его длительность равной нулю.  Это помогло, и я даже дал тот самый ответ на StackOverflow. Сразу же после решения этой проблемы возникла другая: CircularRevealAnimator не выполняет свою анимацию, если у View ещё не был выполнен onMeasure. Это было плохой новостью, так как у ViewAnimator невидимые child имеют Visibility.GONE. Это значит, что они не измеряются вплоть до того момента, пока им не выставят другой тип Visibility — VISIBLE или INVISIBLE. Даже если бы перед началом анимации я изменил Visibility на INVISIBLE, то это не решило бы проблемы. Так как измерение размеров View происходит при отрисовке кадра, а отрисовка кадров происходит асинхронно, то нет никакой гарантии, что к моменту старта Animator'а View была бы измерена. Выставлять задержку или использовать onPreDrawListener мне крайне не хотелось, поэтому по умолчанию я решил использовать Visibility.INVISIBLE вместо Visibility.GONE.  В голове прокручивались сцены ужасов по мотивам того, как мои View измеряются при инфлейте (хотя им это совсем не надо), что сопровождается визуальными лагами. Поэтому я решил провести небольшой тест, измеряя время инфлейта, с Visibility.INVISIBLE и Visibility.GONE c 10 View и вложенностью 5. Тесты показали, что разница не превышала 1 миллисекунды. То ли я не заметил, как телефоны стали гораздо мощнее, то ли Android так хорошо оптимизировали, но мне смутно вспоминается, что когда-то лишний Visibility.INVISIBLE плохо влиял на производительность. Ну да ладно, проблема была побеждена.  Не успев опомниться от предыдущей «схватки», я бросился в следующую. Так как во FrameLayout child лежат друг над другом, то при одновременном выполнении InAnimator и OutAnimator возникает ситуация, когда в зависимости от индекса child анимация выглядит по-разному.
Из-за всех проблем, возникших с реализацией Animator'ов, мне хотелось их бросить, но чувство «раз начал — закончи» заставляло двигаться вперед. Проблема возникает, когда я пытаюсь сделать видимой View, которая лежит ниже текущей отображаемой View. Из-за этого анимация исчезновения полность перекрывает анимацию появления и наоборот. В поисках решения я пытался использовать другие ViewGroup, игрался со свойством Z и пробовал ещё кучу всякого. Наконец, пришла идея в начале анимации просто удалить нужную View из контейнера, добавить её наверх, а в конце анимации опять удалить и затем вернуть на исходное место. Идея сработала, но на слабых устройствах анимации подлагивали. Подвисание происходило из-за того, что при удалении или добавлении View у него самого и у его parent вызывается requestLayout(), который пересчитывает и перерисовывает их. Пришлось лезть в дебри класса ViewGroup. Спустя несколько минут изучения я пришел к выводу, что порядок расположения View внутри ViewGroup зависит всего лишь от одного массива, а дальше наследники ViewGroup (к примеру, FrameLayout или LinearLayout) уже решают, как его отобразить. Увы, массив, а также методы работы с ним, были помечены private. Но была и хорошая новость: в Java это не проблема, так как есть Java Reflection. С помощью Java Reflection я воспользовался методами работы с массивом и теперь мог управлять положением нужной мне View напрямую. Получился вот такой метод: Этот метод выставляет нужную мне позицию для View. Перерисовку в конце этих манипуляций вызывать не нужно — за вас это сделает анимация. Теперь перед началом анимации я мог положить нужную мне View наверх, а в конце анимации вернуть обратно. Итак, основная часть рассказа о CannyViewAnimator закончена. Github Новая задача: добавить возможность настройки с помощью XML. Так как я очень сильно не люблю создание Animator в XML (они мне кажутся чем-то плохо читаемым и не очевидным), я решил сделать набор стандартных анимаций с возможностью их выставления через флаги. Плюс такой подход поможет проще задавать анимации через Java-код. Так как подход к созданию CircularRevalAnimator отличается от стандартного, пришлось написать два типа классов-помощников: один для обычных Property, другой — для CircularReval.
В итоге получилось шесть классов: С их помощью инициализация анимаций стало проще и изящнее. Вместо: Можно просто написать: Получилось даже посимпатичнее, чем с использованием lamda-выражений. Далее с помощью этих классов я создал два списка стандартных анимаций: один для Property — PropertyAnimators, другой для CircularReaval — RevealAnimators. Далее я с помощью флагов находил в XML позицию в этих списках и подставлял его. Так как CircularRevealAnimator работает только с Android 5 и выше. Пришлось создать четыре параметра вместо двух: Далее при разборе параметров из XML я определяю версию системы. Если она выше, чем 5.0, то беру значения из in и out; если ниже, то из pre_lollipop_in и pre_lollipop_out. Если версия ниже чем 5.0, но pre_lollipop_in и pre_lollipop_out не заданы, то значения берутся из in и out. Несмотря на множество проблем, я всё же успешно завершил CannyViewAnimator. Вообще, странно то, что каждый раз, как я хочу реализовать какую-либо свою хотелку, мне приходится использовать Java Reflection и лезть вглубь. Это наводит на мысли, что либо с Android SDK что-то не то, либо я хочу слишком много. Если у вас есть идеи и предложения — добро пожаловать в комментарии.
Ещё раз повторю ссылку на проект снизу:
Ссылка на проект в Github  Всем пока! Android developer