Своя библиотека под 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:

image

Если билд не был успешным, это можно определить по красной иконке вместо зелёной, по ней же будут доступны логи. Почему это может произойти? Дело в том, что для компиляции кода 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:

image

Но это уже совсем другая история 🙂


ссылка на оригинал статьи https://habr.com/ru/articles/739332/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *