Как стать автором
Обновить

Анимированные числа на Android

Время на прочтение9 мин
Количество просмотров7.4K

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


demo


На 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


Анимация изменения числа, а именно, превращение, появление и исчезновение цифр, будет контролироваться специальным аниматором для RecyclerViewDigitItemAnimator. Для определения продолжительности анимаций используется тот же 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).

Теги:
Хабы:
Всего голосов 16: ↑15 и ↓1+14
Комментарии12

Публикации

Истории

Работа

Java разработчик
358 вакансий
Веб дизайнер
24 вакансии

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань