В процессе написания статьи она незаметно для меня трансформировалась из туториала по публикации Android-проекта как библиотеки в максимально душную статью о том, как математика пригодилась разработчику с гуманитарным бэкграундом в отрисовке анимашек. Статью подробную, разжеванную, с множеством строк кода. Возможно, не для слабонервных.
Что, если у вас появилась потребность использовать один и тот же код на Jetpack Compose между несколькими проектами, да еще так, чтобы он импортировался одинаково и автоматически на нескольких машинах? Такая ситуация может возникнуть с большой вероятностью, потому что Compose не блещет обилием предоставляемых из коробки виджетов и тулзов (хотя их количество постоянно растет). Быть может, ваш дизайнер пришел к вам с чем-то настолько диковинным, что готовыми компонентами просто не обойтись. Тогда тот пайплайн разработки и публикации собственной библиотеки, который я опишу ниже, может оказаться для вас полезным.
В качестве примера возьмем не самый очевидный элемент интерфейса — кнопку с движущейся синусоидоподобной волной. Отлично подойдет для управления, например, голосовым вводом.
В процессе создания библиотеки я буду пользоваться Gradle Kotlin DSL, а не Groovy. В Intellij Idea или Android Studio создаем модуль-библиотеку (Project Structure -> New Module -> Android Library). Минимальную версию Android SDK выставляем по вкусу, но не стоит ставить ниже, чем у проектов, в которых библиотека будет использоваться, иначе не пройдет ее импорт в последующем.
Чтобы сделать кнопку круглой, я решил использовать обыкновенный Row вот так:
Прежде чем перейти к собственно отрисовке эффекта, оговорюсь: все, что мне до этого приходилось делать с анимациями в Jetpack Compose, было намного проще. Было бы очень скучно, если бы все юзкейсы можно было бы исчерпывающе покрыть всякими
Начать отрисовку бесконечной анимации чего угодно, на мой взгляд, стоит с корутины, которая будет выдавать время. Добавим к этой переменной коэффициент скорости и получим что-то вроде
Frequency я назвал так потому, что эта переменная определяет количество изгибов синусоиды, видимых пользователю в момент времени.
Теперь приступим к самой отрисовке.
Этот нехитрый код готовит важные для отрисовки параметры, такие как центр плоскости отрисовки, расстояние между любыми двумя пересечениями волной оси x (
Самое интересное — это отрисовка самой волны. Мне кажется, имеет смысл декомпозировать ее алгоритм так:
1) расчет положения n точек на оси x в зависимости от времени time и частоты frequency:
2) смещение каждой из этих точек на четверть шага назад вправо и влево для разворачивания одной полный волны синусоиды
3) расчет координаты y для каждой точки x на кривой нормального распределения и отзеркаливание полученного значения по оси y
Для определения координаты точки на кривой нормального распределения используем такую функцию:
Наконец, соберем логику, описанную выше, в единый ансамбль с отрисовкой кривых Безье и получим такого Франкенштейна:
Результат — лаконичная кнопка с бесконечно бегущими внутри нее волнами. Симпатично, не так ли?
Остаётся опубликовать код как gradle-зависимость. Для этого в корневой
Теперь все готово к публикации. Перед отправкой билда в облачный репозиторий стоит убедиться, что библиотека публикуется и импортируется локально:
Для импорта в другом проекте достаточно просто добавить
В веб-интерфейсе на гитхабе создаём новый релиз (Releases -> Draft new release). Jitpack сам подхватит исходный код ветки main или master и упакует его в jar. Чтобы проверить, все ли прошло успешно, в поисковой строке Jitpack введем url репозитория с GitHub:
Если билд не был успешным, это можно определить по красной иконке вместо зелёной, по ней же будут доступны логи. Почему это может произойти? Дело в том, что для компиляции кода Jitpack использует версию Java 1.8, тогда как наш код написан под Java 11 или даже 17. Чтобы это исправить, достаточно создать файл jitpack.yml в корне проекта и вписать в него следующее:
Все, теперь билд проходит успешно и можно использовать библиотеку в любом другом проекте:
Например, можно сделать кнопку с речевым вводом для голосового ассистента со скином Хлои из Detroit Become Human:
Но это уже совсем другая история :)
Что, если у вас появилась потребность использовать один и тот же код на 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:
Но это уже совсем другая история :)
Комментарии (4)
UltimateOrb
06.06.2023 16:39Я может пропустил, но что делают вот эти делегаты: by remember, by produceState ?
Rusrst
06.06.2023 16:39Это compose, remember это типа кэша локального внутри composable fun, a producerstate это специальный класс который генерирует новое состояние в соответствии с лямбдой
kavaynya
06.06.2023 16:39+1Позволяют использовать State который возвращают данные функции, без вызовов value.
Например в таком выражении:val number by remember { mutableStateOf(5) }
при обращении к
number
будете получать5,
без делегата пришлось бы обращаться такnumber.value
Rusrst
О, анимашки это всегда хорошо))) тоже люблю кастомные вещи рисовать, жаль не часто получается...
Compose к анимациям конечно дружелюбен.