共计 11653 个字符,预计需要花费 30 分钟才能阅读完成。
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 设置端点的圆形成果:
@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
)
}
}
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
进行旋转,实现最终成果:
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
同步控制多个动画,代码如下:
@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()}
}
}
}
最终成果如下:
6. 晴天成果
通过一个旋转的太阳代表晴天成果
太阳的绘制
太阳的图形由两头的圆形和围绕圆环的等分竖线组成。
@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,也能够实现旋转成果。
最初咱们给太阳的圆心减少一个偏移量,让转动更加活跃:
7. 动画的组合、切换
下面别离实现了 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
}
// 应用最新的 ComposeInfo 显示 Icon
ComposedIcon(
modifier,
composeInfo
)
}
我的库存,须要的小伙伴请点击我的 GitHub 收费支付