Всем привет! Мне очень нравится работать с анимациями — в каждом 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
Всем пока!