В процессе написания статьи она незаметно для меня трансформировалась из туториала по публикации 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

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

Комментарии (4)


  1. Rusrst
    06.06.2023 16:39

    О, анимашки это всегда хорошо))) тоже люблю кастомные вещи рисовать, жаль не часто получается...

    Compose к анимациям конечно дружелюбен.


  1. UltimateOrb
    06.06.2023 16:39

    Я может пропустил, но что делают вот эти делегаты: by remember, by produceState ?


    1. Rusrst
      06.06.2023 16:39

      Это compose, remember это типа кэша локального внутри composable fun, a producerstate это специальный класс который генерирует новое состояние в соответствии с лямбдой


    1. kavaynya
      06.06.2023 16:39
      +1

      Позволяют использовать State который возвращают данные функции, без вызовов value.
      Например в таком выражении:

      val number by remember { mutableStateOf(5) }

      при обращении к number будете получать 5, без делегата пришлось бы обращаться так number.value