Jetpack Compose绘制动画
ax2djmti 人气:01. 项目背景
最近参加了Compose挑战赛的终极挑战,使用Compose完成了一个天气app。之前几轮挑战也都有参与,每次都学到不少新东西。如今迎来最终挑战,希望能将这段时间的积累活学活用,做出更加成熟的作品。
项目挑战
因为没有美工协助,所以我考虑通过代码实现app中的所有UI元素例如各种icon等,这样的UI在任何分辨率下都不会失真,跟重要的是可以灵活地实现各种动画效果。
为了降低实现成本,我将app中的UI元素定义成偏卡通的风格,可以更容易地通过代绘实现:
上面的动画没有使用gif、lottie或者其他静态资源,所有图形都是基于Compose代码绘制的。
2. MyApp:CuteWeather
App界面比较简洁,采用单页面呈现(挑战赛要求),卡通风格的天气动画算是相对于同类app的特色:
项目地址:https://github.com/vitaviva/compose-weather
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 //设置直线宽度 ) }
上面代码绘制效果如下:
4.简单易用的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中的具体示例看一下实际使用效果
首先,看一下雨水的绘制过程。
5. 雨天效果
雨天天气的关键是如何绘制不断下落的雨水
雨滴的绘制
我们先绘制构成雨水的基本单元:雨滴
经拆解后,雨水效果可由三组雨滴构成,每一组雨滴分成上下两端,这样在运动时就可以形成接连不断的雨水效果。我们使用drawLine绘制每一段黑线,设置适当的stokeWidth
,并通过cap设置端点的圆形效果:
@Composable fun 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这个动画。
以锚点为基准绘制上下两线段,就可以行成接连不断的雨滴效果了
代码如下:
@Composable fun 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 ) } }
6.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视图一样,自定义布局需要先后经历measure
、layout
两步。
measrue
:measurables返回所有待测量的子Composable,constraints类似于MeasureSpec,封装父容器对子元素的布局约束。measurable.measure()
中对子元素进行测量
layout
:placeables返回测量后的子元素,依次调用placeable.place()
对雨滴进行布局,通过xPosition
预留雨滴在x轴的间隔
经过layout之后,通过 modifier.rotate(30f)
对Composable
进行旋转,完成最终效果:
7.. 雪天效果
雪天效果的关键在于雪花的飘落。
雪花的绘制
雪花的绘制非常简单,用一个圆圈代表一个雪花
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
同步控制多个动画,代码如下:
@Composable private 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) ) } }
animateY
的targetValue
设为2.5f
,让雪花的运动轨迹更长,看起来更加真实
雪花的自定义布局
像雨滴一样,对雪花也使用Layout自定义布局
@Composable fun 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() } } } }
最终效果如下:
8. 晴天效果
通过一个旋转的太阳代表晴天效果
太阳的绘制
太阳的图形由中间的圆形和围绕圆环的等分竖线组成。
@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 ) } } }
均分360度,每间隔45度画一条竖线,cos计算x轴坐标,sin计算y轴坐标。
太阳的旋转
太阳的旋转动画很简单,通过Modifier.rotate
不断转动Canvas即可。
@Composable fun 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,也可以实现旋转效果。
最后我们给太阳的圆心增加一个偏移量,让转动更加活泼:
9. 动画的组合、切换
上面分别实现了Rain、Snow、Sun等图形,接下来使用这些元素组合成各种天气效果。
将图形组合成天气
Compose的声明式语法非常有利于UI的组合:
比如,多云转阵雨,我们摆放Sun
、Cloud
、Rain
等元素后,通过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)) } }
让动画切换更加自然
当在多个天气动画之间进行切换时,我们希望能实现更自然的过渡。实现思路是将组成天气动画的各元素的Modifier
信息变量化,然后通过Animation
进行改变state 假设所有的天气都可以由Cloud、Sun、Rain组合而成,无非就是offset
、size
、alpha
值的不同:
ComposeInfo data 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实现不同的天气组合
@Composable fun 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
,并在其发生更新时使用动画进行过度:
@Composable fun 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 }
加载全部内容