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

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

demo

На YouTube доступно Demo-видео.

В статье пойдет рассказ о том, как все это реализовать.


Одна статическая цифра

Для каждой из цифр имеется векторное изображение, например, для 8 это res/drawable/viv_vd_pathmorph_digits_eight.xml:



    
        
    


Кроме цифр 0–9 также также требуются изображения знака «минус» (viv_vd_pathmorph_digits_minus.xml) и пустое изображение (viv_vd_pathmorph_digits_nth.xml), которое будет символизировать исчезающий разряд числа во время анимации.
XML-файлы изображений отличаются только атрибутом android:pathData. Все остальные атрибуты для удобства задаются через отдельные ресурсы и одинаковы для всех векторных изображений.
Изображения для цифр 0–9 были взяты тут.


Анимация перехода

Описанные векторные изображения представляют собой статические изображения. Для анимации необходимо добавить анимированные векторные изображения (). Например, для анимации цифры 2 в цифру 5 добавляем файл res/drawable/viv_avd_pathmorph_digits_2_to_5.xml:



  
    
      
    
  


Здесь мы для удобства задаем длительность анимации через отдельный ресурс. Всего у нас есть 12 статических изображений (0 — 9 + «минус» + «пустота»), каждое из них может быть анимировано в любое из остальных. Получается, для полноты требуется 12×11 = 132 файла анимации. Отличаться они будут только атрибутами android:valueFrom и android:valueTo, и создавать их вручную — не вариант. Поэтому напишем простой генератор:


Генератор файлов анимации
import java.io.File
import java.io.FileWriter

fun main(args: Array) {
    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("""



  
    
      
    
  



""".trimIndent())
            }
        }
    }
}


Все вместе

Теперь нужно связать статические векторные изображения и анимации переходов в одном файле , который, как и обычный , отображает одно из изображений в зависимости от текущего состояния. Этот drawable-ресурс (res/drawable/viv_asl_pathmorph_digits.xml) содержит объявления состояний изображения и переходов между ними.

Состояния задаются тегами с указанием изображения и атрибута состояния (в данном случае — app:viv_state_three), определяющего данное изображение. Каждое состояние имеет id, которое используется для определения требуемой анимации перехода:


Атрибуты состояний задаются в файле res/values/attrs.xml:


    
        
        
        
        
        
        
        
        
        
        
        
        
    

Анимации переходов между состояниями задаются тегами с указанием , символизирующим переход, а также id начального и конечного состояния:


Содержимое 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-ресурс, что и в , описанных выше:

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-атрибуты. Для этого добавим в файл res/values/attrs.xml:


    
    

Создаваемый 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-разметку выглядит следующим образом:



    


Впоследствии можно изменить отображаемое число в коде, передав 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).

© Habrahabr.ru