CannyViewAnimator: перемикаємо стану красиво

Всім привіт! Мені дуже подобається працювати з анімаціями — в кожному Android-додатку, у створенні якого я беру участь або на яке просто дивлюся, я знайшов би місце парочці. У ще не такому далекому квітні 2016 року з моєї запису про тип класів Animation почав жити блозі компанії Лайв Тайпинг, а пізніше я виступив з доповіддю про анімація на черговому омському IT-суботнику. У цій статті я хочу познайомити вас з нашою бібліотекою CannyViewAnimator, а також занурити вас в процес її розробки. Вона потрібна для красивого перемикання видимості View. Якщо вам цікава бібліотека, або історія її створення, ну або хоча б цікаві проблеми, з якими я зіткнувся, і їх рішення, то ласкаво просимо в статтю!
Про що взагалі мова
Але спочатку наведемо для наочності ситуацію, банальну в Android-розробці. У вас є екран, а на ньому — список, який приходить від сервера. Поки прекрасні дані вантажаться від прекрасного сервера, ви показуєте лоадер; як тільки дані прийшли, ви в них дивіться: якщо порожньо — показуєте заглушку, якщо немає — показуєте, власне, дані.
Як вирішити цю ситуацію на UI? Раніше, ми в Лайв Тайпинг користувалися таким рішенням, яке коли-то підгледіли в U2020, а потім перенесли в наш U2020 MVPBetterViewAnimator, View, який успадковується від ViewAnimator. Єдине, але важлива відмінність BetterViewAnimator від його предка — це вміння працювати з id ресурсів. Але він не ідеальний.
Що таке ViewAnimator?ViewAnimator — це View, який успадковується від FrameLayout і у якого в конкретний момент часу видно тільки один з його child. Для перемикання видимого child є набір методів.
Важливим мінусом BetterViewAnimator є вміння працювати тільки з застарілим AnimationFramework. І в цій ситуації приходить на допомогу CannyViewAnimator. Він підтримує роботу з Animator і AppCompat Transition.
Посилання на проект Github
З чого все почалося
Під час розробки чергового екрану «список-лоадер-заглушка» я задумався про те, що ми, звичайно, використовуємо BetterViewAnimator, але чомусь не користуємося його чи не основною фішкою — анімаціями. Налаштований оптимістично, я вирішив додати анімацію і натрапив на те, про що забув: ViewAnimator може працювати тільки з Animation. Пошуки альтернативи на Github, на жаль, не увінчалися успіхом — достойних не було, а був тільки Чоловічий 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), тут же відображаючи його.
Підсумковий код вийшов таким:
ViewAnimator
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();
}
}
}

Ссилочка на Github
TransitionViewAnimator
Закінчивши з ViewAnimator, я приступив до досить простий, але від цього не менш цікавою завдання: зробити підтримку Transition. Суть роботи така: при виклику переопределенного методу changeVisibility (View inChild, View outChild) готується анімація. Із заданого CannyTransition за допомогою інтерфейсу забирається Transition і записується в поле класу.
CannyTransition
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.
TransitionViewAnimator
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;
}
}

Посилання на Github
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.
InAnimator
public interface InAnimator {
Animator getInAnimator(View inChild, View outChild);
}

OutAnimator
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 закінчена.
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);
}
}

Github
Додаємо підтримку XML і класи-помічники
Нова задача: додати можливість налаштування за допомогою XML. Так як я дуже сильно не люблю створення Animator в XML (вони мені здаються чимось погано читаються і не очевидним), я вирішив зробити набір стандартних анімацій з можливістю їх виставлення через прапори. Плюс такий підхід допоможе простіше ставити анімації через Java-код. Так як підхід до створення CircularRevalAnimator відрізняється від стандартного, довелося написати два типи класів-помічників: один для звичайних Property, інший — для CircularReval.
У підсумку вийшло шість класів:
PropertyCanny
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();
}
}

PropertyIn
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);
}
}

PropertyOut
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);
}

}

RevealCanny
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;
}
}

RevealIn
@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);
}

}

RevealOut
@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
Всім пока!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.