Красивий і привабливий 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
, визначених вище.
Відображення потрібної цифри в `ImageView`
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).
Також є демо-апк (minSdkVersion 21
).