Красивый и привлекательный 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
).