CannyViewAnimator — наша версия ViewAnimator

Оригинал статьи был впервые опубликован здесь.

Всем привет! Мне очень нравится работать с анимациями — в каждом Android-приложении, в создании которого я участвую или на которое просто смотрю, я нашёл бы место парочке. В не таком ещё далёком апреле 2016 года с моей записи про тип классов Animation начал жить блог компании Лайв Тайпинг, а позже я выступил с докладом об анимациях на очередном омском IT-субботнике. В этой статье я хочу познакомить вас с нашей библиотекой CannyViewAnimator, а также погрузить вас в процесс её разработки. Она нужна для красивого переключения видимости View. 

О чём вообще речь

Но сначала представим для наглядности ситуацию, банальную в Android-разработке. У вас есть экран, а на нём — список, который приходит от сервера. Пока прекрасные данные грузятся от прекрасного сервера, вы показываете лоадер; как только данные пришли, вы в них смотрите: если пусто — показываете заглушку, если нет — показываете, собственно, данные. Как разрешить эту ситуацию на UI? Раньше, мы в Лайв Тайпинг пользовались следующим решением, которое когда-то подсмотрели в U2020, а затем перенесли в наш U2020 MVP — это BetterViewAnimator, View, который наследуется от ViewAnimator. Единственное, но важное отличие BetterViewAnimator от его предка — это умение работать с id ресурсов. Но он не идеален.

Что такое ViewAnimator?

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), тут же отображая его. Итоговый код получился таким:

ViewAnimator

<code class="post-page__inline-code">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();
        }
    }
}</code>

Ссылка на Github

TransitionViewAnimator

Закончив с ViewAnimator, я приступил к довольно простой, но от этого не менее интересной задаче: сделать поддержку Transition. Суть работы такова: при вызове переопределённого метода changeVisibility (View inChild, View outChild) подготавливается анимация. Из заданного CannyTransition с помощью интерфейса забирается Transition и записывается в поле класса.

CannyTransition

<code class="post-page__inline-code">public interface CannyTransition {
    Transition getTransition(View inChild, View outChild);
}</code>

Затем в отдельном методе выполняется запуск этого 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. Это значит, что они не измеряются вплоть до того момента, пока им не выставят другой тип VisibilityVISIBLE или 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-код. Так как подход к созданию CircularRevealAnimator отличается от стандартного, пришлось написать два типа классов-помощников: один для обычных Property, другой — для CircularReveal. В итоге получилось шесть классов:

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 что-то не то, либо я хочу слишком много. Если у вас есть идеи и предложения — добро пожаловать в комментарии. Ещё раз повторю ссылку на проект.

Android
Development
ViewAnimator