Pull to refresh

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

Reading time 8 min
Views 19K
Приветствую Вас, коллеги,

сегодня я пожаловал к вам с коротенькой статьей на тему добавления анимаций в 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;
        ViewCompat.postInvalidateOnLayout(this);
    }

}


Мы добавили поле 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% того, что нужно сделать отражено в этой статье.
Tags:
Hubs:
+45
Comments 12
Comments Comments 12

Articles