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