利用Jetpack Compose绘制可爱的天气动画

2022-07-18,,,,

目录
  • 1. 项目背景
  • 2. myapp:cuteweather
    • app界面构成
  • 3. compose自定义绘制
    • 声明式地创建和使用canvas
    • 强大的drawscope
  • 4.简单易用的api
    • 使用原生canvas
  • 5. 雨天效果
    • 雨滴的绘制
    • 雨滴下落动画
  • 6.compose自定义布局
    • 7.. 雪天效果
      • 雪花的绘制
      • 雪花飘落动画
      • 雪花的自定义布局
    • 8. 晴天效果
      • 太阳的绘制
      • 太阳的旋转
    • 9. 动画的组合、切换
      • 将图形组合成天气
      • composedicon
      • composedweather

    1. 项目背景

    最近参加了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视图一样,自定义布局需要先后经历measurelayout两步。

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

    animateytargetvalue设为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的组合:

    比如,多云转阵雨,我们摆放suncloudrain等元素后,通过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组合而成,无非就是offsetsizealpha值的不同:

    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
        }

    以上就是利用jetpack compose绘制可爱的天气动画的详细内容,更多关于jetpack compose绘制动画的资料请关注其它相关文章!

    《利用Jetpack Compose绘制可爱的天气动画.doc》

    下载本文的Word格式文档,以方便收藏与打印。