Own library for Android in one evening

In the process of writing the article, it imperceptibly for me was transformed from a tutorial on publishing an Android project as a library into the most stuffy article on how mathematics was useful to a developer with a humanitarian background in drawing animations. The article is detailed, chewed, with many lines of code. Perhaps not for the faint of heart.

What if you had a need to use the same Jetpack Compose code across multiple projects, so that it would import the same and automatically on multiple machines? This situation is very likely to occur, because Compose does not shine with an abundance of widgets and tools provided out of the box (although their number is constantly growing). Perhaps your designer came to you with something so outlandish that ready-made components are simply not enough. Then the pipeline for developing and publishing your own library, which I will describe below, may be useful for you.

As an example, let’s take a not-so-obvious interface element — a button with a moving sine wave. Perfect for controlling, for example, voice input.

In the process of creating the library, I will use the Gradle Kotlin DSL, not Groovy. In Intellij Idea or Android Studio, create a library module (Project Structure -> New Module -> Android Library). We set the minimum version of the Android SDK to taste, but you should not set it lower than that of the projects in which the library will be used, otherwise it will not be imported later.

To make the button round, I decided to use an ordinary Row like this:

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

Before moving on to actually rendering the effect, I will make a reservation: everything that I had to do with animations in Jetpack Compose before was much easier. It would be very boring if all user cases could be exhaustively covered with all sorts of AnimatedVisibility And AnimatedContent, is not it? For this reason, the code below will most likely appear to some as experimental and/or with potential for optimization.

To start drawing an endless animation of anything, in my opinion, it’s worth it from a coroutine that will give out time. Let’s add a speed factor to this variable and get something like

val frequency = 4
var speed by remember { mutableStateOf(1f) }
val time by produceState(0f) {
        while (true) {
            withInfiniteAnimationFrameMillis {
                value = it / 1000f * speed
            }
        }
    }

Frequency I named it so because this variable determines the number of bends in the sinusoid visible to the user at a time.

Now let’s get to the drawing itself.

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

This simple code prepares parameters important for rendering, such as the center of the rendering plane, the distance between any two intersections of the x-axis wave (pointsDistance) and the distance between the two waves along the x axis (initialOffset). In the future, it is worth making the number of waves configurable, but for a start it will do 🙂

The most interesting thing is drawing the wave itself. It seems to me that it makes sense to decompose its algorithm like this:
1) calculation of the position of n points on the x-axis depending on time time and frequency 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) shifting each of these points a quarter step back to the right and left to unfold one full sine wave
3) calculation of the y coordinate for each x point on the normal distribution curve and mirroring the resulting value along the y axis

To determine the coordinate of a point on the normal distribution curve, we use the following function:

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

Finally, let’s collect the logic described above into a single ensemble with the rendering of Bezier curves and get the following Frankenstein:

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

The result is a laconic button with waves endlessly running inside it. It’s pretty, isn’t it?

It remains to publish the code as a gradle dependency. For this, in the root build.gradle.kts project, you need to add a few lines:

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

Now everything is ready for publication. Before submitting a build to the cloud repository, you should make sure that the library is published and imported locally:

./gradlew clean
./gradlew build
./gradlew publishToMavenLocal

To import in another project, just add mavenLocal() V repositories and the corresponding dependency in dependencies, of course. Next, we create and push a tag with the release version on GitHub:

git tag 1.0.0
git push --tags

In the web interface on the github, we create a new release (Releases -> Draft new release). Jitpack itself will pick up the source code of the main or master branch and pack it into a jar. To check if everything went well, in the Jitpack search box, enter the url of the GitHub repository:

image

If the build was not successful, this can be identified by the red icon instead of the green one, and the logs will be available from it. Why might this happen? The fact is that Jitpack uses Java version 1.8 to compile code, while our code is written under Java 11 or even 17. To fix this, just create a jitpack.yml file in the root of the project and enter the following into it:

jdk:
  - openjdk<ВАША_ВЕРСИЯ_ДЖАВЫ>

Everything, now the build is successful and you can use the library in any other project:

repositories {
   maven { url = uri("https://jitpack.io") }
}
dependencies {
   implementation("com.github.gleb-skobinsky:jetwidgets:1.0.0")
}

For example, you can make a button with speech input for a voice assistant with the skin of Chloe from Detroit Become Human:

image

But that’s a completely different story 🙂

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *