Как создавать кастомные UI-элементы с анимацией в Android без тонны ненужного кода
Всем привет! Меня зовут Андрей, и я пишу Android-приложения в компании Genesis Media для наших медиапроектов в Африке. В этой статье расскажу о том, как создавать кастомные анимированные Android view c использованием шейдеров и матриц преобразований.
Этот текст будет полезен как начинающим, так и опытным Android-разработчикам, которые хотят улучшить свои навыки создания кастомных UI-элементов.
Графические Android-ограничения
Android предоставляет набор UI-элементов: карточки, floating action button, navigation menu и многие другие. Использовать только стандартные элементы для создания приложения — моветон. Плюс иногда дизайнер хочет видеть элементы не такими, какими они представлены в системе.
Например, у стандартного ProgressBar есть ограниченный набор параметров для изменения: цвет, ширина, высота и прочие. Но если дизайнер хочет чего-то особенного — анимированный градиент с блестками и единорогами, надо переписывать весь код, потому что нет подходящих параметров для кастомизации и невозможно ничего сделать в этом view-элементе. Поэтому мы создаем свой кастомный элемент.
Недавно мне поступила интересная задача: потребовалось создать что-то вроде кастомного ProgressBar. Вариантов выполнения было два: кастомизировать нативный андроидовский ProgressBar или написать что-то свое.
Если взглянуть на гифку, быстро становится понятно, что придется использовать именно второй вариант (из базовых элементов такое не соберешь).
Подумав пару минут и не найдя быстрого решения в своей голове, я обратился к чужим наработкам и идеям. Stack Overflow предлагал пару вариантов (link 1, link 2), требующих создания множества новых файлов и кода в таком количестве, что это выглядело очень костыльно. Например, создать несколько
Короче говоря, я так и не смог найти подходящего решения для своей задачи, поэтому придумал его самостоятельно. Мы создадим кастомный view-элемент, в котором сами на canvas отрисуем необходимое изображение, а потом анимируем его.
Реализуем кастомный UI-элемент
Создадим наш класс, унаследовав от View. В методе onMeasure будем определять размеры нашего GradientProgressBar. Очень удобно знать ширину и высоту GradientProgressBar, так как можно будет привязаться к этим размерам при отрисовке чего бы то ни было.
class GradientProgressBar @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : View(context, attrs, defStyleAttr) { private var parentWidth = 0f private var parentHeight = 0f override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) parentWidth = MeasureSpec.getSize(widthMeasureSpec).toFloat() parentHeight = MeasureSpec.getSize(heightMeasureSpec).toFloat() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) } }
Попробуем первым делом отрисовать подложку под наш ProgressBar. Она статическая и меняться не будет.
@ColorInt private val greyColor = -0x8F8F8F private val backgroundRect = RectF() private val paint = Paint() override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) parentWidth = MeasureSpec.getSize(widthMeasureSpec).toFloat() parentHeight = MeasureSpec.getSize(heightMeasureSpec).toFloat() backgroundRect.set(0f, 0f, parentWidth, parentHeight) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) paint.style = Paint.Style.FILL paint.isAntiAlias = true paint.color = greyColor canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, paint) }
Отлично. Теперь создадим шейдер, которым будем зарисовывать ProgressBar.
@ColorInt private val yellow = -0x006300 @ColorInt private val orange = -0x0021e9 private fun createShader(): Shader { return LinearGradient(0f, 0f, 300f, parentHeight, yellow, orange, Shader.TileMode.REPEAT) }
Шейдер описывает изменение цвета в пространстве. От точки с координатами (0, 0) до точки (300f, parentHeight
) цвет будет линейно меняться от yellow до orange. Но что будет дальше, за пределами указанного нами диапазона? Shader.TileMode.REPEAT
— этот параметр говорит, что дальше шейдер будет просто повторяться. Нас это устраивает.
Применим шейдер к экземпляру класса Paint и нарисуем прямоугольник до середины длины нашего view-элемента поверх серой подложки.
private val progressRect = RectF() private val progressPaint = Paint() private val shader = createShader() override fun onDraw(canvas: Canvas) { super.onDraw(canvas) paint.style = Paint.Style.FILL paint.isAntiAlias = true progressPaint.isAntiAlias = true progressPaint.style = Paint.Style.FILL paint.color = greyColor canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, paint) progressPaint.shader = shader progressRect.set(0f, 0f, parentWidth/2f , parentHeight) canvas.drawRoundRect(progressRect, parentHeight/2f, parentHeight/2f, progressPaint) }
Но как теперь анимировать наш шейдер?
Рубрика «Вредные советы». Пересоздаем шейдер с новыми параметрами:
LinearGradient(10f, 0f, 310f, parentHeight, yellow, orange, Shader.TileMode.REPEAT)
Тут шейдер сдвинут на десять пикселей вправо по сравнению с предыдущим вариантом. По идее, можно было бы анимировать путем пересоздания шейдера, но garbage collector спасибо не скажет. Метод onDraw вызывается 60 раз в секунду, а это значит, что 60 раз в секунду будет создаваться новый экземпляр шейдера — это не очень хорошо.
Поэтому залезем в исходники класса Shader. Бросается в глаза наличие поля и метода:
private Matrix mLocalMatrix; public void setLocalMatrix(@Nullable Matrix localM)
А значит, мы можем изменять наш уже созданный экземпляр шейдера, не создавая новый. Класс Matrix позволяет задавать различные виды преобразований: сдвиг, поворот, сжатие и т. д.
Заводим еще пару полей:
private val transformMatrix = Matrix() private val rotationAngle = 30f private var matrixTransitionOffset = 10f override fun onDraw(canvas: Canvas) { super.onDraw(canvas) paint.style = Paint.Style.FILL paint.isAntiAlias = true progressPaint.isAntiAlias = true progressPaint.style = Paint.Style.FILL paint.color = greyColor canvas.drawRoundRect(backgroundRect, cornerRadius, cornerRadius, paint)
Зададим матрице сдвиг по оси Х:
transformMatrix.setTranslate(matrixTransitionOffset, 0f)
А заодно и поворот:
transformMatrix.postRotate(rotationAngle)
Применяем матрицу к нашему шейдеру:
shader.setLocalMatrix(transformMatrix) progressPaint.shader = shader progressRect.set(0f, 0f, parentWidth/2f , parentHeight) canvas.drawRoundRect(progressRect, parentHeight/2f, parentHeight/2f, progressPaint) }
Для анимации сдвига надо изменять значение поля matrixTransitionOffset. Изменять будем от нуля до длины шейдера (в нашем случае это 300 пикселей). Так удастся добиться эффекта постоянно бегущего градиента.
private var transitionAnimator: ValueAnimator? = null private var matrixTransitionOffset = 0f set(value) { field = value postInvalidateOnAnimation() } override fun onAttachedToWindow() { super.onAttachedToWindow() transitionAnimator = ValueAnimator.ofFloat(0f, 300f).apply { addUpdateListener { matrixTransitionOffset = it.animatedValue as Float } duration = 500L repeatMode = ValueAnimator.RESTART repeatCount = ValueAnimator.INFINITE interpolator = LinearInterpolator() start() } } override fun onDetachedFromWindow() { transitionAnimator?.cancel() super.onDetachedFromWindow() }
Аналогичным образом не составляет труда анимировать значение длины ProgressBar. Да и любой другой параметр.
Итого
На решение подобных задач этим способом вы потратите максимум час, а элемент займет не более чем 20 строчек кода. Вот полная реализация. Мне кажется, что подобным образом реализован анимированный placeholder в приложении Facebook.
Теперь вы можете создавать свои кастомные графические элементы с помощью классов Shader, Gradient и Matrix. Без них тоже можно, но будет проблематично, долго и некрасиво.