Drive your career as React Developer with Symphony Solutions!
×Закрыть

Как создавать кастомные 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), требующих создания множества новых файлов и кода в таком количестве, что это выглядело очень костыльно. Например, создать несколько XML-файлов, в каждом из которых определить Shape с градиентной заливкой внутри. Плюс создать еще один файл, который описывает анимацию между этими файлами. Для нашего случая не очень подходит.

Короче говоря, я так и не смог найти подходящего решения для своей задачи, поэтому придумал его самостоятельно. Мы создадим кастомный 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. Без них тоже можно, но будет проблематично, долго и некрасиво.

LinkedIn

4 комментария

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

По шейдерам неплохой видос есть www.youtube.com/watch?v=q2GtM1_RmMw

Юзал аналогичное решение на прошлом проекте, только прогресс был в виде сегмента круга, суть такая же, но менялся угол. Да, удобно, кратко и работоспособно.

Да, для такого 100% надо писать свои компоненты, а не извращаться с кастомизациями стандартных.
Код правда неплохо было бы причесать и некоторые места совсем сомнительные, как это например:

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)
}

Но в целом думаю многим статья действительно будет полезна.

Мне кажется, что подобным образом реализован анимированный placeholder в приложении Facebook.

Примерно. Реализация в опен сорсе у них.

Первый комментарий

Подписаться на комментарии