Jetpack Compose 自定义布局+物理引擎 = ?

2,102 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情

效果

废话不说,先上图!

PhysicsLayout演示_.gif

所对应代码大致为:

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

class PhysicsParentData(
    var physicsConfig: PhysicsConfig = PhysicsConfig(),
    var initialX: Float = 0f,
    var initialY: Float = 0f,
    var width: Int = 0,
    var height: Int = 0
)

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

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

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又会反复进行,而初始化应该只进行一次。所以用个变量来控制吧

var initialized by remember {
    mutableStateOf(false)
}

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

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

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

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

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

不断模拟

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

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

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

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

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

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

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!