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

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

    Поделиться публикацией
    Комментарии 12
      +1
      Всё это очень здорово и клёво и вообще «Вау!», но только первые два-три раза. Потом уже сидишь и с тоской ждёшь, пока вся эта анимация отанимирует и тебе наконец покажут то, ради чего ты запустил приложение.
        +1

        Спасибо за отзыв! При желании длительность анимации можно уменьшить до 250-300 мс, чтобы не напрягать юзеров.

          0

          Вспоминаются перелистывающиеся цифры часов на каком-то старинном телефоне.


          Типа таких: https://cdn.theunlockr.com/wp-content/uploads/2014/04/HTC-Sense-6.0.jpg


          Разблокируешь телефон, посмотреть время, они показывают то ли 00:00 то ли последнее время до блокировки и давай листать на текущее...

          +1
          Не дай бог когда-нибудь столкнуться с приложением с такой анимацией — боюсь, разобью в злости смартфон.
          Может изредка все-таки задумываться над действительно полезными новшествами? Все эти розовые ленточки и бантики хороши для девочек в детском садике…
            +2
            Это, что, дискриминация по возрастному признаку?!
              0
              … и гендерному!
                0
                Если разумность определяется возрастом и гендером — то да. Надоело, что дизайн давно стал синонимом тупости, но тупости красивенькой, с бантиками и рюшечками.
              +2
              Свистелки и перделки, а так же их родители )))
                +2
                Вам, возможно, и не нужна такая анимация. Но я как-то заглянул в мобилу жены друга. Вот там бы она была как раз кстати. Всегда можно в настройки приложения включить пункт с отключением этих «свистелок/перделок».
                Ресурс IT-шный и статья как раз в тему, спасибо автору.
                  0
                  Всегда можно в настройки приложения включить пункт с отключением этих «свистелок/перделок».
                  Можно. А можно не включать. Обычно такой пункт в настройки не добавляют. Даже там, где можно было бы.
                    0
                    Хороший разработчик тот, кто прислушивается к мнению пользователей своего приложения.
                  0
                  Timely — такая анимация у него с самой первой версии.

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое