1. Background


Recently participated in the ultimate challenge of the Compose Challenge and completed a weather app using Compose. I also participated in the previous rounds of challenges and learned a lot of new things each time. I hope that in this last round of challenges, I can make use of the accumulation of this period of time to make more mature works.

Project challenges

Since there is no artist to assist me, I consider implementing all UI elements such as ICONS in the app through code, so that the UI will not be distorted at any resolution, and can be more flexible to complete various animation effects.

In order to reduce the implementation cost, I define the UI elements in the app as cartoonish, which is more conducive to code implementation:

Instead of using GIF, Lottie, etc., the animation above is drawn based on the Compose code.

MyApp: CuteWeather


The App interface is simple, with a single page (which is also required for the challenge), and you can view the weather information and temperature trends of the last week.

Project address: github.com/vitaviva/co…

Cartoon-style weather animations are a feature of the app compared to its competitors. This article will focus on these weather animations, and show you how to draw custom graphics using Compose and animate based on them.


2. Compose custom drawing


Like regular Android development, in addition to the various default Composable controls, Compose also provides a Canvas for drawing custom graphics.

The Canvas API is pretty much the same across platforms, but for Compose it has the following features:

  • Create and use in a declarative mannerCanvas
  • throughDrawScopeProvide the necessary state and various APIs
  • The API is simpler to use

Create and use Canvas declaratively

In Compose, the Canvas as a Composable can be added declaratively to other composables and configured via Modifier

Canvas(modifier = Modifier.fillMaxSize()){ // this: DrawScope 
	// Internal custom drawing
}
Copy the code

The traditional approach requires obtaining a Canvas handle to draw assertively, while the Canvas{… } refresh the UI by executing the drawing logic within the block in a state-driven manner.

Powerful DrawScope

Canvas{… } with DrawScope, you can use the DrawScope to provide the state required for the current drawing. DrawScope also includes a variety of common drawing apis, such as drawLine

Canvas(modifier = Modifier.fillMaxSize()){
	// Obtain the current canvas width and height from size
    val canvasWidth = size.width
    val canvasHeight = size.height

	// Draw the line
    drawLine(
        start = Offset(x=canvasWidth, y = 0f),
        end = Offset(x = 0f, y = canvasHeight),
        color = Color.Blue,
        strokeWidth = 5F // Set the line width)}Copy the code

The above code is drawn as follows:

Easy to use API

While the traditional Canvas API requires Paint configuration, the DrawScope API is simpler and more user-friendly.

For example, to draw a circle, the traditional API looks like this:

public void drawCircle(float cx, float cy, float radius, @NonNull Paint paint) {
	/ /...
}
Copy the code

DrawScope provides API:

fun drawCircle(
    color: Color,
    radius: Float = size.minDimension / 2.0f,
    center: Offset = this.center,
    alpha: Float = 1.0f,
    style: DrawStyle = Fill,
    colorFilter: ColorFilter? = null,
    blendMode: BlendMode = DefaultBlendMode
){... }Copy the code

It looks like there are a lot of parameters, but you’ve already set the proper default values, such as size, and you don’t have to create or configure Paint, so it’s easier to use.

Use native Canvas

At present, the API provided by DrawScope is not as rich as that of native Canvas (for example, drawText is not supported). When the usage requirements are not met, the native Canvas object can also be directly used for drawing

   drawIntoCanvas { canvas ->
            //nativeCanvas is a nativeCanvas object, android platform is android.graphics.canvas
            val nativeCanvas  = canvas.nativeCanvas
            
        }
Copy the code

Below is a brief introduction to Canvas in Compose. Let’s take a look at the actual use of Canvas in app

First, take a look at the rainwater drawing process.


3. Rainy day effect


The key to rainy weather is how to map falling rain

Drawing raindrops

Let’s start by drawing the basic unit that makes up rain: the raindrop

When disassembled, the rain effect can be made up of three groups of raindrops, each with two upper and lower sections, so as to create a continuous effect as they move.

We draw each black line using drawLine, set the appropriate stokeWidth, and use cap to set the rounded effect of the endpoints:

@Composable
fun rainDrop(a) {

	Canvas(modifier) {

       val x: Float = size.width / 2 // The x coordinate is the position of 1/2
        
        drawLine(
            Color.Black,
            Offset(x, line1y1), / /, line1 starting point
            Offset(x, line1y2), The end of the / /, line1
            strokeWidth = width, // Set the width
            cap = StrokeCap.Round// Round head
        )

		/ / the above line2
        drawLine(
            Color.Black,
            Offset(x, line2y1),
            Offset(x, line2y2),
            strokeWidth = width,
            cap = StrokeCap.Round
        )
    }
}
Copy the code

Raindrop Falling animation

After completing the basic drawing of the raindrop, the next step is to add a displacement animation for the two line segments to create a flowing effect.

Take the gap between the two lines as the anchor point of the animation, change its Y-axis position according to the animationState, move from the top to the bottom of the canvas (0 ~ size.hight), and then restart the animation.

Then draw the upper and lower segments based on the anchor point, and the animation effect is continuous

The code is as follows:

@Composable
fun rainDrop(a) {
	// Loop animation (0f ~ 1f)
    val animateTween by rememberInfiniteTransition().animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            tween(durationMillis, easing = LinearEasing),
            RepeatMode.Restart / / start the animation
        )
    )

    Canvas(modifier) {

        // scope: draw the region
        val width = size.width
        val x: Float = size.width / 2

 		// width/2 is the width of the strokCap. The width of the strokCap is reserved at the scopeHeight
        val scopeHeight = size.height - width / 2 

        // space: the gap between two line segments
        val space = size.height / 2.2 f + width / 2 / / gap size
        val spacePos = scopeHeight * animateTween // Anchor position varies with the animationState
        val sy1 = spacePos - space / 2
        val sy2 = spacePos + space / 2

        // line length
        val lineHeight = scopeHeight - space

        // line1
        val line1y1 = max(0f, sy1 - lineHeight)
        val line1y2 = max(line1y1, sy1)

        // line2
        val line2y1 = min(sy2, scopeHeight)
        val line2y2 = min(line2y1 + lineHeight, scopeHeight)

        // draw
        drawLine(
            Color.Black,
            Offset(x, line1y1),
            Offset(x, line1y2),
            strokeWidth = width,
            colorFilter = ColorFilter.tint(
                Color.Black
            ),
            cap = StrokeCap.Round
        )

        drawLine(
            Color.Black,
            Offset(x, line2y1),
            Offset(x, line2y2),
            strokeWidth = width,
            colorFilter = ColorFilter.tint(
                Color.Black
            ),
            cap = StrokeCap.Round
        )
    }
}

Copy the code

Compose custom layout

Having animated the single raindrop, we then use three raindrops to create the rain effect.

First of all, we can use Row+Space to assemble, but this method lacks flexibility. It is difficult to accurately arrange the relative positions of the three raindrops only by using Modifier, so we should consider using the custom layout of Compose to improve flexibility and accuracy:

Layout(
    modifier = modifier.rotate(30f), // Rotation Angle of raindrop
    content = { // Define a subcomposable
		Raindrop(modifier.fillMaxSize())
		Raindrop(modifier.fillMaxSize())
		Raindrop(modifier.fillMaxSize())
    }
) { measurables, constraints ->
    // List of measured children
    val placeables = measurables.mapIndexed { index, measurable ->
        // Measure each children
        val height = when (index) { // Let the height of the three raindrops be different
            0 -> constraints.maxHeight * 0.8 f
            1 -> constraints.maxHeight * 0.9 f
            2 -> constraints.maxHeight * 0.6 f
            else -> 0f
        }
        measurable.measure(
            constraints.copy(
                minWidth = 0,
                minHeight = 0,
                maxWidth = constraints.maxWidth / 10.// raindrop width
                maxHeight = height.toInt(),
            )
        )
    }

    // Set the size of the layout as big as it can
    layout(constraints.maxWidth, constraints.maxHeight) {
        var xPosition = constraints.maxWidth / ((placeables.size + 1) * 2)

        // Place children in the parent layout
        placeables.forEachIndexed { index, placeable ->
            // Position item on the screen
            placeable.place(x = xPosition, y = 0)

            // Record the y co-ord placed up to
            xPosition += (constraints.maxWidth / ((placeables.size + 1) * 0.8 f)).roundToInt()
        }
    }
}
Copy the code

For Compose, you can use Layout{… } Make a Composable layout with content{… } defines a subcomposable that participates in the layout.

Just like the traditional Android view, the custom layout needs to go through measure and Layout.

  • measrue:measurablesReturns all subcomposables to be measured,constraintsSimilar to theMeasureSpec, encapsulates the parent container’s layout constraints on child elements.measurable.measure()The child elements are measured in
  • layout:placeablesReturns the measured child elements, called in turnplaceable.place()Lay out the raindrops and passxPositionReserve the space between raindrops on the x axis

After layout, rotate the Composable with modifier.rotate(30f) to complete the final effect:


4. Snow effect


The key to a snow day’s effect is how the snow falls.

Drawing snowflakes

Drawing snowflakes is very simple, with a circle representing a snowflake

Canvas(modifier) {

	val radius = size / 2
	
	drawCircle( // White fill
		color = Color.White,
		radius = radius,
		style = FILL
	)

	 drawCircle(// Black border
	 	color = Color.Black,
	    radius = radius,
		style = Stroke(width = radius * 0.5 f))}Copy the code

Animation of Snow Falling

The process of falling snow is more complicated than falling rain, and consists of three animations:

  1. Fall: Change the y axis coordinate: 0f ~ 2.5F
  2. Drift left and right: change the offset of the X-axis: -1f ~ 1f
  3. Gradually disappear: change alpha: 1F ~ 0F

Control multiple animations synchronously with the InfiniteTransition.

@Composable
private fun Snowdrop(
	modifier: Modifier = Modifier,
	durationMillis: Int = 1000 // Druation
) {

	// Loop the Transition
    val transition = rememberInfiniteTransition()

	//1. Drop animation: restart animation
    val animateY by transition.animateFloat(
        initialValue = 0f,
        targetValue = 2.5 f,
        animationSpec = infiniteRepeatable(
            tween(durationMillis, easing = LinearEasing),
            RepeatMode.Restart
        )
    )

	//2. Drift left and right: Reverse animation
    val animateX by transition.animateFloat(
        initialValue = -1f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            tween(durationMillis / 3, easing = LinearEasing),
            RepeatMode.Reverse
        )
    )

	//3. Alpha value: restart animation, ending at 0f
    val animateAlpha by transition.animateFloat(
        initialValue = 1f,
        targetValue = 0f,
        animationSpec = infiniteRepeatable(
            tween(durationMillis, easing = FastOutSlowInEasing),
        )
    )

    Canvas(modifier) {

        val radius = size.width / 2

		// The center of the circle is changed depending on the AnimationState to achieve the effect of falling snow
        val _center = center.copy(
            x = center.x + center.x * animateX,
            y = center.y + center.y * animateY
        )

        drawCircle(
            color = Color.White.copy(alpha = animateAlpha),// Change the alpha value to make the snowflake disappear
            center = _center,
            radius = radius,
        )

        drawCircle(
            color = Color.Black.copy(alpha = animateAlpha),
            center = _center,
            radius = radius,
            style = Stroke(width = radius * 0.5 f))}}Copy the code

The animateY targetValue is set to 2.5f to make the snowflake’s trajectory longer and more realistic

Custom layout of snowflakes

Just like raindrops, use Layout to customize the Layout for snowflakes

@Composable
fun Snow(
    modifier: Modifier = Modifier,
    animate: Boolean = false.) {

    Layout(
        modifier = modifier,
        content = {
        	// Place three snowflakes with different duration to add randomness
            Snowdrop( modifier.fillMaxSize(), 2200)
            Snowdrop( modifier.fillMaxSize(), 1600)
            Snowdrop( modifier.fillMaxSize(), 1800)
        }
    ) { measurables, constraints ->
        val placeables = measurables.mapIndexed { index, measurable ->
            val height = when (index) {
            	// The height of the snowflake is different, also to increase randomness
                0 -> constraints.maxHeight * 0.6 f
                1 -> constraints.maxHeight * 1.0 f
                2 -> constraints.maxHeight * 0.7 f
                else -> 0f
            }
            measurable.measure(
                constraints.copy(
                    minWidth = 0,
                    minHeight = 0,
                    maxWidth = constraints.maxWidth / 5.// snowdrop width
                    maxHeight = height.roundToInt(),
                )
            )
        }

        layout(constraints.maxWidth, constraints.maxHeight) {
            var xPosition = constraints.maxWidth / ((placeables.size + 1))

            placeables.forEachIndexed { index, placeable ->
                placeable.place(x = xPosition, y = -(constraints.maxHeight * 0.2).roundToInt())

                xPosition += (constraints.maxWidth / ((placeables.size + 1) * 0.9 f)).roundToInt()
            }
        }
    }
}
Copy the code

The final result is as follows:


5. Sunny day effect


The sunny day effect is represented by a rotating sun

Drawing the Sun

The figure of the sun consists of a central circle and bisecting segments around the ring.

@Composable
fun Sun(modifier: Modifier = Modifier) {

    Canvas(modifier) {

        val radius = size.width / 6
        val stroke = size.width / 20

        // draw circle
        drawCircle(
            color = Color.Black,
            radius = radius + stroke / 2,
            style = Stroke(width = stroke),
        )
        drawCircle(
            color = Color.White,
            radius = radius,
            style = Fill,
        )

        // draw line

        val lineLength = radius * 0.2 f
        val lineOffset = radius * 1.8 f
        (0.7.).forEach { i ->

            val radians = Math.toRadians(i * 45.0)

            val offsetX = lineOffset * cos(radians).toFloat()
            val offsetY = lineOffset * sin(radians).toFloat()

            val x1 = size.width / 2 + offsetX
            val x2 = x1 + lineLength * cos(radians).toFloat()

            val y1 = size.height / 2 + offsetY
            val y2 = y1 + lineLength * sin(radians).toFloat()

            drawLine(
                color = Color.Black,
                start = Offset(x1, y1),
                end = Offset(x2, y2),
                strokeWidth = stroke,
                cap = StrokeCap.Round
            )
        }
    }
}
Copy the code

Divide 360 degrees evenly, draw a line segment every 45 degrees, calculate the x coordinate cosine, calculate the y coordinate sine.

Rotation of the sun

The rotation animation of the sun is very simple. Rotate the Canvas through Modifier. Rotate.

@Composable
fun Sun(modifier: Modifier = Modifier) {

	// Loop animation
    val animateTween by rememberInfiniteTransition().animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(tween(5000), RepeatMode.Restart)
    )

    Canvas(modifier.rotate(animateTween)) {// Rotate the animation

        val radius = size.width / 6
        val stroke = size.width / 20
        val centerOffset = Offset(size.width / 30, size.width / 30) // The center offset

        // draw circle
        drawCircle(
            color = Color.Black,
            radius = radius + stroke / 2,
            style = Stroke(width = stroke),
            center = center + centerOffset // The center of the circle is shifted
        )
       	
       	/ /... slightly}}Copy the code

In addition, DrawScope provides the ROTATE API and can also achieve the rotation effect.

Finally, we add an offset to the sun’s center to make the rotation more dynamic:


6. Animation combination and switching


After you’ve implemented Rain, Snow, Sun, etc., you can use these graphics to combine various weather effects.

Combine the graphics into weather

Compose’s declarative syntax is very good for composing UI:

For example, cloudy to shower, we put Sun, Cloud, Rain and other elements, through the Modifier to adjust their position:

@Composable
fun CloudyRain(modifier: Modifier) {
	Box(modifier.size(200.dp)){
		Sun(Modifier.size(120.dp).offset(140.dp, 40.dp))
		Rain(Modifier.size(80.dp).offset(80.dp, 60.dp))
		Cloud(Modifier.align(Aligment.Center))
	}
}
Copy the code

Make animation transitions more natural

When switching between multiple weather animations, we wanted a more natural transition. The idea is to make up the weather Animation elements of the Modifier configuration variable, and then change through the Animation

Suppose all weather is composed of Cloud, Sun, Rain, and offset, size, and alpha:

ComposeInfo

data class IconInfo(
    val size: Float = 1f.val offset: Offset = Offset(0f.0f),
    val alpha: Float = 1f.)Copy the code
// Weather combination information, i.e. Sun, Cloud, Rain location information
data class ComposeInfo(
    val sun: IconInfo,
    val cloud: IconInfo,
    val rains: IconInfo,

) {
    operator fun times(float: Float): ComposeInfo =
        copy(
            sun = sun * float,
            cloud = cloud * float,
            rains = rains * float
        )

    operator fun minus(composeInfo: ComposeInfo): ComposeInfo =
        copy(
            sun = sun - composeInfo.sun,
            cloud = cloud - composeInfo.cloud,
            rains = rains - composeInfo.rains,
        )

    operator fun plus(composeInfo: ComposeInfo): ComposeInfo = copy( sun = sun + composeInfo.sun, cloud = cloud + composeInfo.cloud, rains = rains + composeInfo.rains, )}Copy the code

As mentioned above, ComposeInfo holds the position information of various elements, and the operator overload is used to follow the Animation to calculate the current latest value.

The ComposeInfo that defines the different weather is as follows:

/ / sunny day
val SunnyComposeInfo = ComposeInfo(
    sun = IconInfo(1f),
    cloud = IconInfo(0.8 f, Offset(-0.1 f.0.1 f), 0f),
    rains = IconInfo(0.4 f, Offset(0.225 f.0.3 f), 0f),/ / cloudy
val CloudyComposeInfo = ComposeInfo(
    sun = IconInfo(0.1 f, Offset(0.75 f.0.2 f), alpha = 0f),
    cloud = IconInfo(0.8 f, Offset(0.1 f.0.1 f)),
    rains = IconInfo(0.4 f, Offset(0.225 f.0.3 f), alpha = 0f),/ / rainy days
val RainComposeInfo = ComposeInfo(
    sun = IconInfo(0.1 f, Offset(0.75 f.0.2 f), alpha = 0f),
    cloud = IconInfo(0.8 f, Offset(0.1 f.0.1 f)),
    rains = IconInfo(0.4 f, Offset(0.225 f.0.3 f), alpha = 1f),Copy the code

ComposedIcon

Next, define the ComposedIcon and consume the ComposeInfo UI to draw the weather combination

@Composable
fun ComposedIcon(modifier: Modifier = Modifier, composeInfo: ComposeInfo) {

	// ComposeInfo for each element
    val (sun, cloud, rains) = composeInfo
    
    Box(modifier) {

		// Apply ComposeInfo to Modifier
        val _modifier = remember(Unit) {
            { icon: IconInfo ->
                Modifier
                    .offset( icon.size * icon.offset.x, icon.size * icon.offset.y )
                    .size(icon.size)
                    .alpha(icon.alpha)
            }
        }

        Sun(_modifier(sun))
        Rains(_modifier(rains))
        AnimatableCloud(_modifier(cloud))
    }
}
Copy the code

ComposedWeather

Finally, define ComposedWeather to update the current ComposedIcon with animation:

@Composable
fun ComposedWeather(modifier: Modifier, composedIcon: ComposedIcon) {

    val (cur, setCur) = remember { mutableStateOf(composedIcon) }
    var trigger by remember { mutableStateOf(0f) }

    DisposableEffect(composedIcon) {
        trigger = 1f
        onDispose { }
    }

	// Create animation (0f ~ 1f) to update ComposeInfo
    val animateFloat by animateFloatAsState(
        targetValue = trigger,
        animationSpec = tween(1000)) {// When the animation ends, update the ComposeWeather to the latest state
        setCur(composedIcon)
        trigger = 0f
    }

	// Calculate the current ComposeInfo from AnimationState
    val composeInfo = remember(animateFloat) {
        cur.composedIcon + (weatherIcon.composedIcon - cur.composedIcon) * animateFloat
    }

	// Display the Icon with the latest ComposeInfo
    ComposedIcon(
        modifier,
        composeInfo
    )
}
Copy the code

At this point, we have achieved the natural transition of weather animation.


7. The last


Compose implements custom graphics and animations in a declarative way, which is much simpler than imperative code. This also makes it possible to use code to replace GIF and other animations and expressions, and the effect drawn by code is much better than GIF in terms of clarity and frame rate. Welcome to download the source code experience.

Of course, in my opinion, the essence of Compose is much more than the UI. I will share more about the architecture and the underlying implementation in the future. I hope to learn and discuss with you.

A link to the

MyApp#CuteWeather

Compose#Canvas

Compose#CustomLayout