CannyViewAnimator: переключаем состояния красиво

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


    Всем пока!

    Лайв Тайпинг
    44,00
    Мы создаём мобильные приложения и веб-сервисы
    Поделиться публикацией

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое