Красивый и привлекательный UI — это важно. Поэтому для Android существует огромное количество библиотек для красивого отображения элементов дизайна. Часто в приложении требуется показать поле с числом или какой-либо счетчик. Например, счетчик количества выделенных элементов списка или сумму расходов за месяц. Конечно, такая задача легко решается с помощью обычного TextView
, но можно ее решить элегантно и еще анимацию изменения числа добавить:
На YouTube доступно Demo-видео.
В статье пойдет рассказ о том, как все это реализовать.
Одна статическая цифра
Для каждой из цифр имеется векторное изображение, например, для 8 это res
: 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
: 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
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
: 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
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
: 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мс по умолчанию).определяет размер одной цифры (
24dp
по умолчанию).применяется ко всем векторным изображениям цифр, чтобы выровнять их по горизонтали.
применяется ко всем векторным изображениям цифр, чтобы выровнять их по вертикали.
применяется ко всем векторным изображениям цифр.
применяется ко всем 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
).
Автор: qwert2603