Wonderful! Draw lovely weather animation with Jetpack Compose!

Posted by cgraz on Thu, 20 Jan 2022 11:40:50 +0100

1. Project 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 every time. Now we are facing the final challenge. We hope to make use of this period of accumulation and make more mature works.

Project challenges

Because there is no artist assistance, I consider implementing all UI elements in the app through code, such as various icon s. Such UI will not be distorted at any resolution. More importantly, it can flexibly realize various animation effects.

In order to reduce the implementation cost, I define the UI elements in the app as cartoon style, which can be more easily realized through proxy drawing:

The above animation does not use gif, lottie or other static resources. All graphics are drawn based on Compose code.

2. MyApp: CuteWeather

The app interface is relatively simple, with single page presentation (challenge requirements). Cartoon style weather animation is a feature compared with similar apps:

Project address: https://github.com/vitaviva/c...

App interface composition

App is vertically divided into several functional areas, and each area involves the use of some different compose APIs

There are many technical points involved. This paper mainly introduces how to use Compose to draw custom graphics and realize animation based on these graphics. Other contents will be introduced separately.

3. Compose custom drawing

Like conventional Android development, in addition to providing various default Composable controls, Compose also provides Canvas to draw custom UI.

In fact, Canvas related API s are similar on all platforms, but the use of Compose has the following characteristics:

  • Create and use Canvas declaratively
  • Provide necessary state and APIs through DrawScope
  • API is simpler and easier to use
Declaratively create and use Canvas

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

Canvas(modifier = Modifier.fillMaxSize()){ // this: DrawScope 
 //Custom drawing inside
}

The traditional method needs to obtain the Canvas handle for command drawing, while Canvas {...} Execute the drawing logic and refresh the UI in the block in a state driven manner.

Powerful DrawScope

Canvas{...} Internally, DrawScope provides the necessary state to obtain the environment variables required for current drawing, such as our most commonly used size. DrawScope also provides various common rendering API s, such as drawLine

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

 //draw a straight line
    drawLine(
        start = Offset(x=canvasWidth, y = 0f),
        end = Offset(x = 0f, y = canvasHeight),
        color = Color.Blue,
        strokeWidth = 5F //Set line width
    )
}

The drawing effect of the above code is as follows:

Easy to use API

The traditional Canvas API requires configuration such as Paint; The API provided by DrawScope is simpler and more user-friendly.

For example, draw a circle. The traditional API is as follows:

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

API s provided by DrawScope:

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

It seems that there are more parameters, but in fact, appropriate default values have been set through size, etc. at the same time, the creation and configuration of Paint are omitted, which is more convenient to use.

Use native Canvas

At present, the API provided by DrawScope is not as rich as the native Canvas (for example, it does not support drawText). When it does not meet the use requirements, you can also directly use the native Canvas object for drawing

drawIntoCanvas { canvas ->
            //nativeCanvas is a native canvas object, which is the android platform graphics. Canvas
            val nativeCanvas  = canvas.nativeCanvas

        }

The basic knowledge of Compose Canvas is introduced above. Let's take a look at the actual use effect in combination with the specific examples in the app

First, take a look at the drawing process of rain water.

4. Rainy day effect

The key to rainy weather is how to draw falling rain

Drawing of raindrops

Let's draw the basic unit of rain: raindrops

After disassembly, the rainwater effect can be composed of three groups of raindrops, and each group of raindrops is divided into upper and lower ends, so that continuous rainwater effect can be formed during movement. We use drawLine to draw each section of black line, set the appropriate stockwidth, and set the circular effect of the end point through cap:

@Composable
fun rainDrop() {

 Canvas(modifier) {

       val x: Float = size.width / 2 //x coordinate: 1 / 2 position

        drawLine(
            Color.Black,
            Offset(x, line1y1), //Starting point of line1
            Offset(x, line1y2), //End of line1
            strokeWidth = width, //Set width
            cap = StrokeCap.Round//Round head
        )

  // line2 ibid
        drawLine(
            Color.Black,
            Offset(x, line2y1),
            Offset(x, line2y2),
            strokeWidth = width,
            cap = StrokeCap.Round
        )
    }
}
Raindrop falling animation

After completing the drawing of the basic graphics, the next step is to realize the circular displacement animation for the two line segments to form the flow effect of rainwater.

For the anchor point with the gap between two segments as the animation, set its y-axis position according to the animation state, move it from the top of the drawing area to the low end (0 ~ size. High), and then restart the animation.

Draw the upper and lower segments based on the anchor point to form a continuous raindrop effect

The code is as follows:

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

    Canvas(modifier) {

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

   // width/2 is the width of the strokCap. The strokCap width is reserved at scopeHeight to keep the raindrops in a circle when moving out and improve the visual effect
        val scopeHeight = size.height - width / 2 

        // space: gap between two segments
        val space = size.height / 2.2f + width / 2 //Gap size
        val spacePos = scopeHeight * animateTween //The anchor position changes with the animation state
        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
        )
    }
}

Compose custom layout

The above completes the graphics and animation of a single raindrop. Next, we use three raindrops to form the effect of rain.

First, the Row+Space method can be used for assembly, but this method lacks flexibility. It is difficult to accurately layout the relative positions of the three raindrops only through the Modifier. Therefore, consider using the custom layout of Compose instead to improve flexibility and accuracy:

Layout(
    modifier = modifier.rotate(30f), //Raindrop rotation angle
    content = { // Define sub Composable
  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 to increase the sense of dislocation
            0 -> constraints.maxHeight * 0.8f
            1 -> constraints.maxHeight * 0.9f
            2 -> constraints.maxHeight * 0.6f
            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.8f)).roundToInt()
        }
    }
}

In Compose, you can use Layout {...} Customize the Layout of Composable, content {...} Define the child Composable participating in the Layout in.

Like the traditional Android view, custom layout needs to go through two steps: measure and layout.

  • Measure: measurables returns all the child Composable elements to be measured. Constraints are similar to MeasureSpec and encapsulate the layout constraints of the parent container on the child elements. measurable. Measure the child elements in measure()
  • layout: placeables returns the measured child elements, and calls placeable Place() arranges raindrops, and reserves the spacing of raindrops on the x axis through xPosition

After layout, use modifier Rotate (30F) rotate Composable to complete the final effect:

5. Snow effect

The key to the snow effect is the falling of snowflakes.

Drawing of snowflakes

The drawing of snowflakes is very simple. A circle represents 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.5f)
 )
}
Snowflake falling animation

The falling process of snowflakes is more complicated than that of raindrops, which consists of three animations:

  • Descent: by changing the position of y-axis (0f ~ 2.5f)
  • Left and right drift: realized through the offset of the x-axis of the table (- 1f ~ 1f)
  • Fade away: by changing alpha (1f ~ 0f)

Control multiple animations synchronously with the help of infinite transmission. The code is as follows:

@Composable
private fun Snowdrop(
 modifier: Modifier = Modifier,
 durationMillis: Int = 1000 // Drawing of snowflake falling animation
) {

 //Transition of looping playback
    val transition = rememberInfiniteTransition()

 //1\.  Descent Animation: restart animation
    val animateY by transition.animateFloat(
        initialValue = 0f,
        targetValue = 2.5f,
        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 with 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 position of the circle changes with the AnimationState to achieve the effect of snowflakes falling
        val _center = center.copy(
            x = center.x + center.x * animateX,
            y = center.y + center.y * animateY
        )

        drawCircle(
            color = Color.White.copy(alpha = animateAlpha),//The change of alpha value realizes the effect of snow disappearance
            center = _center,
            radius = radius,
        )

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

The targetValue of animateY is set to 2.5f, which makes the motion track of snowflakes longer and more realistic

Custom layout for snowflakes

Like raindrops, you can also use Layout to customize the Layout of snowflakes

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

    Layout(
        modifier = modifier,
        content = {
         //Place three snowflakes and set different duration s to increase 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 snowflakes is different, but also to increase randomness
                0 -> constraints.maxHeight * 0.6f
                1 -> constraints.maxHeight * 1.0f
                2 -> constraints.maxHeight * 0.7f
                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.9f)).roundToInt()
            }
        }
    }
}

The final effect is as follows:

6. Sunny effect

A rotating sun represents the sunny effect

Drawing of the sun

The figure of the sun consists of a circle in the middle and an equal vertical line 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.2f
        val lineOffset = radius * 1.8f
        (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
            )
        }
    }
}

Divide 360 degrees equally and draw a vertical line every 45 degrees. cos calculates the x-axis coordinates and sin calculates the y-axis coordinates.

The rotation of the sun

The rotation animation of the sun is very simple, through modifier Rotate rotate the Canvas continuously.

@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 animation

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

        // draw circle
        drawCircle(
            color = Color.Black,
            radius = radius + stroke / 2,
            style = Stroke(width = stroke),
            center = center + centerOffset //Center offset
        )

        //... slightly
    }
}

In addition, DrawScope also provides an API for rotate, which can also realize the rotation effect.

Finally, we add an offset to the center of the sun to make the rotation more lively:

7. Combination and switching of animation

The above implements Rain, Snow, Sun and other graphics respectively. Next, these elements are combined into various weather effects.

Combine graphics into weather

The declarative syntax of Compose is very conducive to the combination of UI:

For example, from cloudy to showers, after placing Sun, Cloud, Rain and other elements, we can adjust their positions through Modifier:

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

Make animation switching more natural

When switching between multiple weather animations, we want to achieve a more natural transition. The implementation idea is to variable the Modifier information of each element of weather Animation, and then change the state through Animation. It is assumed that all weather can be combined by Cloud, Sun and Rain, which is nothing more than the difference of offset, size and alpha values:

ComposeInfo
data class IconInfo(
    val size: Float = 1f, 
    val offset: Offset = Offset(0f, 0f),
    val alpha: Float = 1f,
) 

//Weather combination information, i.e. location information of Sun, Cloud and Rain
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,
        )
}

As mentioned above, ComposeInfo holds the position information of various elements, and operator overloading enables it to calculate the current latest value in Animation.

Next, use ComposeInfo to define the location information of each element for different weather

//a sunny day
val SunnyComposeInfo = ComposeInfo(
    sun = IconInfo(1f),
    cloud = IconInfo(0.8f, Offset(-0.1f, 0.1f), 0f),
    rains = IconInfo(0.4f, Offset(0.225f, 0.3f), 0f),
)

//cloudy
val CloudyComposeInfo = ComposeInfo(
    sun = IconInfo(0.1f, Offset(0.75f, 0.2f), alpha = 0f),
    cloud = IconInfo(0.8f, Offset(0.1f, 0.1f)),
    rains = IconInfo(0.4f, Offset(0.225f, 0.3f), alpha = 0f),
)

//rain
val RainComposeInfo = ComposeInfo(
    sun = IconInfo(0.1f, Offset(0.75f, 0.2f), alpha = 0f),
    cloud = IconInfo(0.8f, Offset(0.1f, 0.1f)),
    rains = IconInfo(0.4f, Offset(0.225f, 0.3f), alpha = 1f),
)
ComposedIcon

Next, define ComposedIcon and realize different weather combinations according to ComposeInfo

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

 //ComposeInfo of 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))
    }
}
ComposedWeather

Finally, define ComposedWeather to record the current composedwicon and use animation to transition when it is updated:

@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 ComposeWeather to the latest state
        setCur(composedIcon)
        trigger = 0f
    }

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

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

For my inventory, please click My GitHub Free collection

Topics: Android