本文示例代码API基于compose UI 1.0.0-bate08
分层布局
compose ui 中一个Column,Row都可以看成一个Layout,在这些布局中modifier都有自己的边界,想让一个组件覆盖在另一个上面是比较难以实现的。如果遇到复杂的布局,就可以采用分层布局。原理就是先画一层layout后,再画一层,或者再画n层(性能很有问题)。
as bate版本新建一个compose项目,示例代码:
@Composable
fun Demo() {
ComplexlayoutTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
//第一层背景
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(Color.Gray)
}
//第二层布局
LazyColumn {
//为了凑长度
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
item{ Greeting("markrenChina love Android!!!") }
}
//第三层布局
LazyRow {
item{ Greeting("Android") }
item{ Greeting("Android") }
item{ Greeting("Android") }
item{ Greeting("Android") }
item{ Greeting("Android") }
item{ Greeting("Android") }
item{ Greeting("Android") }
item{ Greeting("Android") }
}
//第四层布局 右下角放一个圆圈
Canvas(modifier = Modifier
.layout { measurable, constraints ->
Log.i("with * height", "${constraints.maxWidth} * ${constraints.maxHeight}")
val p = measurable.measure(constraints)
layout(constraints.maxWidth, constraints.maxHeight) {
//荣耀20 1dp = 3
p.placeRelative(constraints.maxWidth- 300, constraints.maxHeight - (50+80)*3)
}
}
.size(80.dp)
) {
Log.i("with * height", "${size.width} * ${size.height}")
drawCircle(
Color.Red//.copy(0.4f)
)
}
}
}
}
效果大致是
示例中,灰色背景是最下层,LazyColumn是第二层,LazyRow是第三层,因为他的第一个列覆盖了第二层的第一行,所以第二层第一行的滑动将失效。第四层,是右下角的小圆圈,最外层将覆盖所以层显示包括事件。
跑酷小游戏(Demo)
简单小demo,我们用全部基于第4篇的图形和动画去实现,因为将UI,就没有实现计分系统和碰撞检查。友情提示,本demo是为了学习compose api,用java搞游戏不科学。本游戏原型来源我的安卓入门书籍 《Android游戏开发详解》美James S Cho
game.kt
/**
* 游戏入口
*/
@Composable
fun Game() {
//先画背景
GameBackground()
//主要内容
GameView()
}
GameBackground.kt
背景非常简单,主要是背景,草坪,太阳
@Composable
fun GameBackground(){
Canvas(modifier = Modifier.fillMaxSize()) {
//画背景
drawRect(
Color.Blue.copy(0.5f),
size = Size(size.width,size.height*0.8F)
)
//画草坪
drawRect(
Color.Green.copy(0.8f),
Offset(0F,size.height*0.8F),
size = Size(size.width,size.height*0.2F)
)
//画太阳
drawCircle(
Color.Yellow,
radius = 120F,
center = Offset(size.width,0F)
)
}
}
为了适配,所有的尺寸都基于最大尺寸换算,这种换算在实际生成环境应该放置于remember引用而不是每次都计算。这里提一点compose的缺点,因为是ui fun,所以其实是一个隐藏的死循环线程中的代码。对于对象的创建,计算必须非常谨慎,循环,循环,循环,重要的事情说三遍。内存抖动,泄露可能就在不经意间。
创建障碍物
为了不让逻辑混乱,我们把障碍物的绘制从GameView分离出来,障碍物的移动就利用入参一个x,y的坐标。
/**
* 障碍物
*/
@Composable
fun BlockView(
offset: Pair<Float, Float>,
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
Color.Yellow,
topLeft = Offset(offset.first * size.width, offset.second * size.height),
size = Size(size.width*0.03F, size.height*0.15F)
)
}
}
这里用Pair不直接传Offset是因为Offset的x,y是val,x ,y每次都不一样,避免过多创建对象。
补充一个线性拟合方法:
//线性拟合
fun lerp(start: Float, stop: Float, fraction: Float): Float {
return (1 - fraction) * start + fraction * stop
}
在GameView 去绘制障碍物
const val BLOCK_NUMBER = 5
@Composable
fun GameView() {
//创建一个随机发生器
val random by remember { mutableStateOf(ThreadLocalRandom.current()) }
//添加动画
val infiniteTransition = rememberInfiniteTransition()
val animatedProgress by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(25000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
//创建障碍物
val blockList = remember {
val offsets = mutableListOf<Block>()
//不用随机数直接均匀分布
val between = 1.0F / BLOCK_NUMBER
(1..BLOCK_NUMBER).forEach { i ->
val block = Block(Pair((between * i), if(random.nextBoolean()) 0.5F else 0.65F))
offsets.add(block)
}
offsets
}
//画出障碍物
repeat(BLOCK_NUMBER) {
//计算当前地址
var x = lerp(blockList[it].offset.first,blockList[it].offset.first - 1F,animatedProgress)
if (x < 0F) {
x += 1F
}
val y = blockList[it].offset.second
Log.i("TAG", "")
BlockView(offset = Pair(x,y))
}}
简单解释一下,infiniteTransition是一个无限循环动画,由他每次去产生一个animatedProgress(动画进度)障碍物创建时我们随机产生了高度,0.5F,0.65F值都是适配而来。在传入BlockView前我们计算每一次最新的位置,如果他移动到了左边超出屏幕了,就让他会到右边,复用这个对象。计算位置需要原始位置信息,所以再定义一个对象去保存:
data class Block(
val offset: Pair<Float, Float>
)
绘制角色
角色绘制,需要使用图片,我们引入assets文件夹放资源文件,然后导入资源
HeroView
@Composable
fun HeroView() {
val assets = LocalContext.current.assets
val runBitmaps by remember {
val bitmaps = mutableListOf<ImageBitmap>()
(1..5).forEach {
assets.open("run_anim$it.webp").use { ins->
bitmaps.add(BitmapFactory.decodeStream(ins).asImageBitmap())
}
}
mutableStateOf(bitmaps)
}
Canvas(modifier = Modifier.fillMaxSize()) {
drawImage(
image = runBitmaps[0],
dstOffset = IntOffset((0.05F* size.width).toInt(),(0.6f* size.height).toInt()),
dstSize = IntSize((size.width*0.1f).toInt(),(size.height*0.2F).toInt())
)
}
}
让角色走路
run图片我们导入了5张,只要让他们交替播放就可以,前面我们有一个animatedProgress的动画发生器,但是太慢了,当然你可以算法换算animatedProgress成0-4即可,简单点就是再建一个,修改GameView,加入一个:
val heroProgress by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1500, easing = LinearEasing),
repeatMode = RepeatMode.Restart
)
)
...
//修改HeroView加入一个入参
HeroView((heroProgress / 0.2F).toInt())
修改HeroView:
drawImage(
image = runBitmaps[progress],
dstOffset = IntOffset((0.05F* size.width).toInt(),(0.6f* size.height).toInt()),
dstSize = IntSize((size.width*0.1f).toInt(),(size.height*0.2F).toInt())
)
让角色下蹲
下蹲躲避上方的障碍物和跳跃躲避下方的障碍物都需要引入手势检查(上划跳跃 ,下划下蹲),跳跃要实现一个缓速上升,再下降,相对实现更复杂,我们先来实现下蹲。
引入手势,还需要是全屏手势,方便用户操作,所以我们来修改HeroView的Canvas
val duckBitmap by remember {
mutableStateOf( assets.open("duck.webp").use {
BitmapFactory.decodeStream(it).asImageBitmap()
})
}
...
//手势判断
var offset by remember { mutableStateOf(0f) }
...
Canvas(modifier = Modifier
.fillMaxSize()
.scrollable(
orientation = Orientation.Vertical,
state = rememberScrollableState { delta ->
offset += delta
delta
}
)) {
...
}
角色的状态需要切换,我们还要状态属性,状态切换后,我们画蹲的图片,然后给定一个时间将状态切回run即可
//动画状态,只有run(0)可以触发 跳(1),蹲(2)
var state by remember { mutableStateOf(0)}
LaunchedEffect(key1 = offset){
if (state == 0) {
if (offset < -100) {
Log.i("TAG", "jump")
state = 1
offset = 0f
} else if (offset > 100) {
Log.i("TAG", "duck")
state = 2
offset = 0f
}else state = 0
}
}
LaunchedEffect(key1 = state){
if (state == 2) {
delay(1000)
offset = 0f
state = 0
}
}
Canvas(modifier = Modifier
.fillMaxSize()
.scrollable(
orientation = Orientation.Vertical,
state = rememberScrollableState { delta ->
offset += delta
delta
}
)) {
if (state == 0){
//run
drawImage(
image = runBitmaps[progress],
dstOffset = IntOffset((0.05F* size.width).toInt(),(0.6f* size.height).toInt()),
dstSize = IntSize((size.width*0.1f).toInt(),(size.height*0.2F).toInt())
)
}else if (state == 1){
//jump
}else{
//duck
drawImage(
image = duckBitmap,
dstOffset = IntOffset((0.05F* size.width).toInt(),(0.65F* size.height).toInt()),
dstSize = IntSize((size.width*0.1f).toInt(),(size.height*0.15F).toInt())
)
}
}
让角色跳起来
跳起来比下蹲只是多一个上升和下降的过渡动画:
HeroView
val jumpBitmap by remember {
mutableStateOf( assets.open("jump.webp").use {
BitmapFactory.decodeStream(it).asImageBitmap()
})
}
val jump = remember { Animatable(0.6f) }
修改LaunchedEffect(key1 = state)
else if (state == 1){
jump.animateTo(
targetValue = 0.27f,
animationSpec =
//tween(durationMillis = 400, easing = LinearEasing)
spring(
stiffness = 30f//Spring.StiffnessVeryLow,
)
)
//这里会浮空一段时间
delay(600)
jump.animateTo(
targetValue = 0.6f,
animationSpec = spring(
stiffness = 30f//Spring.StiffnessVeryLow,
)
)
delay(400)
state = 0
offset = 0f
}
最后修改画的部分
else if (state == 1){
//jump
drawImage(
image = jumpBitmap,
dstOffset = IntOffset((0.05F* size.width ).toInt(),(jump.value* size.height).toInt()),
dstSize = IntSize((size.width*0.1f).toInt(),(size.height*0.2F).toInt())
)
}
最后
demo代码
写demo周日下午在家一边带儿子一边写花了几个小时,晚上写博文又是2小时,看完点个赞。要是写UI要是没什么人看,大概这就是最后一篇了!