сегодня я пожаловал к вам с коротенькой статьей на тему добавления анимаций в ListView при скроллинге. Не так давно мне захотелось добавить в мой список анимацию, аналогичную той, что можно увидеть в G+ клиенте, но немного другую.
А захотелось мне сделать так, чтобы новые элементы не просто появлялись внизу, а выплывали снизу и немного справа. В общем-то, это я сделал, но позже, я посмотрел доклада Романа Ги и Чета Хааса на Google IO 2013 и загорелся идеей добавить искажение при этом, чтобы добавить реалистичности. Это потребовало немного изменить подход, но, в целом концепция осталась прежней.
Давайте теперь по-порядку я расскажу о том, что было, то, как оно изменилось, и, собственно, как все это работает.
Чтобы было понятно, вообще, о чем я, ниже ссылка на ролик с конечной анимацией. Обратите внимание, как элементы деформируются при появлении. Поскольку видео записывалось с эмулятора, присутствуют небольшие дергания, на девайсе все идеально гладко. Так же для наглядности я увеличил продолжительность анимации до 900мс. Обычно вы хотите, чтобы она длилась 300 мс.
Простое движение
Поскольку мы хотим, чтобы элементы «всплывали» при появлении внизу или вверху списка, самое логичное место — добавить код в getView нашего адаптера.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) { animatePostHc(position, v); } else { animatePreHc(position, v); }
Сразу отмечу, что основной акцент у меня на ICS+, поэтому дальше я буду рассказывать в основном о нем.
Давайте посмотрим метод animatePostHc;
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private void animatePostHc(int position, View v) { if (prevPosition < position) { v.setTranslationX(animX); v.setTranslationY(animY); } else { v.setTranslationX(-animX); v.setTranslationY(-animY); } v.animate().translationY(0).translationX(0).setDuration(300) .setListener(new InnerAnimatorListener(v)).start(); }
По шагам. Мы определяем, в каком направлении движется наш список и делаем соответствующее смещение. Далее мы, используя новый API анимаций говорим, что мы хотим подвинуть в (0, 0) за 300 мс.
Также мы вешаем обработчик, который делает следующее:
static class InnerAnimatorListener implements AnimatorListener { private View v; private int layerType; public InnerAnimatorListener(View v) { this.v = v; } @Override public void onAnimationStart(Animator animation) { layerType = v.getLayerType(); v.setLayerType(View.LAYER_TYPE_HARDWARE, null); } @Override public void onAnimationRepeat(Animator animation) { } @Override public void onAnimationEnd(Animator animation) { v.setLayerType(layerType, null); } @Override public void onAnimationCancel(Animator animation) { } }
поскольку мы хотим, чтобы наша анимация была плавной и хорошей, лучше всего установить нашему элементу режим HARDWARE LAYER на время анимации. В этом случае, у нас создается цельный слой, в котором наш компонент рендерится как единая текстура (в этом можно убедиться, включив, например, режим отладки hardwarew overdraw), что сильно ускоряет рендеринг.
На самом деле, начиная с Jelly Bean, ровно то же самое можно сделать гораздо проще, а именно, вызвав метод withLayer() у аниматора:
v.animate().withLayer().translationY(0).translationX(0).setDuration(300).setListener(new InnerAnimatorListener(v)).start();
Но мы не живем в идеальном мире.
Проверяем — да, работает. Но… вьюхи анимируются всегда, даже при просто открытии активити. Давайте ограничим появление анимаций только на то время, когда мы действительно скроллим наш ListView.
Для этого я добавил в свой адаптер булево поле animate. Теперь нам нужно лишь повесить обработчик на ListView и включать/выключать анимации:
listView.setOnScrollListener(new OnScrollListener() { @Override public void onScrollStateChanged(AbsListView view, int scrollState) { adapter.setAnimate(scrollState == SCROLL_STATE_FLING || SCROLL_STATE_TOUCH_SCROLL == scrollState); } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { } });
Пробуем. Теперь уже лучше. Однако есть дефект. Если пустить список прокручиваться быстро, то анимация выглядит уродливо, т. к. анимируемые компоненты просто не успевают. Логично в этом случае просто не показывать анимацию, когда список прокручивается быстрее дозволенного. Так я и сделал, но об этом я напишу немного позже, т. к. сначала хочу рассказать про добавление деформаций в анимацию, а потом уже про устранение дефектов.
Искажение
Как я и говорил, на добавление этого меня вдохновила это лекция c Google I/O 2013. Вообще, я считаю, что каждый материал (видео, пост в блог и т. п.) от Романа Ги абсолютно бесценен.
Для того, чтобы добавить небольшое искажение элемента списка, нам потребуется создать кастомный layout. Не пугайтесь, я не говорю, что нам нужно создать его с нуля, нам нужно просто расширить существующий. В моем примере каждый элемент списка — RelativeLayout, поэтому его я и расширил, создав класс SkewingRelativeLayout:
public class SkewingRelativeLayout extends RelativeLayout { private float skewX = 0; public SkewingRelativeLayout(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } public SkewingRelativeLayout(Context context, AttributeSet attrs) { super(context, attrs); } public SkewingRelativeLayout(Context context) { super(context); } @Override public void draw(Canvas canvas) { if (skewX != 0) { canvas.skew(skewX, 0); } super.draw(canvas); } public void setSkewX(float skewX) { this.skewX = skewX; } }
Мы добавили поле skew — искажение. Теперь, мы переопределили наш метод draw, и в нем перед тем, как нарисовать наш компонент, искажаем канву.
Заменяем элементы списка на SkewingRelativeLayout.
Теперь к анимации… Для того чтобы сделать синхронное искажение и перемещение нашего элемента, мне пришлось чуть чуть изменить подход к его анимированию:
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH) private void animatePostHc(int position, View v) { float startSkewX = 0.15f; float translationX; float translationY; if (prevPosition < position) { translationX = animX; translationY = animY; } else { translationX = -animX; translationY = -animY; } ObjectAnimator skewAnimator = ObjectAnimator.ofFloat(v, "skewX", startSkewX, 0f); ObjectAnimator translationXAnimator = ObjectAnimator.ofFloat(v, View.TRANSLATION_X, translationX, 0.0f); ObjectAnimator translationYAnimator = ObjectAnimator.ofFloat(v, View.TRANSLATION_Y, translationY, 0.0f); AnimatorSet set = new AnimatorSet(); set.playTogether(skewAnimator, translationXAnimator, translationYAnimator); set.setDuration(300); set.setInterpolator(decelerator); set.addListener(new AnimatorWithLayerListener(v)); set.start(); }
использование ViewPropertyAnimator было заменено на три отдельных ObjectAnimator’a, каждый из которых отвечает за свое значение (искажение, смещение по X, смещение по Y). Чтобы они работали синхронно и на одном интерполяторе, используем класс AnimatorSet.
Если мы теперь попробуем это запустить, то увидим, как красиво искажаются наши элементы.
Одна проблема с которой я столкнулся при работе с искажением — это то, что мне пришлось отказаться от hardware layers, потому что при добавлении искажения по краям искаженного компонента появляются страшные черные дыры. Я не смог это побороть и убрал hardware layers. Но вроде и без них на моем Galaxy Nexus работает очень плавно.
Избавление от дефекта при быстрой прокрутки
После ряда экспериментов я пришел к выводу, что, чтобы избавиться от нежелательных дефектов, мне надо выполнить два пункта:
- Отключить анимирование при превышении скроллингом определенного порога скорости
- Отмена всех уже запущенных анимаций в этом случае
Второе действие необходимо, т. к. граница превышения скорости очень тонкая и наступает неожиданно, что может привести к тому, что один элемент пошел анимироваться, а следующий — уже нет. Получается, что первый накладывается на второй. Уродство.
Для вычисления скорости я немного модифицировал код:
listView.setOnScrollListener(new OnScrollListener() { private int previousFirstVisibleItem = 0; private long previousEventTime = 0; private double speed = 0; private int scrollState; @Override public void onScrollStateChanged(AbsListView view, int scrollState) { this.scrollState = scrollState; adapter.setAnimate(scrollState == SCROLL_STATE_FLING || SCROLL_STATE_TOUCH_SCROLL == scrollState); } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { if (previousFirstVisibleItem != firstVisibleItem) { long currTime = System.currentTimeMillis(); long timeToScrollOneElement = currTime - previousEventTime; speed = ((double) 1 / timeToScrollOneElement) * 1000; previousFirstVisibleItem = firstVisibleItem; previousEventTime = currTime; if (scrollState == SCROLL_STATE_FLING && speed > 16) { adapter.setAnimate(false); adapter.cancelAnimations(); } else { adapter.setAnimate(true); } } } });
Как видно, теперь при превышении определенного порога скорости (подобранного на глаз) мы отключаем анимирование и отменяем все анимации. Повторюсь, что магическое число 16 подобрано мной на глаз и работает в моем случае, но, оно зависит от размера элементов вашего списка, так что лучше его не хардкодить.
В адаптере я добавляю метод:
public void cancelAnimations() { for (int i = anims.size() - 1; i >= 0; i--) { anims.get(i).cancel(); } }
И модифицирую listener анимации. В финальном варианте он выглядит так:
private class AnimatorWithLayerListener implements AnimatorListener { View view; public AnimatorWithLayerListener(View view) { this.view = view; } @Override public void onAnimationStart(Animator animation) { ViewCompat.setHasTransientState(view, true); } @Override public void onAnimationEnd(Animator animation) { ViewCompat.setHasTransientState(view, false); anims.remove(animation); } @Override public void onAnimationCancel(Animator animation) { view.setTranslationX(0); view.setTranslationY(0); ((SkewingRelativeLayout) view).setSkewX(0); } @Override public void onAnimationRepeat(Animator animation) { } }
Теперь, при отмене анимации мы моментально убираем все смещения и искажения. Сразу стоит отметить, что метод onAnimationEnd вызывается всегда: и при отмене, и при обычном завершении. Поэтому нет смысла дублировать то, что в нем есть для отмены.
Также важно, чтобы мы выставляли нашему элементу флаг ViewCompat.setHasTransientState(view, false);. Этот флаг, начиная с ICS позволяет пометить элемент в списке как модифицируемый, и ListView будет это учитывать при внутреннем реюзе view. ViewPropertyAnimator делает это сам за нас, но, в случае с ObjectAnimator нам нужно сделать это руками.
Backwards Compatibiliy
Поскольку мы люди хорошие и не хотим терять 39% своих юзеров, мы хотим как-то порадовать и пользователей андроида 2.3. Я не задавался задачей полноценного портирования решения, поэтому я просто сделал альтернативный метод, который использует старый API анимаций.
private void animatePreHc(int position, View v) { if (prevPosition < position) { v.clearAnimation(); v.startAnimation(AnimationUtils.loadAnimation(context, R.anim.pop_from_bottom)); } else { v.clearAnimation(); v.startAnimation(AnimationUtils.loadAnimation(context, R.anim.pop_from_top)); } }
А если бы я все же задался такой целью, с большой вероятностью я бы просто воспользовался библиотекой NineOldAndroids от JakeWharton, которая, является качественным бекпортом нового API анимаций на версии вплоть до 1.6.
Заключение
Как всегда, я не претендую на абсолютную универсальность и безупречность того, что я описываю, но в моем случае это работает очень хорошо, и я просто хочу поделиться этим.
Возможно, если у вас очень длинный список (у меня там максимум 10 элементов наберется), вам придется предпринять доп. действия по минимизации «пложения» объектов в getView. Поскольку AnimatorSet можно реюзать, мне кажется, можно организовать какой-то разумный пул объектов, но это все выходит за рамки того, что я хотел Вам поведать, уважаемые коллеги, так что позвольте за сим раскланяться.
З. Ы. если кто-то хочет более полных исходников — попросите в комментах, как будет время, я выпилю этот кусок из проекта и выложу на github, хотя 95% того, что нужно сделать отражено в этой статье.
ссылка на оригинал статьи http://habrahabr.ru/post/181338/
Добавить комментарий