Jetpack Compose实现的天气动画!可爱~

fundroid | 作者 

承香墨影 | 校对 

https://juejin.cn/post/6944884453038620685 | 原文

Hi,大家好,这里是承香墨影!

Jetpack Compose 是 Android 新出的响应式 UI 框架,可以简化并加快 Android 上的 UI 开发。

Jetpack Compose 目前还处于 Beta 版,但仍然阻止不聊喜欢尝鲜的开发者,使用 Compose 做出酷炫的效果。

从命令式转到响应式,最主要的思想的转变。

响应式 UI 不同于 Android 传统的基于 XML 的命令式 UI 开发规范,是一套全新的 UI 思想,糅合了 UI 与代码逻辑。跨平台框架 Flutter 的 Widget 和 Swift UI 都属于响应式。

今天给大家推荐一个使用 Compose 实现酷炫的天气动画的实现,其实只要掌握了方式方法,利用响应式的 UI 框架,依然可以做出酷炫的效果,UI 展示并不是难点。

一、项目介绍

此前参加了 Compose 挑战赛的终极挑战,使用 Compose 完成了一个天气 App。之前几轮挑战我也都有参与,每次都学到不少新东西,希望在这最后一轮挑战中,活用这段时间的积累做出更加成熟的作品。

1.1 项目挑战

因为没有美工协助,所以我考虑通过代码实现 App 中的所有 UI 元素例如各种 Icon 等,这样的 UI 在任何分辨率下都不会失真,而且可以更灵活地完成各种动画效果。

为了降低实现成本,我将 app 中的 UI 元素定义成偏卡通的风格,更利于代码实现:

上面的动画没有使用 GifLottie 等三方资源,所有效果都基于 Compose 代码绘制。

1.2 MyApp:CuteWeather

App 界面比较简洁,采用单页面呈现(这也是挑战赛要求),可以查看近一周的天气信息和温度走势等。

项目地址:

https://github.com/vitaviva/compose-weather

其中,卡通风格的天气动画,算是这个 app 相对于同类应用的特色,本文将围绕这些天气动画,介绍一下如何使用 Compose 绘制自定义图形、并基于这些图形实现动画。

二、Compose自定义绘制

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

Canvas 相关的 API 在各个平台都大同小异,但在 Compose 上具有以下特点:

  • 用声明式的方式创建和使用 Canvas

  • 通过 DrawScope 提供必要的 state 及各种 APIs;

  • API 更简单易用;

2.1 声明式地创建和使用 Canvas

Compose 中,Canvas 作为 Composable 可以声明式地添加到其他 Composable 中,并通过 Modifier 进行配置

Canvas(modifier = Modifier.fillMaxSize()){
}

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

2.2 强大的 DrawScope

Canvas{...} 通过 DrawScope 提供了一些当前绘制所需的 state,例如经常使用到的 size;DrawScope 还提了各种常用的绘制 API,例如 drawLine 等。

Canvas(modifier = Modifier.fillMaxSize()){
 
  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 
  )
}

上面代码绘制效果如下:

2.3 简单易用的 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 的创建和配置,使用起来更方便。

2.4 使用原生 Canvas

目前 DrawScope 提供的 API 还不及原生 Canvas 丰富(比如不支持 drawText 等),当不满足使用需求时,也可以直接使用原生 Canvas 对象进行绘制。

drawIntoCanvas { canvas ->
  val nativeCanvas  = canvas.nativeCanvas
}

上面对 Compose 中的 Canvas 做了简单介绍,下面结合 App 中的具体示例看一下实际使用效果。

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

三、雨天效果

雨天天气的关键,是如何绘制不断下落的雨水。

3.1 雨滴的绘制

我们先绘制构成雨水的基本单元:雨滴。

经拆解后,雨水效果可由三组雨滴构成,每一组雨滴分成上下两段,这样在运动时,可以形成接连不断的效果。

我们使用 drawLine 绘制每一段黑线,设置适当的 stokeWidth,并通过 cap 设置端点的圆形效果:

@Composable
fun rainDrop() {
  Canvas(modifier) {
    val x: Float = size.width / 2 
    drawLine(
      Color.Black,
      Offset(x, line1y1), 
      Offset(x, line1y2), 
      strokeWidth = width, 
      cap = StrokeCap.Round
    )

    drawLine(
      Color.Black,
      Offset(x, line2y1),
      Offset(x, line2y2),
      strokeWidth = width,
      cap = StrokeCap.Round
    )
  }
}

3.2 雨滴下落动画

完成雨滴的基本图形绘制后,接下来为两线段增加位移动画,形成流动的效果。

以两线段中间空隙为动画的锚点,根据 animationState 变动其 y 轴位置,从 canvas 的顶端移动到低端(0 ~ size.hight),然后 restart 这个动画。

然后以锚点为基准绘制上下两线段,就形成接连不断的动画效果了。

代码如下:

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

  Canvas(modifier) {
    val width = size.width
    val x: Float = size.width / 2

    val scopeHeight = size.height - width / 2 

    val space = size.height / 2.2f + width / 2 
    val spacePos = scopeHeight * animateTween 
    val sy1 = spacePos - space / 2
    val sy2 = spacePos + space / 2

    val lineHeight = scopeHeight - space

    val line1y1 = max(0f, sy1 - lineHeight)
    val line1y2 = max(line1y1, sy1)

    val line2y1 = min(sy2, scopeHeight)
    val line2y2 = min(line2y1 + lineHeight, scopeHeight)

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

3.3 Compose 自定义布局

完成了单个雨滴的动画,接下来我们使用三个雨滴组成雨水的效果。

首先可以使用 Row + Space 的方式进行组装,但是这种方式缺少灵活性,仅通过 Modifier 很难准确布局三雨滴的相对位,因此考虑借助 Compose 的自定义布局,以提高灵活性和准确性:

Layout(
  modifier = modifier.rotate(30f), 
  content = { 
  Raindrop(modifier.fillMaxSize())
  Raindrop(modifier.fillMaxSize())
  Raindrop(modifier.fillMaxSize())
  }
) { measurables, constraints ->
    
  val placeables = measurables.mapIndexed { index, measurable ->

    val height = when (index) { 
      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, 
        maxHeight = height.toInt(),
      )
    )
  }

  layout(constraints.maxWidth, constraints.maxHeight) {
    var xPosition = constraints.maxWidth / ((placeables.size + 1) * 2)

    placeables.forEachIndexed { index, placeable ->
      placeable.place(x = xPosition, y = 0)
      xPosition += (constraints.maxWidth / ((placeables.size + 1) * 0.8f)).roundToInt()
    }
  }
}

Compose 中可以使用 Layout{...} 对 Composable 进行自定义布局,content{...} 中定义参与布局的子 Composable。

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

  • measruemeasurables返回所有待测量的子 Composable,constraints 类似于 MeasureSpec,封装父容器对子元素的布局约束。measurable.measure() 中对子元素进行测量

  • layoutplaceables 返回测量后的子元素,依次调用placeable.place()对雨滴进行布局,通过 xPosition 预留雨滴在 x 轴的间隔

经过 layout 之后,通过 modifier.rotate(30f) 对 Composable 进行旋转,完成最终效果:

四、雪天效果

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

4.1 雪花的绘制

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

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

4.2 雪花飘落动画

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

  1. 下落:改变 y 轴坐标:0f ~ 2.5f;

  2. 左右飘移:改变 x 轴的 offset:-1f ~ 1f;

  3. 逐渐消失:改变 alpha:1f ~ 0f;

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

@Composable
private fun Snowdrop(
 modifier: Modifier = Modifier,
 durationMillis: Int = 1000 
) {
  val transition = rememberInfiniteTransition()

  val animateY by transition.animateFloat(
    initialValue = 0f,
    targetValue = 2.5f,
    animationSpec = infiniteRepeatable(
      tween(durationMillis, easing = LinearEasing),
      RepeatMode.Restart
    )
  )

  val animateX by transition.animateFloat(
    initialValue = -1f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
      tween(durationMillis / 3, easing = LinearEasing),
      RepeatMode.Reverse
    )
  )

  val animateAlpha by transition.animateFloat(
    initialValue = 1f,
    targetValue = 0f,
    animationSpec = infiniteRepeatable(
      tween(durationMillis, easing = FastOutSlowInEasing),
    )
  )

  Canvas(modifier) {
    val radius = size.width / 2
    val _center = center.copy(
      x = center.x + center.x * animateX,
      y = center.y + center.y * animateY
    )

    drawCircle(
      color = Color.White.copy(alpha = animateAlpha),
      center = _center,
      radius = radius,
    )

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

animateYtargetValue 设为 2.5f 是为了让雪花的运动轨迹更长,看起来更加真实。

4.3 雪花的自定义布局

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

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

        Snowdrop( modifier.fillMaxSize(), 2200)
        Snowdrop( modifier.fillMaxSize(), 1600)
        Snowdrop( modifier.fillMaxSize(), 1800)
    }
  ) { measurables, constraints ->
    val placeables = measurables.mapIndexed { index, measurable ->
      val height = when (index) {
        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, 
          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()
      }
    }
  }
}

最终效果如下:

五、晴天效果

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

5.1 太阳的绘制

太阳的图形由中心圆形,和围绕圆环的等分线段组成。

@Composable
fun Sun(modifier: Modifier = Modifier) {
  Canvas(modifier) {

    val radius = size.width / 6
    val stroke = size.width / 20

    drawCircle(
      color = Color.Black,
      radius = radius + stroke / 2,
      style = Stroke(width = stroke),
    )
    drawCircle(
      color = Color.White,
      radius = radius,
      style = Fill,
    )
    
    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 轴坐标。

5.2 太阳的旋转

太阳的旋转动画很简单,通过 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) 
    drawCircle(
      color = Color.Black,
      radius = radius + stroke / 2,
      style = Stroke(width = stroke),
      center = center + centerOffset 
    )
  }
}

此外,DrawScope 提供了 rotate 的 API,也可以实现旋转效果。

最后我们给太阳的圆心增加一个偏移量,让转动更加活泼:

六、动画的组合、切换

在实现了 RainSnowSun 等图形后,就可以使用这些图形组合成各种天气效果了。

6.1 将图形组合成天气

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

6.2 让动画切换更加自然

当在多个天气动画之间进行切换时,我们希望能实现更自然的过渡。

实现思路是将组成天气动画的各元素的 Modifier 配置变量化,然后通过 Animation 不断改变。

假设所有的天气都由 CloudSunRain 组成,无非就是 offsetsizealpha 值的不同:

6.3 ComposeInfo

data class IconInfo(
  val size: Float = 1f, 
  val offset: Offset = Offset(0f, 0f),
  val alpha: Float = 1f,
) 
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),
)

6.4 ComposedIcon

接着,定义 ComposedIcon,消费 ComposeInfo 绘制天气组合的 UI。

@Composable
fun ComposedIcon(modifier: Modifier = Modifier, composeInfo: ComposeInfo) {
  val (sun, cloud, rains) = composeInfo
  Box(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))
  }
}

6.5 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 { }
  }

  val animateFloat by animateFloatAsState(
      targetValue = trigger,
      animationSpec = tween(1000)
  ) {
    setCur(composedIcon)
    trigger = 0f
  }

  val composeInfo = remember(animateFloat) {
    cur.composedIcon + (weatherIcon.composedIcon - cur.composedIcon) * animateFloat
  }


  ComposedIcon(
    modifier,
    composeInfo
  )
}

到此,我们就实现了天气动画的自然过度了。

七、最后

Compose 通过声明式的方式,实现自定义图形及其动画,这相比与命令式的代码来的更加简单,这也让用代码替代 gif 等实现动画、表情成为可能,经代码绘制的效果在清晰度以及帧率上也都要远超 gif,欢迎大家下载源码体验。

当然,在我看来 Compose 精髓绝不仅是 UI,后面有机会将分享更多关于架构以及底层实现的内容,希望与大家一起学习和讨论。

-- End --

本文对你有帮助吗?留言、转发、点好看是最大的支持,谢谢!

references:

  • github:https://github.com/vitaviva/compose-weather

  • Canvas:https://developer.android.com/jetpack/compose/graphics#canvas

  • CustomLayout:https://developer.android.com/jetpack/compose/layout#custom-layouts

推荐阅读:

还在用 Glide?看看 Google 官推的图片库 Coil 有何不同!

效果炸了,Drawable 实现红鲤鱼动画,点哪儿游哪儿(下)

把倒计时做到极致,又准、又稳!

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值