Своя библиотека под Android за один вечер
В процессе написания статьи она незаметно для меня трансформировалась из туториала по публикации Android-проекта как библиотеки в максимально душную статью о том, как математика пригодилась разработчику с гуманитарным бэкграундом в отрисовке анимашек. Статью подробную, разжеванную, с множеством строк кода. Возможно, не для слабонервных.
Что, если у вас появилась потребность использовать один и тот же код на Jetpack Compose между несколькими проектами, да еще так, чтобы он импортировался одинаково и автоматически на нескольких машинах? Такая ситуация может возникнуть с большой вероятностью, потому что Compose не блещет обилием предоставляемых из коробки виджетов и тулзов (хотя их количество постоянно растет). Быть может, ваш дизайнер пришел к вам с чем-то настолько диковинным, что готовыми компонентами просто не обойтись. Тогда тот пайплайн разработки и публикации собственной библиотеки, который я опишу ниже, может оказаться для вас полезным.
В качестве примера возьмем не самый очевидный элемент интерфейса — кнопку с движущейся синусоидоподобной волной. Отлично подойдет для управления, например, голосовым вводом.
В процессе создания библиотеки я буду пользоваться Gradle Kotlin DSL, а не Groovy. В Intellij Idea или Android Studio создаем модуль-библиотеку (Project Structure -> New Module -> Android Library). Минимальную версию Android SDK выставляем по вкусу, но не стоит ставить ниже, чем у проектов, в которых библиотека будет использоваться, иначе не пройдет ее импорт в последующем.
Чтобы сделать кнопку круглой, я решил использовать обыкновенный Row вот так:
val lightBlue = Color(173, 216, 230) Row( Modifier .padding(bottom = 24.dp) .size(size) .border(width = 1.dp, brush = SolidColor(lightBlue), shape = RoundedCornerShape(50)) .background( Brush.radialGradient( listOf( lightBlue, Color.Transparent, ) ), RoundedCornerShape(50) ) .pointerInput(Unit) { detectTapGestures( onDoubleTap = { focused = !focused speed = focused.toAnimationSpeed() onAction() } ) } .clip(RoundedCornerShape(50)) ) {}
Прежде чем перейти к собственно отрисовке эффекта, оговорюсь: все, что мне до этого приходилось делать с анимациями в Jetpack Compose, было намного проще. Было бы очень скучно, если бы все юзкейсы можно было бы исчерпывающе покрыть всякими AnimatedVisibility
и AnimatedContent
, не правда ли? По этой причине код ниже, скорее всего, покажется кому-то экспериментальным и/или имеющим потенциал для оптимизации.
Начать отрисовку бесконечной анимации чего угодно, на мой взгляд, стоит с корутины, которая будет выдавать время. Добавим к этой переменной коэффициент скорости и получим что-то вроде
val frequency = 4 var speed by remember { mutableStateOf(1f) } val time by produceState(0f) { while (true) { withInfiniteAnimationFrameMillis { value = it / 1000f * speed } } }
Frequency я назвал так потому, что эта переменная определяет количество изгибов синусоиды, видимых пользователю в момент времени.
Теперь приступим к самой отрисовке.
private fun Modifier.drawWaves(time: Float, frequency: Int) = drawBehind { // Calculate the mean of bell curve and the distance between each wriggle on x-axis val mean = size.width / 2 val pointsDistance = size.width / frequency // Calculate the initial offset between the three waves on x-axis val initialOffset = pointsDistance / 3 // Draw the three waves with different initial offsets. drawWave(frequency, pointsDistance, time, mean, -initialOffset) drawWave(frequency, pointsDistance, time, mean, 0f) drawWave(frequency, pointsDistance, time, mean, initialOffset) }
Этот нехитрый код готовит важные для отрисовки параметры, такие как центр плоскости отрисовки, расстояние между любыми двумя пересечениями волной оси x (pointsDistance
) и расстояние между двумя волнами по оси x (initialOffset
). В будущем стоит сделать количество волн настраиваемым, но для начала и так сойдет :)
Самое интересное — это отрисовка самой волны. Мне кажется, имеет смысл декомпозировать ее алгоритм так:
1) расчет положения n точек на оси x в зависимости от времени time и частоты frequency:
private fun constructXPoints( frequency: Int, pointsDistance: Float, time: Float, initialOffset: Float, ): MutableList<Float> { val points = mutableListOf<Float>() for (i in 0 until frequency) { val xMin = initialOffset + pointsDistance * i val addUp = time % 1 * pointsDistance val offsetX = xMin + addUp points.add(offsetX) } return points }
2) смещение каждой из этих точек на четверть шага назад вправо и влево для разворачивания одной полный волны синусоиды
3) расчет координаты y для каждой точки x на кривой нормального распределения и отзеркаливание полученного значения по оси y
Для определения координаты точки на кривой нормального распределения используем такую функцию:
private fun calculateY(x: Float, mean: Float, heightRatio: Float): Float { val stdDev = mean / 3 val exponent = -0.5 * ((x - mean) / stdDev).pow(2) val denominator = sqrt(2 * PI) return mean + (heightRatio * mean * exp(exponent) / denominator).toFloat() }
Наконец, соберем логику, описанную выше, в единый ансамбль с отрисовкой кривых Безье и получим такого Франкенштейна:
private fun DrawScope.drawWave( frequency: Int, pointsDistance: Float, time: Float, mean: Float, initialOffset: Float, heightRatio: Float = 1f, ) { // The step between wriggles val subStep = pointsDistance / 4 // Construct the X points of the wave using the given parameters. val xPoints = constructXPoints( frequency = frequency, pointsDistance = pointsDistance, time = time, initialOffset = initialOffset ) // Create a path object and populate it with the cubic Bézier curves that make up the wave. val strokePath = Path().apply { for (index in xPoints.indices) { val offsetX = xPoints[index] when (index) { 0 -> { // Move to the first point in the wave. val offsetY = calculateY(offsetX, mean, heightRatio) moveTo(offsetX - subStep, offsetY) } xPoints.indices.last -> { // Draw the last cubic Bézier curve in the wave. val sourceXNeg = xPoints[index - 1] + subStep val sourceYNeg = mean * 2 - calculateY(sourceXNeg, mean, heightRatio) val xMiddle = (sourceXNeg + offsetX) / 2f val targetOffsetX = offsetX + subStep val targetOffsetY = calculateY(targetOffsetX, mean, heightRatio) cubicTo(xMiddle, sourceYNeg, xMiddle, targetOffsetY, targetOffsetX, targetOffsetY) } else -> { // Draw the cubic Bézier curves between the first and last points in the wave. val sourceXNeg = xPoints[index - 1] + subStep val sourceYNeg = mean * 2 - calculateY(sourceXNeg, mean, heightRatio) val targetXPos = offsetX - subStep val targetYPos = calculateY(targetXPos, mean, heightRatio) val xMiddle1 = (sourceXNeg + targetXPos) / 2f cubicTo(xMiddle1, sourceYNeg, xMiddle1, targetYPos, targetXPos, targetYPos) val targetXNeg = offsetX + subStep val targetYNeg = mean * 2 - calculateY(targetXNeg, mean, heightRatio) val xMiddle2 = (targetXPos + targetXNeg) / 2f cubicTo(xMiddle2, targetYPos, xMiddle2, targetYNeg, targetXNeg, targetYNeg) } } } } // Draw the wave path. drawPath( path = strokePath, color = Color.White, style = Stroke( width = 2f, cap = StrokeCap.Round ) ) }
Результат — лаконичная кнопка с бесконечно бегущими внутри нее волнами. Симпатично, не так ли?
Остаётся опубликовать код как gradle-зависимость. Для этого в корневой build.gradle.kts
проекта нужно добавить несколько строк:
plugins { id("com.android.library") version "7.4.0" // или другая версия Android Gradle Plugin id("maven-publish") ... } android { ... publishing { multipleVariants { allVariants() withJavadocJar() withSourcesJar() } } } afterEvaluate { publishing { publications { create<MavenPublication>("mavenRelease") { groupId = "com.jetwidgets" artifactId = "jetwidgets" version = "1.0" from(components["release"]) } create<MavenPublication>("mavenDebug") { groupId = "com.jetwidgets" artifactId = "jetwidgets" version = "1.0" from(components["debug"]) } } } }
Теперь все готово к публикации. Перед отправкой билда в облачный репозиторий стоит убедиться, что библиотека публикуется и импортируется локально:
./gradlew clean ./gradlew build ./gradlew publishToMavenLocal
Для импорта в другом проекте достаточно просто добавить mavenLocal()
в repositories
и соответствующую зависимость в dependencies
, понятное дело. Дальше создаём и пушим тэг с версией релиза на GitHub:
git tag 1.0.0 git push --tags
В веб-интерфейсе на гитхабе создаём новый релиз (Releases -> Draft new release). Jitpack сам подхватит исходный код ветки main или master и упакует его в jar. Чтобы проверить, все ли прошло успешно, в поисковой строке Jitpack введем url репозитория с GitHub:
Если билд не был успешным, это можно определить по красной иконке вместо зелёной, по ней же будут доступны логи. Почему это может произойти? Дело в том, что для компиляции кода Jitpack использует версию Java 1.8, тогда как наш код написан под Java 11 или даже 17. Чтобы это исправить, достаточно создать файл jitpack.yml в корне проекта и вписать в него следующее:
jdk: - openjdk<ВАША_ВЕРСИЯ_ДЖАВЫ>
Все, теперь билд проходит успешно и можно использовать библиотеку в любом другом проекте:
repositories { maven { url = uri("https://jitpack.io") } } dependencies { implementation("com.github.gleb-skobinsky:jetwidgets:1.0.0") }
Например, можно сделать кнопку с речевым вводом для голосового ассистента со скином Хлои из Detroit Become Human: