Добавление анимации в ListView

от автора

Приветствую Вас, коллеги,

сегодня я пожаловал к вам с коротенькой статьей на тему добавления анимаций в 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *