1. 我的项目背景

最近加入了Compose挑战赛的终极挑战,应用Compose实现了一个天气app。之前几轮挑战也都有参加,每次都学到不少新货色。现在迎来最终挑战,心愿能将这段时间的积攒活学活用,做出更加成熟的作品。

我的项目挑战

因为没有美工帮助,所以我思考通过代码实现app中的所有UI元素例如各种icon等,这样的UI在任何分辨率下都不会失真,跟重要的是能够灵便地实现各种动画成果。

为了升高实现老本,我将app中的UI元素定义成偏卡通的格调,能够更容易地通过代绘实现:

下面的动画没有应用gif、lottie或者其余动态资源,所有图形都是基于Compose代码绘制的。

2. MyApp:CuteWeather

App界面比拟简洁,采纳单页面出现(挑战赛要求),卡通格调的天气动画算是绝对于同类app的特色:

我的项目地址:https://github.com/vitaviva/c...

App界面形成

App纵向划分为几个性能区域,每个区域都波及到一些不同的Compose API的应用

波及技术点较多,本文次要介绍如何应用Compose绘制自定义图形、并基于这些图形实现动画,其余内容有机会再独自介绍。

3. Compose自定义绘制

像惯例的Android开发一样,除了提供各种默认的Composable控件以外,Compose也提供了Canvas用来绘制自定义UI。

其实Canvas相干API在各个平台都大同小异,但在Compose上的应用有以下特点:

  • 用申明式的形式创立和应用Canvas
  • 通过DrawScope提供必要的state及各种APIs
  • API更简略易用
申明式地创立和应用Canvas

Compose中,Canvas作为Composable,能够申明式地增加到其余Composable中,并通过Modifier进行配置

Canvas(modifier = Modifier.fillMaxSize()){ // this: DrawScope  //外部进行自定义绘制}

传统形式须要获取Canvas句柄命令式的进行绘制,而Canvas{...}通过状态驱动的形式在block内执行绘制逻辑、刷新UI。

弱小的DrawScope

Canvas{...}外部通过DrawScope提供必要的state用来获取以后绘制所需环境变量,例如咱们最罕用的size。DrawScope还提了各种罕用的绘制API,例如drawLine

Canvas(modifier = Modifier.fillMaxSize()){ //通过size获取以后canvas的width和height    val canvasWidth = size.width    val canvasHeight = size.height //绘制直线    drawLine(        start = Offset(x=canvasWidth, y = 0f),        end = Offset(x = 0f, y = canvasHeight),        color = Color.Blue,        strokeWidth = 5F //设置直线宽度    )}

下面代码绘制成果如下:

简略易用的API

传统的Canvas API须要进行Paint等配置;DrawScope提供的API更简略,应用更敌对。

例如绘制一个圆,传统的API是这样:

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

DrawScope提供的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) {...}

看起来参数变多了,然而其实曾经通过size等设置了适合的默认值,同时省去了对Paint的创立和配置,应用起来更不便。

应用原生Canvas

目前DrawScope提供的API还不迭原生Canvas丰盛(比方不反对drawText等),当不满足应用需要时,也能够间接应用原生Canvas对象进行绘制

drawIntoCanvas { canvas ->            //nativeCanvas是原生canvas对象,android平台即android.graphics.Canvas            val nativeCanvas  = canvas.nativeCanvas        }

下面介绍了Compose Canvas的基本知识,上面联合app中的具体示例看一下理论应用成果

首先,看一下雨水的绘制过程。

4. 雨天成果

雨天天气的要害是如何绘制一直着落的雨水

雨滴的绘制

咱们先绘制形成雨水的根本单元:雨滴

经拆解后,雨水成果可由三组雨滴形成,每一组雨滴分成高低两端,这样在静止时就能够造成接连不断的雨水成果。咱们应用drawLine绘制每一段黑线,设置适当的stokeWidth,并通过cap设置端点的圆形成果:

@Composablefun rainDrop() { Canvas(modifier) {       val x: Float = size.width / 2 //x坐标:1/2的地位        drawLine(            Color.Black,            Offset(x, line1y1), //line1 的终点            Offset(x, line1y2), //line1 的起点            strokeWidth = width, //设置宽度            cap = StrokeCap.Round//头部圆形        )  // line2同上        drawLine(            Color.Black,            Offset(x, line2y1),            Offset(x, line2y2),            strokeWidth = width,            cap = StrokeCap.Round        )    }}
雨滴着落动画

实现根本图形的绘制后,接下来为两线段实现周而复始的位移动画,造成雨水的流动成果。

以两线段两头空隙为动画的锚点,依据animationState设置其y轴地位,让其从绘制区域的顶端挪动到低端(0 ~ size.hight),而后restart这个动画。

以锚点为基准绘制高低两线段,就能够行成接连不断的雨滴成果了

代码如下:

@Composablefun rainDrop() { //循环播放的动画 ( 0f ~ 1f)    val animateTween by rememberInfiniteTransition().animateFloat(        initialValue = 0f,        targetValue = 1f,        animationSpec = infiniteRepeatable(            tween(durationMillis, easing = LinearEasing),            RepeatMode.Restart //start动画        )    )    Canvas(modifier) {        // scope : 绘制区域        val width = size.width        val x: Float = size.width / 2   // width/2是strokCap的宽度,scopeHeight处预留strokCap宽度,让雨滴移出时放弃正圆,进步视觉效果        val scopeHeight = size.height - width / 2         // space : 两线段的间隙        val space = size.height / 2.2f + width / 2 //间隙size        val spacePos = scopeHeight * animateTween //锚点地位随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        )    }}

Compose自定义布局

下面实现了单个雨滴的图形和动画,接下来咱们应用三个雨滴组成雨水的成果。

首先能够应用Row+Space的形式进行组装,然而这种形式短少灵活性,仅通过Modifier很难精确布局三个雨滴的绝对地位。因而思考转而应用Compose的自定义布局,以进步灵活性和准确性:

Layout(    modifier = modifier.rotate(30f), //雨滴旋转角度    content = { // 定义子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) { //让三个雨滴的height不同,减少错落感            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()        }    }}

Compose中,能够通过Layout{...}对Composable进行自定义布局,content{...}中定义参加布局的子Composable。

跟传统Android视图一样,自定义布局须要先后经验measurelayout两步。

  • measrue:measurables返回所有待测量的子Composable,constraints相似于MeasureSpec,封装父容器对子元素的布局束缚。measurable.measure()中对子元素进行测量
  • layout:placeables返回测量后的子元素,顺次调用placeable.place()对雨滴进行布局,通过xPosition预留雨滴在x轴的距离

通过layout之后,通过 modifier.rotate(30f)Composable进行旋转,实现最终成果:

5. 雪天成果

雪天成果的关键在于雪花的飘落。

雪花的绘制

雪花的绘制非常简单,用一个圆圈代表一个雪花

Canvas(modifier) { val radius = size / 2 drawCircle( //红色填充  color = Color.White,  radius = radius,  style = FILL )  drawCircle(// 彩色边框   color = Color.Black,     radius = radius,  style = Stroke(width = radius * 0.5f) )}
雪花飘落动画

雪花飘落的过程绝对于雨滴坠落要简单一些,由三个动画组成:

  • 降落:通过扭转y轴地位实现 (0f ~ 2.5f)
  • 左右飘移:通过该表x轴的offset实现 (-1f ~ 1f)
  • 逐步隐没:通过扭转alpha实现(1f ~ 0f)

借助InfiniteTransition同步控制多个动画,代码如下:

@Composableprivate fun Snowdrop( modifier: Modifier = Modifier, durationMillis: Int = 1000 // 雪花飘落动画的druation) { //循环播放的Transition    val transition = rememberInfiniteTransition() //1\. 降落动画:restart动画    val animateY by transition.animateFloat(        initialValue = 0f,        targetValue = 2.5f,        animationSpec = infiniteRepeatable(            tween(durationMillis, easing = LinearEasing),            RepeatMode.Restart        )    ) //2\. 左右飘移:reverse动画    val animateX by transition.animateFloat(        initialValue = -1f,        targetValue = 1f,        animationSpec = infiniteRepeatable(            tween(durationMillis / 3, easing = LinearEasing),            RepeatMode.Reverse        )    ) //3\. alpha值:restart动画,以0f完结    val animateAlpha by transition.animateFloat(        initialValue = 1f,        targetValue = 0f,        animationSpec = infiniteRepeatable(            tween(durationMillis, easing = FastOutSlowInEasing),        )    )    Canvas(modifier) {        val radius = size.width / 2  // 圆心地位随AnimationState扭转,实现雪花飘落的成果        val _center = center.copy(            x = center.x + center.x * animateX,            y = center.y + center.y * animateY        )        drawCircle(            color = Color.White.copy(alpha = animateAlpha),//alpha值的变动实现雪花隐没成果            center = _center,            radius = radius,        )        drawCircle(            color = Color.Black.copy(alpha = animateAlpha),            center = _center,            radius = radius,            style = Stroke(width = radius * 0.5f)        )    }}

animateYtargetValue设为2.5f,让雪花的静止轨迹更长,看起来更加实在

雪花的自定义布局

像雨滴一样,对雪花也应用Layout自定义布局

@Composablefun Snow(    modifier: Modifier = Modifier,    animate: Boolean = false,) {    Layout(        modifier = modifier,        content = {         //摆放三个雪花,别离设置不同duration,减少随机性            Snowdrop( modifier.fillMaxSize(), 2200)            Snowdrop( modifier.fillMaxSize(), 1600)            Snowdrop( modifier.fillMaxSize(), 1800)        }    ) { measurables, constraints ->        val placeables = measurables.mapIndexed { index, measurable ->            val height = when (index) {             // 雪花的height不同,也是为了减少随机性                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()            }        }    }}

最终成果如下:

6. 晴天成果

通过一个旋转的太阳代表晴天成果

太阳的绘制

太阳的图形由两头的圆形和围绕圆环的等分竖线组成。

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

均分360度,每距离45度画一条竖线,cos计算x轴坐标,sin计算y轴坐标。

太阳的旋转

太阳的旋转动画很简略,通过Modifier.rotate一直转动Canvas即可。

@Composablefun Sun(modifier: Modifier = Modifier) { //循环动画    val animateTween by rememberInfiniteTransition().animateFloat(        initialValue = 0f,        targetValue = 360f,        animationSpec = infiniteRepeatable(tween(5000), RepeatMode.Restart)    )    Canvas(modifier.rotate(animateTween)) {// 旋转动画        val radius = size.width / 6        val stroke = size.width / 20        val centerOffset = Offset(size.width / 30, size.width / 30) //圆心偏移量        // draw circle        drawCircle(            color = Color.Black,            radius = radius + stroke / 2,            style = Stroke(width = stroke),            center = center + centerOffset //圆心偏移        )        //...略    }}

此外,DrawScope也提供了rotate的API,也能够实现旋转成果。

最初咱们给太阳的圆心减少一个偏移量,让转动更加活跃:

7. 动画的组合、切换

下面别离实现了Rain、Snow、Sun等图形,接下来应用这些元素组合成各种天气成果。

将图形组合成天气

Compose的申明式语法十分有利于UI的组合:

比方,多云转阵雨,咱们摆放SunCloudRain等元素后,通过Modifier调整各自地位即可:

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

让动画切换更加天然

当在多个天气动画之间进行切换时,咱们心愿能实现更天然的过渡。实现思路是将组成天气动画的各元素的Modifier信息变量化,而后通过Animation进行扭转state 假如所有的天气都能够由Cloud、Sun、Rain组合而成,无非就是offsetsizealpha值的不同:

ComposeInfodata class IconInfo(    val size: Float = 1f,     val offset: Offset = Offset(0f, 0f),    val alpha: Float = 1f,) //天气组合信息,即Sun、Cloud、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,        )}

如上,ComposeInfo中持有各种元素的地位信息,运算符重载使其能够在Animation中计算以后最新值。

接下来,应用ComposeInfo为不同天气定义各元素的地位信息

//晴天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),)//多云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),)//雨天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

接着,定义ComposedIcon,依据ComposeInfo实现不同的天气组合

@Composablefun ComposedIcon(modifier: Modifier = Modifier, composeInfo: ComposeInfo) { //各元素的ComposeInfo    val (sun, cloud, rains) = composeInfo    Box(modifier) {  //利用ComposeInfo到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

最初,定义ComposedWeather记录以后ComposedIcon,并在其产生更新时应用动画进行适度:

@Composablefun ComposedWeather(modifier: Modifier, composedIcon: ComposedIcon) {    val (cur, setCur) = remember { mutableStateOf(composedIcon) }    var trigger by remember { mutableStateOf(0f) }    DisposableEffect(composedIcon) {        trigger = 1f        onDispose { }    } //创立动画(0f ~ 1f),用于更新ComposeInfo    val animateFloat by animateFloatAsState(        targetValue = trigger,        animationSpec = tween(1000)    ) {     //当动画完结时,更新ComposeWeather到最新state        setCur(composedIcon)        trigger = 0f    } //依据AnimationState计算以后ComposeInfo    val composeInfo = remember(animateFloat) {        cur.composedIcon + (weatherIcon.composedIcon - cur.composedIcon) * animateFloat    } //应用最新的ComposeInfo显示Icon    ComposedIcon(        modifier,        composeInfo    )}

我的库存,须要的小伙伴请点击我的GitHub收费支付