Всем привет! Мне очень нравится работать с анимациями — в каждом 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, но он абсолютно не гибок и поддерживает только восемь заранее заданных в нём анимаций. Это означало только одно: придётся писать всё самому.
Что же я хочу получить
Первое, что я решил сделать — это продумать то, что я в итоге хочу получить:
- возможность всё так же управлять видимостью child;
- возможность использовать Animator и в особенности CircularRevealAnimator;
- возможность запускать анимации как последовательно, так и параллельно (ViewAnimator умеет только последовательно);
- возможность использовать Transition;
- сделать набор стандартных анимаций с возможностью их выставления через xml;
- гибкость работы, возможность выставлять для отдельного child свою анимацию.
Определившись с желаниями, я начал продумывать «архитектуру» будущего проекта. Получилось три части:
- ViewAnimator — отвечает на переключение видимости child;
- TransitionViewAnimator — наследуется от ViewAnimator и отвечает за работу с Transition;
- CannyViewAnimator — наследуется от TransitionViewAnimator и отвечает за работу с Animator.
Выставление Animator'ов и Transition я решил сделать с помощью интерфейса с двумя параметрами: child, который будет появляться, и child, который будет исчезать. Каждый раз, когда сменяется видимый child, из реализации интерфейса будет забираться необходимая анимация. Интерфейса будет три:
- InAnimator — отвечающий за Animator появляющегося child;
- OutAnimator — отвечающий за Animator исчезающего child;
- CannyTransition — отвечающий за Transition.
Интерфейс для Transition я решил сделать один, так как Transition накладывается сразу на все появляющиеся и исчезающие child. Концепция была продумана, и я приступил к разработке.
ViewAnimator
Со своим базовым классом я не стал особо мудрить и решил сделать копирку с ViewAnimator из SDK. Я лишь выбросил из него работу с Animation и оптимизировал методы в нём, так как многие из них мне показались избыточными. Также я не забыл добавить и методы из BetterViewAnimator. Итоговый список важных для работы с ним методов получился таким:
- void setDisplayedChildIndex(int inChildIndex) — отображает child с заданным индексом;
- void setDisplayedChildId(@IdRes int id) — отображает child с заданным id;
- void setDisplayedChild(View view) — отображает конкретный child;
- int getDisplayedChildIndex() — получение индекса отображаемого child;
- View getDisplayedChild() — получение отображаемого child;
- int getDisplayedChildId() — получение id отображаемого child.
Немного подумав, я решил дополнительно сохранять позицию текущего видимого child в onSaveInstanceState() и восстанавливать её onRestoreInstanceState(Parcelable state), тут же отображая его.
Итоговый код получился таким:
public class ViewAnimator extends FrameLayout { private int lastWhichIndex = 0; public ViewAnimator(Context context) { super(context); } public ViewAnimator(Context context, AttributeSet attrs) { super(context, attrs); } public void setDisplayedChildIndex(int inChildIndex) { if (inChildIndex >= getChildCount()) { inChildIndex = 0; } else if (inChildIndex < 0) { inChildIndex = getChildCount() - 1; } boolean hasFocus = getFocusedChild() != null; int outChildIndex = lastWhichIndex; lastWhichIndex = inChildIndex; changeVisibility(getChildAt(inChildIndex), getChildAt(outChildIndex)); if (hasFocus) { requestFocus(FOCUS_FORWARD); } } public void setDisplayedChildId(@IdRes int id) { if (getDisplayedChildId() == id) { return; } for (int i = 0, count = getChildCount(); i < count; i++) { if (getChildAt(i).getId() == id) { setDisplayedChildIndex(i); return; } } throw new IllegalArgumentException("No view with ID " + id); } public void setDisplayedChild(View view) { setDisplayedChildId(view.getId()); } public int getDisplayedChildIndex() { return lastWhichIndex; } public View getDisplayedChild() { return getChildAt(lastWhichIndex); } public int getDisplayedChildId() { return getDisplayedChild().getId(); } protected void changeVisibility(View inChild, View outChild) { outChild.setVisibility(INVISIBLE); inChild.setVisibility(VISIBLE); } @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { super.addView(child, index, params); if (getChildCount() == 1) { child.setVisibility(View.VISIBLE); } else { child.setVisibility(View.INVISIBLE); } if (index >= 0 && lastWhichIndex >= index) { setDisplayedChildIndex(lastWhichIndex + 1); } } @Override public void removeAllViews() { super.removeAllViews(); lastWhichIndex = 0; } @Override public void removeView(View view) { final int index = indexOfChild(view); if (index >= 0) { removeViewAt(index); } } @Override public void removeViewAt(int index) { super.removeViewAt(index); final int childCount = getChildCount(); if (childCount == 0) { lastWhichIndex = 0; } else if (lastWhichIndex >= childCount) { setDisplayedChildIndex(childCount - 1); } else if (lastWhichIndex == index) { setDisplayedChildIndex(lastWhichIndex); } } @Override public void removeViewInLayout(View view) { removeView(view); } @Override public void removeViews(int start, int count) { super.removeViews(start, count); if (getChildCount() == 0) { lastWhichIndex = 0; } else if (lastWhichIndex >= start && lastWhichIndex < start + count) { setDisplayedChildIndex(lastWhichIndex); } } @Override public void removeViewsInLayout(int start, int count) { removeViews(start, count); } @Override protected void onRestoreInstanceState(Parcelable state) { if (!(state instanceof SavedState)) { super.onRestoreInstanceState(state); return; } SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); lastWhichIndex = ss.lastWhichIndex; setDisplayedChildIndex(lastWhichIndex); } @Override protected Parcelable onSaveInstanceState() { SavedState savedState = new SavedState(super.onSaveInstanceState()); savedState.lastWhichIndex = lastWhichIndex; return savedState; } public static class SavedState extends View.BaseSavedState { int lastWhichIndex; SavedState(Parcelable superState) { super(superState); } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeInt(this.lastWhichIndex); } @Override public String toString() { return "ViewAnimator.SavedState{" + "lastWhichIndex=" + lastWhichIndex + '}'; } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { @Override public SavedState createFromParcel(Parcel source) { return new SavedState(source); } @Override public SavedState[] newArray(int size) { return new SavedState[size]; } }; protected SavedState(Parcel in) { super(in); this.lastWhichIndex = in.readInt(); } } }
TransitionViewAnimator
Закончив с ViewAnimator, я приступил к довольно простой, но от этого не менее интересной задаче: сделать поддержку Transition. Суть работы такова: при вызове переопределённого метода changeVisibility (View inChild, View outChild) подготавливается анимация. Из заданного CannyTransition с помощью интерфейса забирается Transition и записывается в поле класса.
public interface CannyTransition { Transition getTransition(View inChild, View outChild); }
Затем в отдельном методе выполняется запуск этого Transition. Я решил сделать запуск отдельным методом с заделом на будущее — дело в том, что запуск Transition осуществляется с помощью метода TransitionManager.beginDelayedTransition, а это накладывает некоторые ограничения. Ведь Transition выполнится только для тех View, которые поменяли свои свойства за некоторый промежуток времени после вызова TransitionManager.beginDelayedTransition. Так как в дальнейшем планируется внедрение Animator’ов, которые могут длится относительно долгое время, то TransitionManager.beginDelayedTransition нужно вызывать непосредственно перед сменой Visibility. Ну, и далее я вызываю super.changeVisibility(inChild, outChild);, который меняет Visibility у нужных child.
public class TransitionViewAnimator extends ViewAnimator { private CannyTransition cannyTransition; private Transition transition; public TransitionViewAnimator(Context context) { super(context); } public TransitionViewAnimator(Context context, AttributeSet attrs) { super(context, attrs); } @Override protected void changeVisibility(View inChild, View outChild) { prepareTransition(inChild, outChild); startTransition(); super.changeVisibility(inChild, outChild); } protected void prepareTransition(View inChild, View outChild) { if (cannyTransition != null) { transition = cannyTransition.getTransition(inChild, outChild); } } public void startTransition() { if (transition != null) { TransitionManager.beginDelayedTransition(this, transition); } } public void setCannyTransition(CannyTransition cannyTransition) { this.cannyTransition = cannyTransition; } }
CannyViewAnimator
Вот я и добрался до основной прослойки. Изначально я хотел воспользоваться 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.
public interface InAnimator { Animator getInAnimator(View inChild, View outChild); }
public interface OutAnimator { Animator getOutAnimator(View inChild, View outChild); }
Всё шло гладко, пока передо мной не встала новая проблема: после выполнения Animator'а нужно восстановить состояние View.
Ответа на StackOverflow я так и не нашёл. После получаса мозгового штурма я решил воспользоваться методом reverse у ValueAnimator, сделав его длительность равной нулю.
if (animator instanceof ValueAnimator) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { animation.removeListener(this); animation.setDuration(0); ((ValueAnimator) animation).reverse(); } }); }
Это помогло, и я даже дал тот самый ответ на 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 напрямую. Получился вот такой метод:
public void bringChildToPosition(View child, int position) { final int index = indexOfChild(child); if (index < 0 && position >= getChildCount()) return; try { Method removeFromArray = ViewGroup.class.getDeclaredMethod("removeFromArray", int.class); removeFromArray.setAccessible(true); removeFromArray.invoke(this, index); Method addInArray = ViewGroup.class.getDeclaredMethod("addInArray", View.class, int.class); addInArray.setAccessible(true); addInArray.invoke(this, child, position); Field mParent = View.class.getDeclaredField("mParent"); mParent.setAccessible(true); mParent.set(child, this); } catch (NoSuchMethodException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (NoSuchFieldException e) { e.printStackTrace(); } }
Этот метод выставляет нужную мне позицию для View. Перерисовку в конце этих манипуляций вызывать не нужно — за вас это сделает анимация. Теперь перед началом анимации я мог положить нужную мне View наверх, а в конце анимации вернуть обратно. Итак, основная часть рассказа о CannyViewAnimator закончена.
public class CannyViewAnimator extends TransitionViewAnimator { public static final int SEQUENTIALLY = 1; public static final int TOGETHER = 2; private int animateType = SEQUENTIALLY; @Retention(RetentionPolicy.SOURCE) @IntDef({SEQUENTIALLY, TOGETHER}) @interface AnimateType { } public static final int FOR_POSITION = 1; public static final int IN_ALWAYS_TOP = 2; public static final int OUT_ALWAYS_TOP = 3; private int locationType = FOR_POSITION; @Retention(RetentionPolicy.SOURCE) @IntDef({FOR_POSITION, IN_ALWAYS_TOP, OUT_ALWAYS_TOP}) @interface LocationType { } private List<? extends InAnimator> inAnimator; private List<? extends OutAnimator> outAnimator; private final Map<View, Boolean> attachedList = new HashMap<>(getChildCount()); public CannyViewAnimator(Context context) { super(context); } public CannyViewAnimator(Context context, AttributeSet attrs) { super(context, attrs); } @SafeVarargs public final <T extends InAnimator> void setInAnimator(T... inAnimators) { setInAnimator(Arrays.asList(inAnimators)); } public void setInAnimator(List<? extends InAnimator> inAnimators) { this.inAnimator = inAnimators; } @SafeVarargs public final <T extends OutAnimator> void setOutAnimator(T... outAnimators) { setOutAnimator(Arrays.asList(outAnimators)); } public void setOutAnimator(List<? extends OutAnimator> outAnimators) { this.outAnimator = outAnimators; } @Override protected void changeVisibility(View inChild, View outChild) { if (attachedList.get(outChild) && attachedList.get(inChild)) { AnimatorSet animatorSet = new AnimatorSet(); Animator inAnimator = mergeInAnimators(inChild, outChild); Animator outAnimator = mergeOutAnimators(inChild, outChild); prepareTransition(inChild, outChild); switch (animateType) { case SEQUENTIALLY: animatorSet.playSequentially(outAnimator, inAnimator); break; case TOGETHER: animatorSet.playTogether(outAnimator, inAnimator); break; } switch (locationType) { case FOR_POSITION: addOnStartVisibleListener(inAnimator, inChild); addOnEndInvisibleListener(outAnimator, outChild); break; case IN_ALWAYS_TOP: addOnStartVisibleListener(inAnimator, inChild); addOnEndInvisibleListener(inAnimator, outChild); addOnStartToTopOnEndToInitPositionListener(inAnimator, inChild); break; case OUT_ALWAYS_TOP: addOnStartVisibleListener(outAnimator, inChild); addOnEndInvisibleListener(outAnimator, outChild); addOnStartToTopOnEndToInitPositionListener(outAnimator, outChild); break; } animatorSet.start(); } else { super.changeVisibility(inChild, outChild); } } private AnimatorSet mergeInAnimators(final View inChild, final View outChild) { AnimatorSet animatorSet = new AnimatorSet(); List<Animator> animators = new ArrayList<>(inAnimator.size()); for (InAnimator inAnimator : this.inAnimator) { if (inAnimator != null) { Animator animator = inAnimator.getInAnimator(inChild, outChild); if (animator != null) { animators.add(animator); } } } animatorSet.playTogether(animators); return animatorSet; } private AnimatorSet mergeOutAnimators(final View inChild, final View outChild) { AnimatorSet animatorSet = new AnimatorSet(); List<Animator> animators = new ArrayList<>(outAnimator.size()); for (OutAnimator outAnimator : this.outAnimator) { if (outAnimator != null) { Animator animator = outAnimator.getOutAnimator(inChild, outChild); if (animator != null) animators.add(animator); } } animatorSet.playTogether(animators); addRestoreInitValuesListener(animatorSet); return animatorSet; } private void addRestoreInitValuesListener(AnimatorSet animatorSet) { for (Animator animator : animatorSet.getChildAnimations()) { if (animator instanceof ValueAnimator) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { animation.removeListener(this); animation.setDuration(0); ((ValueAnimator) animation).reverse(); } }); } } } private void addOnStartVisibleListener(Animator animator, final View view) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { startTransition(); view.setVisibility(VISIBLE); } }); } private void addOnEndInvisibleListener(Animator animator, final View view) { animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { startTransition(); view.setVisibility(INVISIBLE); } }); } private void addOnStartToTopOnEndToInitPositionListener(Animator animator, final View view) { final int initLocation = indexOfChild(view); animator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(Animator animation) { bringChildToPosition(view, getChildCount() - 1); } @Override public void onAnimationEnd(Animator animation) { bringChildToPosition(view, initLocation); } }); } public int getAnimateType() { return animateType; } public void setAnimateType(@AnimateType int animateType) { this.animateType = animateType; } public int getLocationType() { return locationType; } public void setLocationType(@LocationType int locationType) { this.locationType = locationType; } @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { attachedList.put(child, false); child.addOnAttachStateChangeListener(new OnAttachStateChangeListener() { @Override public void onViewAttachedToWindow(View v) { attachedList.put(v, true); } @Override public void onViewDetachedFromWindow(View v) { attachedList.put(v, false); } }); super.addView(child, index, params); } @Override public void removeAllViews() { attachedList.clear(); super.removeAllViews(); } @Override public void removeView(View view) { attachedList.remove(view); super.removeView(view); } @Override public void removeViewAt(int index) { attachedList.remove(getChildAt(index)); super.removeViewAt(index); } @Override public void removeViews(int start, int count) { for (int i = start; i < start + count; i++) { attachedList.remove(getChildAt(i)); } super.removeViews(start, count); } }
Добавляем поддержку XML и классы-помощники
Новая задача: добавить возможность настройки с помощью XML. Так как я очень сильно не люблю создание Animator в XML (они мне кажутся чем-то плохо читаемым и не очевидным), я решил сделать набор стандартных анимаций с возможностью их выставления через флаги. Плюс такой подход поможет проще задавать анимации через Java-код. Так как подход к созданию CircularRevalAnimator отличается от стандартного, пришлось написать два типа классов-помощников: один для обычных Property, другой — для CircularReval.
В итоге получилось шесть классов:
class PropertyCanny { Animator propertyAnimator; public PropertyCanny(PropertyValuesHolder... holders) { this.propertyAnimator = ObjectAnimator.ofPropertyValuesHolder(holders); } public PropertyCanny(Property<?, Float> property, float start, float end) { this.propertyAnimator = ObjectAnimator.ofFloat(null, property, start, end); } public PropertyCanny(String propertyName, float start, float end) { this.propertyAnimator = ObjectAnimator.ofFloat(null, propertyName, start, end); } public Animator getPropertyAnimator(View child) { propertyAnimator.setTarget(child); return propertyAnimator.clone(); } }
public class PropertyIn extends PropertyCanny implements InAnimator { public PropertyIn(PropertyValuesHolder... holders) { super(holders); } public PropertyIn(Property<?, Float> property, float start, float end) { super(property, start, end); } public PropertyIn(String propertyName, float start, float end) { super(propertyName, start, end); } public PropertyIn setDuration(long millis) { propertyAnimator.setDuration(millis); return this; } @Override public Animator getInAnimator(View inChild, View outChild) { return getPropertyAnimator(inChild); } }
public class PropertyOut extends PropertyCanny implements OutAnimator { public PropertyOut(PropertyValuesHolder... holders) { super(holders); } public PropertyOut(Property<?, Float> property, float start, float end) { super(property, start, end); } public PropertyOut(String propertyName, float start, float end) { super(propertyName, start, end); } public PropertyOut setDuration(long millis) { propertyAnimator.setDuration(millis); return this; } @Override public Animator getOutAnimator(View inChild, View outChild) { return getPropertyAnimator(outChild); } }
class RevealCanny { private final int gravity; public RevealCanny(int gravity) { this.gravity = gravity; } @SuppressLint("RtlHardcoded") protected int getCenterX(View view) { final int horizontalGravity = gravity & Gravity.HORIZONTAL_GRAVITY_MASK; if (horizontalGravity == Gravity.LEFT) { return 0; } else if (horizontalGravity == Gravity.RIGHT) { return view.getWidth(); } else { // (Gravity.CENTER_HORIZONTAL) return view.getWidth() / 2; } } protected int getCenterY(View view) { final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK; if (verticalGravity == Gravity.TOP) { return 0; } else if (verticalGravity == Gravity.BOTTOM) { return view.getHeight(); } else { // (Gravity.CENTER_VERTICAL) return view.getHeight() / 2; } } public int getGravity() { return gravity; } }
@TargetApi(Build.VERSION_CODES.LOLLIPOP) public class RevealIn extends RevealCanny implements InAnimator { public RevealIn(int gravity) { super(gravity); } @Override public Animator getInAnimator(View inChild, View outChild) { float inRadius = (float) Math.hypot(inChild.getWidth(), inChild.getHeight()); return ViewAnimationUtils.createCircularReveal(inChild, getCenterX(inChild), getCenterY(inChild), 0, inRadius); } }
@TargetApi(Build.VERSION_CODES.LOLLIPOP) public class RevealOut extends RevealCanny implements OutAnimator { public RevealOut(int gravity) { super(gravity); } @Override public Animator getOutAnimator(View inChild, View outChild) { float outRadius = (float) Math.hypot(outChild.getWidth(), outChild.getHeight()); return ViewAnimationUtils.createCircularReveal(outChild, getCenterX(outChild), getCenterY(outChild), outRadius, 0); } }
С их помощью инициализация анимаций стало проще и изящнее. Вместо:
animator.setInAnimator(new InAnimator() { @Override public Animator getInAnimator(View inChild, View outChild) { return ObjectAnimator.ofFloat(inChild, View.ALPHA, 0, 1); } }); animator.setOutAnimator(new OutAnimator() { @Override public Animator getOutAnimator(View inChild, View outChild) { return ObjectAnimator.ofFloat(outChild, View.ALPHA, 1, 0); } });
Можно просто написать:
animator.setInAnimator(new PropertyIn(View.ALPHA, 0, 1)); animator.setOutAnimator(new PropertyOut(View.ALPHA, 1, 0));
Получилось даже посимпатичнее, чем с использованием lamda-выражений. Далее с помощью этих классов я создал два списка стандартных анимаций: один для Property — PropertyAnimators, другой для CircularReaval — RevealAnimators. Далее я с помощью флагов находил в XML позицию в этих списках и подставлял его. Так как CircularRevealAnimator работает только с Android 5 и выше. Пришлось создать четыре параметра вместо двух:
- in — выставляет анимацию на появление
- out — выставляет анимацию на исчезновение
- pre_lollipop_in — выставляет анимацию на появление, не содержит в списке CircularReveal
- pre_lollipop_out — выставляет анимацию на исчезновение, не содержит в списке CircularReveal
Далее при разборе параметров из 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
Всем пока!