效果

废话不说,先上图!

PhysicsLayout演示_

所对应代码大致为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
val physicsConfig = PhysicsConfig()

PhysicsLayout(modifier = modifier, physicsLayoutState = physicsLayoutState, boundSize = boundSize.toFloat()) {
RandomColorBox(modifier = Modifier
.size(40.dp)
.physics(physicsConfig, initialX = 300f, initialY = 500f))
// This one has a circle shape
// so you need to modify it with not only a `clip()` Modifier to make it "looks like" a circle
// but also a `physics(physicsConfig.copy(shape = PhysicsShape.CIRCLE)` Modifier to create a circle Body
RandomColorBox(modifier = Modifier
.clip(CircleShape)
.size(50.dp)
.physics(physicsConfig.copy(shape = PhysicsShape.CIRCLE), 300f, 1000f))
RandomColorBox(modifier = Modifier
.size(60.dp)
.physics(physicsConfig))
var checked by remember {
mutableStateOf(false)
}
Checkbox(...)
Card(...) {
...
}
}

这之中的PhysicsLayout就是我实现的物理布局

如需体验上图的其他功能,可以到Github仓库下载demo;完整源亦可见仓库

这是怎么实现的?

正如标题所言,这是一个自定义布局。关于这方面,我已经写了5篇文章详细描述了,感兴趣的同学可以点击我的头像查看。本文所用到的无非就是那里的知识加上JBox2d而已,看完之后你也写的出来

JBox2d

JBox2D是开源的2D物理引擎,能够根据开发人员设定的参数,如重力、密度、摩擦系数和弹性系数等,自动进行2D刚体物理运动的模拟。

参考

本布局参考自Jawnnypoo/PhysicsLayout: Android layout that simulates physics using JBox2D (github.com),其中部分代码也来自于那里,在此表示诚挚的感谢!( 不过我进行了大量的修改,以使原先用于 View的代码被用于Compose

实现

定义

首先,我们先来想一个事情:现在每个物体其实都有自己的位置、大小、形状这些参数,那么父布局怎么获得这些值呢?如果你读过我的深入Jetpack Compose——布局原理与自定义布局(四)ParentData,估计可以想到:这是利用ParentData传递的。所以咱们先写个自定义的ParentData

1
2
3
4
5
6
7
class PhysicsParentData(
var physicsConfig: PhysicsConfig = PhysicsConfig(),
var initialX: Float = 0f,
var initialY: Float = 0f,
var width: Int = 0,
var height: Int = 0
)

PhysicsConfig代表基本的物理配置,我们先不细究,其余的就是初位置和宽高了

有了ParentData,那是不是也得有对应的修饰符和作用域啊,所以咱们写一写

1
2
3
4
5
6
7
8
9
10
11
12
13
interface PhysicsLayoutScope {
@Stable
fun Modifier.physics(physicsConfig: PhysicsConfig, initialX : Float = 0f, initialY : Float = 0f) : Modifier
}

internal object PhysicsLayoutScopeInstance : PhysicsLayoutScope {
@Stable
override fun Modifier.physics(
physicsConfig: PhysicsConfig,
initialX: Float,
initialY: Float
): Modifier = this.then(PhysicsParentData(physicsConfig, initialX, initialY))
}

上面的代码都很简单,属于是自定义Modifier的基本操作了,如果你看不懂可以先了解了解再来

使用

现在Modifier的定义差不多了,接下来就是使用了。其实总结下来就是这个过程

  1. 初始化各个物体和世界

  2. 用代码不断模拟一下各个物体的运动过程

  3. 在Layout过程中获取位置并正确摆放出来

咱们分别来看(下面的内容只是我的思路,有些地方可能不太优雅,如果您有更好的想法欢迎指出!)

整体来说,应该有一些代码专门负责物理模拟的过程,这一部分在代码中为Physics类,它负责进行具体的物理世界创造进行物理模拟等过程。此处不赘述。

初始化

考虑到各子微件的具体信息要到Layout才能读取到,所以似乎只能在这里初始化;但是Layout又会反复进行,而初始化应该只进行一次。所以用个变量来控制吧

1
2
3
var initialized by remember {
mutableStateOf(false)
}

然后第一次Layout时读取各ParentData并存起来

1
2
3
4
5
6
7
val placeables = measurables.mapIndexed { index,  measurable ->
val physicsParentData = (measurable.parentData as? PhysicsParentData) ?: PhysicsParentData()
if (!initialized){
parentDataList.add(index, physicsParentData)
}
measurable.measure(childConstraints)
}

然后开个副作用,在所有物体信息初始化好后创建世界并创建Body(在JBox2d中代表刚体的类)

1
2
3
4
5
6
7
// 初始化世界
LaunchedEffect(initialized){
if (!initialized) return@LaunchedEffect
physics.createWorld { body, i ->
parentDataList[i].body = body
}
}

其中createWord方法负责创建Body并在每个Body创建完后回调

不断模拟

模拟的工作交给JBox2d,我们要做的就是不断就行。所以while循环吧

1
2
3
4
5
6
LaunchedEffect(key1 = Unit){
while (true){
delay(16)
physics.step() // 模拟 16ms
}
}
读取并正确放置

这个就很简单了,在Layout方法里layout()中读一下各个Body的位置并place就行

不过这里注意,因为Body有旋转角度,所以在place的时候需要使用placeWithLayer,该方法签名如下:

1
2
3
4
5
fun Placeable.placeWithLayer(
position: IntOffset,
zIndex: Float = 0f,
layerBlock: GraphicsLayerScope.() -> Unit = DefaultLayerBlock
)

其中第三个参数layerBlock就提供了缩放、选择等方法。具体代码是:

1
2
3
4
5
6
7
8
9
10
layout(constraints.maxWidth, constraints.maxHeight){
placeables.forEachIndexed { i, placeable: Placeable ->
val x = physics.metersToPixels(parentDataList[i].x).toInt() - placeable.width / 2
val y = physics.metersToPixels(parentDataList[i].y).toInt() - placeable.height / 2

placeable.placeWithLayer(IntOffset(x,y), zIndex = 0f, layerBlock = {
rotationZ = parentDataList[i].rotation
})
}
}

上面的metersToPixels用于将物理世界的坐标映射到现实

完工!

后续

其实目前来看,代码里还有些地方感觉不大对劲,比如,为了触发Layout过程,我实际使用了一个并无任何用处的state。因为在我的尝试里,只要layout块里不出现state的变化,它就不会重新触发(这点当然符合Compose的感觉喽);我想不到什么好点子,只好这么处理了。如果大家有什么好想法,欢迎探讨和PR

如果你好奇有什么用……额,我也不知道有什么实际用处。我就是觉得很好玩儿,很早之前就想做了,最近下定决心,两天完成,感觉效果还不错。

如果你对Compose完整项目感兴趣,欢迎看看我的开源项目FunnySaltyFish/FunnyTranslation: 基于Jetpack Compose开发的翻译软件,支持多引擎、插件化~

本文代码:FunnySaltyFish/JetpackComposePhysicsLayout: Jetpack Compose custom layout that simulates physics using JBox2D ,欢迎Star!