Красивый и привлекательный UI — это важно. Поэтому для Android существует огромное количество библиотек для красивого отображения элементов дизайна. Часто в приложении требуется показать поле с числом или какой-либо счетчик. Например, счетчик количества выделенных элементов списка или сумму расходов за месяц. Конечно, такая задача легко решается с помощью обычного TextView, но можно ее решить элегантно и еще анимацию изменения числа добавить:

На YouTube доступно Demo-видео.
В статье пойдет рассказ о том, как все это реализовать.
Одна статическая цифра
Для каждой из цифр имеется векторное изображение, например, для 8 это res/drawable/viv_vd_pathmorph_digits_eight.xml:
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="@dimen/viv_digit_size" android:height="@dimen/viv_digit_size" android:viewportHeight="1" android:viewportWidth="1"> <group android:translateX="@dimen/viv_digit_translateX" android:translateY="@dimen/viv_digit_translateY"> <path android:name="iconPath" android:pathData="@string/viv_path_eight" android:strokeColor="@color/viv_digit_color_default" android:strokeWidth="@dimen/viv_digit_strokewidth"/> </group> </vector>
Кроме цифр 0-9 также также требуются изображения знака "минус" (viv_vd_pathmorph_digits_minus.xml) и пустое изображение (viv_vd_pathmorph_digits_nth.xml), которое будет символизировать исчезающий разряд числа во время анимации.
XML-файлы изображений отличаются только атрибутом android:pathData. Все остальные атрибуты для удобства задаются через отдельные ресурсы и одинаковы для всех векторных изображений.
Изображения для цифр 0-9 были взяты тут.
Анимация перехода
Описанные векторные изображения представляют собой статические изображения. Для анимации необходимо добавить анимированные векторные изображения (<animated-vector>). Например, для анимации цифры 2 в цифру 5 добавляем файл res/drawable/viv_avd_pathmorph_digits_2_to_5.xml:
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:drawable="@drawable/viv_vd_pathmorph_digits_zero"> <target android:name="iconPath"> <aapt:attr name="android:animation"> <objectAnimator android:duration="@integer/viv_animation_duration" android:propertyName="pathData" android:valueFrom="@string/viv_path_two" android:valueTo="@string/viv_path_five" android:valueType="pathType"/> </aapt:attr> </target> </animated-vector>
Здесь мы для удобства задаем длительность анимации через отдельный ресурс. Всего у нас есть 12 статических изображений (0 — 9 + "минус" + "пустота"), каждое из них может быть анимировано в любое из остальных. Получается, для полноты требуется 12 * 11 = 132 файла анимации. Отличаться они будут только атрибутами android:valueFrom и android:valueTo, и создавать их вручную — не вариант. Поэтому напишем простой генератор:
import java.io.File import java.io.FileWriter fun main(args: Array<String>) { val names = arrayOf( "zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "nth", "minus" ) fun getLetter(i: Int) = when (i) { in 0..9 -> i.toString() 10 -> "n" 11 -> "m" else -> null!! } val dirName = "viv_out" File(dirName).mkdir() for (from in 0..11) { for (to in 0..11) { if (from == to) continue FileWriter(File(dirName, "viv_avd_pathmorph_digits_${getLetter(from)}_to_${getLetter(to)}.xml")).use { it.write(""" <?xml version="1.0" encoding="utf-8"?> <animated-vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt" android:drawable="@drawable/viv_vd_pathmorph_digits_zero"> <target android:name="iconPath"> <aapt:attr name="android:animation"> <objectAnimator android:duration="@integer/viv_animation_duration" android:propertyName="pathData" android:valueFrom="@string/viv_path_${names[from]}" android:valueTo="@string/viv_path_${names[to]}" android:valueType="pathType"/> </aapt:attr> </target> </animated-vector> """.trimIndent()) } } } }
Все вместе
Теперь нужно связать статические векторные изображения и анимации переходов в одном файле <animated-selector>, который, как и обычный <selector>, отображает одно из изображений в зависимости от текущего состояния. Этот drawable-ресурс (res/drawable/viv_asl_pathmorph_digits.xml) содержит объявления состояний изображения и переходов между ними.
Состояния задаются тегами <item> с указанием изображения и атрибута состояния (в данном случае — app:viv_state_three), определяющего данное изображение. Каждое состояние имеет id, которое используется для определения требуемой анимации перехода:
<item android:id="@+id/three" android:drawable="@drawable/viv_vd_pathmorph_digits_three" app:viv_state_three="true" />
Атрибуты состояний задаются в файле res/values/attrs.xml:
<resources> <declare-styleable name="viv_DigitState"> <attr name="viv_state_zero" format="boolean" /> <attr name="viv_state_one" format="boolean" /> <attr name="viv_state_two" format="boolean" /> <attr name="viv_state_three" format="boolean" /> <attr name="viv_state_four" format="boolean" /> <attr name="viv_state_five" format="boolean" /> <attr name="viv_state_six" format="boolean" /> <attr name="viv_state_seven" format="boolean" /> <attr name="viv_state_eight" format="boolean" /> <attr name="viv_state_nine" format="boolean" /> <attr name="viv_state_nth" format="boolean" /> <attr name="viv_state_minus" format="boolean" /> </declare-styleable> </resources>
Анимации переходов между состояниями задаются тегами <transition> с указанием <animated-vector>, символизирующим переход, а также id начального и конечного состояния:
<transition android:drawable="@drawable/viv_avd_pathmorph_digits_6_to_2" android:fromId="@id/six" android:toId="@id/two" />
Содержимое res/drawable/viv_asl_pathmorph_digits.xml довольно-таки однотипно, и для его создания также использовался генератор. Этот drawable-ресурс состоит из 12 состояний и 132 переходов между ними.
CustomView
Теперь, когда у нас есть drawable, позволяющий отображать одну цифру и анимировать ее изменение, нужно создать VectorIntegerView, который будет содержать число, состоящее из нескольких разрядов, и управлять анимациями. В качестве основы был выбран RecyclerView, так как количество цифр в числе — величина переменная, а RecyclerView — это лучший в Android способ отображать переменное количество элементов (цифр) в ряд. Кроме того, RecyclerView позволяет управлять анимациями элементов через ItemAnimator.
DigitAdapter и DigitViewHolder
Начать необходимо с создания DigitViewHolder, содержащего одну цифру. View такого DigitViewHolder будет состоять из одного ImageView, у которого android:src="@drawable/viv_asl_pathmorph_digits". Для отображения нужной цифры в ImageView используется метод mImageView.setImageState(state, true);. Массив состояния state формируется исходя из отображаемой цифры с использованием атрибутов состояния viv_DigitState, определенных выше.
private static final int[] ATTRS = { R.attr.viv_state_zero, R.attr.viv_state_one, R.attr.viv_state_two, R.attr.viv_state_three, R.attr.viv_state_four, R.attr.viv_state_five, R.attr.viv_state_six, R.attr.viv_state_seven, R.attr.viv_state_eight, R.attr.viv_state_nine, R.attr.viv_state_nth, R.attr.viv_state_minus, }; void setDigit(@IntRange(from = 0, to = VectorIntegerView.MAX_DIGIT) int digit) { int[] state = new int[ATTRS.length]; for (int i = 0; i < ATTRS.length; i++) { if (i == digit) { state[i] = ATTRS[i]; } else { state[i] = -ATTRS[i]; } } mImageView.setImageState(state, true); }
Адаптер DigitAdapter отвечает за создание DigitViewHolder и за отображение нужной цифры в нужном DigitViewHolder.
Для корректной анимации превращения одного числа в другое используется DiffUtil. С его помощью разряд десятков анимируется в разряд десятков, сотни — в сотни, десятки миллионов — в десятки миллионов и так далее. Символ "минус" всегда остается сам собой и может только появляться или исчезать, превращаясь в пустое изображение (viv_vd_pathmorph_digits_nth.xml).
Для этого в DiffUtil.Callback в методе areItemsTheSame возвращается true только если сравниваются одинаковые разряды чисел. "Минус" является особым разрядом, и "минус" из предыдущего числа равен "минусу" из нового числа.
В методе areContentsTheSame сравниваются символы, стоящие на определенных позициях в предыдущем и новом числах. Саму реализацию можно увидеть в DigitAdapter.
DigitItemAnimator
Анимация изменения числа, а именно, превращение, появление и исчезновение цифр, будет контролироваться специальным аниматором для RecyclerView — DigitItemAnimator. Для определения продолжительности анимаций используется тот же integer-ресурс, что и в <animated-vector>, описанных выше:
private final int animationDuration; DigitItemAnimator(@NonNull Resources resources) { animationDuration = resources.getInteger(R.integer.viv_animation_duration); } @Override public long getMoveDuration() { return animationDuration; } @Override public long getAddDuration() { return animationDuration; } @Override public long getRemoveDuration() { return animationDuration; } @Override public long getChangeDuration() { return animationDuration; }
Основная часть DigitItemAnimator — это переопределение методов аминирования. Анимация появления цифры (метод animateAdd) выполняется как переход от пустого изображения к нужной цифре или знаку "минус". Анимация исчезновения (метод animateRemove) выполняется как переход от отображаемой цифры или знака "минус" к пустому изображению.
Для выполнения анимации изменения цифры сначала сохраняется информация о предыдущей отображаемой цифре с помощью переопределения метода recordPreLayoutInformation. После чего в методе animateChange выполняется переход от предыдущей отображаемой цифры к новой.
RecyclerView.ItemAnimator требует, чтобы при переопределении методов анимации обязательно вызывались методы, символизирующие окончание анимации. Поэтому в каждом из методов animateAdd, animateRemove и animateChange присутствует вызов соответствующего метода с задержкой, равной длительности анимации. К примеру, в методе animateAdd вызывается метод dispatchAddFinished с задержкой, равной @integer/viv_animation_duration:
@Override public boolean animateAdd(final RecyclerView.ViewHolder holder) { final DigitAdapter.DigitViewHolder digitViewHolder = (DigitAdapter.DigitViewHolder) holder; int a = digitViewHolder.d; digitViewHolder.setDigit(VectorIntegerView.DIGIT_NTH); digitViewHolder.setDigit(a); holder.itemView.postDelayed(new Runnable() { @Override public void run() { dispatchAddFinished(holder); } }, animationDuration); return false; }
VectorIntegerView
Перед созданием CustomView нужно определить его xml-атрибуты. Для этого добавим <declare-styleable> в файл res/values/attrs.xml:
<declare-styleable name="VectorIntegerView"> <attr name="viv_vector_integer" format="integer" /> <attr name="viv_digit_color" format="color" /> </declare-styleable>
Создаваемый VectorIntegerView будет иметь 2 xml-атрибута для кастомизации:
viv_vector_integerчисло, отображаемое при создании view (0 по умолчанию).viv_digit_colorцвет цифр (черный по умолчанию).
Другие параметры VectorIntegerView могут быть изменены через переопределение ресурсов в приложении (как это сделано в демо-приложении):
@integer/viv_animation_durationопределяет длительность анимации (400мс по умолчанию).@dimen/viv_digit_sizeопределяет размер одной цифры (24dpпо умолчанию).@dimen/viv_digit_translateXприменяется ко всем векторным изображениям цифр, чтобы выровнять их по горизонтали.@dimen/viv_digit_translateYприменяется ко всем векторным изображениям цифр, чтобы выровнять их по вертикали.@dimen/viv_digit_strokewidthприменяется ко всем векторным изображениям цифр.@dimen/viv_digit_margin_horizontalприменяется ко всем view цифр (DigitViewHolder) (-3dpпо умолчанию). Это нужно, чтобы сделать пробелы между цифрами меньше, так как векторные изображения цифр — квадратные.
Переопределенные ресурсы будут применены ко всем VectorIntegerView в приложении.
Все эти параметры задаются через ресурсы, так как изменение размеров VectorDrawable или длительности анимации AnimatedVectorDrawable через код невозможно.
Добавление VectorIntegerView в XML-разметку выглядит следующим образом:
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <com.qwert2603.vector_integer_view.VectorIntegerView android:id="@+id/vectorIntegerView" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="16dp" app:viv_digit_color="#ff8000" app:viv_vector_integer="14" /> </FrameLayout>
Впоследствии можно изменить отображаемое число в коде, передав BigInteger:
final VectorIntegerView vectorIntegerView = findViewById(R.id.vectorIntegerView); vectorIntegerView.setInteger( vectorIntegerView.getInteger().add(BigInteger.ONE), /* animated = */ true );
Ради удобства имеется метод для передачи числа типа long:
vectorIntegerView.setInteger(1918L, false);
Если в качестве animated передано false, то для адаптера будет вызван метод notifyDataSetChanged, и новое число будет отображено без анимаций.
При пересоздании VectorIntegerView отображаемое число сохраняется с использованием методов onSaveInstanceState и onRestoreInstanceState.
Исходники
Исходный код доступен на github (директория library). Там же находится demo приложение, использующее VectorIntegerView (директория app).
Также имеется демо-apk (minSdkVersion 21).
