前言
在还没有疫情的年代,外出经常会选择高铁,等高铁的时候我就喜欢打开 掌上高铁 的成就,签到领个徽章,顺便玩一下那个类似碰撞小球的徽章墙,当时我就在想这东西怎么实现的,但是吧,实在太懒了/doge,这几年都没尝试去自己实现过。最近有时间倒逼自己做了一些学习和尝试,就分享一下这种功能的实现。
不过,当我为写这篇文章做准备的时候,据不完全考古发现,似乎摩拜的 app 更早就实现了这个需求,但有没有更早的我就不知道了/doge
其实呢,我想起来做这个尝试是我在一个 Android
自定义 View 合集的库 里看到了一个叫 PhysicsLayout 的库,当时我就虎躯一震,我心心念念的徽章墙不就是这个效果嘛,于是也就有了这篇文章。这个 PhysicsLayout
其实是借助 JBox2D
来实现的,但不妨先借助 PhysicsLayout
实现徽章墙,然后再来探索 PhysicsLayout
的实现方式。
实现
-
添加依赖,
sync
implementation("com.jawnnypoo:physicslayout:3.0.1") 复制代码
-
在布局文件中添加
PhysicsLinearLayout
,并添加一个子 View
,run
起来<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <com.jawnnypoo.physicslayout.PhysicsLinearLayout android:id="@+id/physics_layout" android:layout_width="match_parent" android:layout_height="200dp" app:layout_constraintTop_toTopOf="parent"> <ImageView android:id="@+id/iv_physics_a" android:layout_width="50dp" android:layout_height="50dp" android:src="@mipmap/ic_launcher_round" app:layout_circleRadius="25dp" app:layout_restitution="1.0" app:layout_shape="circle" /> </com.jawnnypoo.physicslayout.PhysicsLinearLayout> </androidx.constraintlayout.widget.ConstraintLayout> 复制代码
这里我给
ImageView
设置 3 个Physic
的属性layout_shape
设置模拟物理形状为圆形layout_circleRadius
设置圆形的半径为25dp
layout_restitution
设置物体弹性的系数,范围为 [0,1],0 表示完全不反弹,1 表示完全反弹
-
看上去好像效果还行,我们再多加几个试试
子 View
试试<ImageView android:id="@+id/iv_physics_b" android:layout_width="50dp" android:layout_height="50dp" android:src="@mipmap/ic_launcher_round" app:layout_circleRadius="25dp" app:layout_restitution="1.0" app:layout_shape="circle" /> ··· <ImageView android:id="@+id/iv_physics_g" android:layout_width="50dp" android:layout_height="50dp" android:src="@mipmap/ic_launcher_round" app:layout_circleRadius="25dp" app:layout_restitution="1.0" app:layout_shape="circle" /> 复制代码
-
有下坠效果了,但是还不能随手机转动自由转动,在我阅读了
PhysicsLayout
之后发现其并未提供随陀螺仪自由晃动的方法,那我们自己加一个,在MainActivity
给PhysicsLayout
添加一个扩展方法/** * 随手机的转动,施加相应的矢量 * @param x x 轴方向的分量 * @param y y 轴方向的分量 */ fun PhysicsLinearLayout.onSensorChanged(x: Float, y: Float) { for (i in 0..this.childCount) { Log.d(this.javaClass.simpleName, "input vec2 value : x $x, y $y") val impulse = Vec2(x, y) val view: View? = this.getChildAt(i) val body = view?.getTag(com.jawnnypoo.physicslayout.R.id.physics_layout_body_tag) as? Body body?.applyLinearImpulse(impulse, body.position) } } 复制代码
-
在
MainActivity
的onCreate()
中获取陀螺仪数据,并将陀螺仪数据设置给我们为PhysicsLayout
扩展的方法,run
val physicsLayout = findViewById<PhysicsLinearLayout>(R.id.physics_layout) val sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager val gyroSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) gyroSensor?.also { sensor -> sensorManager.registerListener(object : SensorEventListener { override fun onSensorChanged(event: SensorEvent?) { event?.also { if (event.sensor.type == Sensor.TYPE_GYROSCOPE) { Log.d(this@MainActivity.javaClass.simpleName, "sensor value : x ${event.values[0]}, y ${event.values[1]}") physicsLayout.onSensorChanged(-event.values[0], event.values[1]) } } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { } }, sensor, SensorManager.SENSOR_DELAY_UI) } 复制代码
动了,但是好像和预期的效果不太符合呀,而且也不符合用户直觉。
-
那不知道这时候大家是怎么处理问题的,我是先去看看这个库的
issue
,搜索一下和 sensor 相关的提问,第二个就是关于如何让子 view 根据加速度计的数值进行移动,作者给出的答复是使用重力传感器,并在AboutActivity中给出了示例代码。那我们这里就换用重力传感器来试一试。
val gyroSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY) gyroSensor?.also { sensor -> sensorManager.registerListener(object : SensorEventListener { override fun onSensorChanged(event: SensorEvent?) { event?.also { if (event.sensor.type == Sensor.TYPE_GRAVITY) { Log.d(this@MainActivity.javaClass.simpleName, "sensor value : x ${event.values[0]}, y ${event.values[1]}") physicsLayout.physics.setGravity(-event.values[0], event.values[1]) } } } override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { } }, sensor, SensorManager.SENSOR_DELAY_UI) } 复制代码
这下碰撞效果就正常了,但是好像会卡住不动啊!
-
不急,回到
issue
,看第一个提问:物理效果会在子 view 停止移动后结束 和这里遇到的问题一样,看一下互动,有人提出是由于物理模拟引擎在物体移动停止后将物体休眠了。给出的修改方式是设置bodyDef.allowSleep = false
这个属性,是由
子 View
持有,所有现在需要获取子 View
的实例并设置对应的属性,这里我就演示修改其中一个的方式,其他类似。findViewById<ImageView>(R.id.iv_physics_a).apply { if (layoutParams is PhysicsLayoutParams) { (layoutParams as PhysicsLayoutParams).config.bodyDef.allowSleep = false } } ··· 复制代码
-
到这里,这个需求基本就算实现了。
原理
看完了徽章墙的实现方式,我们再来看看 PhysicsLayout
是如何实现这种物理模拟效果的。
-
初看一下代码结构,可以说非常简单
-
那我们先看一下我上面使用到的
PhysicsLinearLayout
class PhysicsLinearLayout : LinearLayout { lateinit var physics: Physics constructor(context: Context) : super(context) { init(null) } constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { init(attrs) } constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { init(attrs) } @TargetApi(21) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) { init(attrs) } private fun init(attrs: AttributeSet?) { setWillNotDraw(false) physics = Physics(this, attrs) } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) physics.onSizeChanged(w, h) } override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) { super.onLayout(changed, l, t, r, b) physics.onLayout(changed) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) physics.onDraw(canvas) } override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { return physics.onInterceptTouchEvent(ev) } @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { return physics.onTouchEvent(event) } override fun generateLayoutParams(attrs: AttributeSet): LayoutParams { return LayoutParams(context, attrs) } class LayoutParams(c: Context, attrs: AttributeSet?) : LinearLayout.LayoutParams(c, attrs), PhysicsLayoutParams { override var config: PhysicsConfig = PhysicsLayoutParamsProcessor.process(c, attrs) } } 复制代码
主要有下面几个重点
- 首先是在构造函数创建了
Physics
实例 - 然后把
View
的绘制,位置,变化,点击事件的处理统统交给了physics
去处理 - 最后由
PhysicsLayoutParamsProcessor
创建PhysicsConfig
的实例
- 首先是在构造函数创建了
-
那我们先来看一下简单一点的
PhysicsLayoutParamsProcessor
object PhysicsLayoutParamsProcessor { /** * 处理子 view 的属性 * * @param c context * @param attrs attributes * @return the PhysicsConfig */ fun process(c: Context, attrs: AttributeSet?): PhysicsConfig { val config = PhysicsConfig() val array = c.obtainStyledAttributes(attrs, R.styleable.Physics_Layout) processCustom(array, config) processBodyDef(array, config) processFixtureDef(array, config) array.recycle() return config } /** * 处理子 view 的形状属性 */ private fun processCustom(array: TypedArray, config: PhysicsConfig) { if (array.hasValue(R.styleable.Physics_Layout_layout_shape)) { val shape = when (array.getInt(R.styleable.Physics_Layout_layout_shape, 0)) { 1 -> Shape.CIRCLE else -> Shape.RECTANGLE } config.shape = shape } if (array.hasValue(R.styleable.Physics_Layout_layout_circleRadius)) { val radius = array.getDimensionPixelSize(R.styleable.Physics_Layout_layout_circleRadius, -1) config.radius = radius.toFloat() } } /** * 处理子 view 的刚体属性 * 1. 刚体类型 * 2. 刚体是否可以旋转 */ private fun processBodyDef(array: TypedArray, config: PhysicsConfig) { if (array.hasValue(R.styleable.Physics_Layout_layout_bodyType)) { val type = array.getInt(R.styleable.Physics_Layout_layout_bodyType, BodyType.DYNAMIC.ordinal) config.bodyDef.type = BodyType.values()[type] } if (array.hasValue(R.styleable.Physics_Layout_layout_fixedRotation)) { val fixedRotation = array.getBoolean(R.styleable.Physics_Layout_layout_fixedRotation, false) config.bodyDef.fixedRotation = fixedRotation } } /** * 处理子 view 的刚体描述 * 1. 刚体的摩擦系数 * 2. 刚体的补偿系数 * 3. 刚体的密度 */ private fun processFixtureDef(array: TypedArray, config: PhysicsConfig) { if (array.hasValue(R.styleable.Physics_Layout_layout_friction)) { val friction = array.getFloat(R.styleable.Physics_Layout_layout_friction, -1f) config.fixtureDef.friction = friction } if (array.hasValue(R.styleable.Physics_Layout_layout_restitution)) { val restitution = array.getFloat(R.styleable.Physics_Layout_layout_restitution, -1f) config.fixtureDef.restitution = restitution } if (array.hasValue(R.styleable.Physics_Layout_layout_density)) { val density = array.getFloat(R.styleable.Physics_Layout_layout_density, -1f) config.fixtureDef.density = density } } } 复制代码
这个类比较简单,就是一个常规的读取设置并创建一个对应的
PhysicsConfig
的属性 -
现在我们来看最关键的
Physics
,这个类代码相对比较长,我就不完全贴出来了,一段一段的来分析- 首先定义了一些伴生对象,主要是预设了几种重力值,模拟世界的边界尺寸,渲染帧率
companion object { private val TAG = Physics::class.java.simpleName const val NO_GRAVITY = 0.0f const val MOON_GRAVITY = 1.6f const val EARTH_GRAVITY = 9.8f const val JUPITER_GRAVITY = 24.8f // Size in DP of the bounds (world walls) of the view private const val BOUND_SIZE_DP = 20 private const val FRAME_RATE = 1 / 60f /** * 在创建 view 对应的刚体时,设置配置参数 * 当布局已经被渲染之后改变 view 的配置需要调用 ViewGroup.requestLayout,刚体才能使用新的配置创建 */ fun setPhysicsConfig(view: View, config: PhysicsConfig?) { view.setTag(R.id.physics_layout_config_tag, config) } } 复制代码
- 然后定义了很多的成员变量,这里挑几个重要的说一说吧
/** * 模拟世界每一步渲染的计算速度,默认是 8 */ var velocityIterations = 8 /** * 模拟世界每一步渲染的迭代速度,默认是 3 */ var positionIterations = 3 /** * 模拟世界每一米对应多少个像素,可以用来调整模拟世界的大小 */ var pixelsPerMeter = 0f /** * 当前控制着 view 的物理状态的模拟世界 */ var world: World? = null private set 复制代码
- 在
init
方法中主要是读取一些Physics
配置,另外初始化了一个拖拽手势处理的实例init { viewDragHelper = TranslationViewDragHelper.create(viewGroup, 1.0f, viewDragHelperCallback) density = viewGroup.resources.displayMetrics.density if (attrs != null) { val a = viewGroup.context .obtainStyledAttributes(attrs, R.styleable.Physics) ··· a.recycle() } } 复制代码
- 然后提供了一些物理长度,角度的换算方法
- 在
onLayout
中创建了模拟世界,根据边界设置决定是否启用边界,设置碰撞处理回调,根据子 view
创建刚体private fun createWorld() { // Null out all the bodies val oldBodiesArray = ArrayList<Body?>() for (i in 0 until viewGroup.childCount) { val body = viewGroup.getChildAt(i).getTag(R.id.physics_layout_body_tag) as? Body oldBodiesArray.add(body) viewGroup.getChildAt(i).setTag(R.id.physics_layout_body_tag, null) } bounds.clear() if (debugLog) { Log.d(TAG, "createWorld") } world = World(Vec2(gravityX, gravityY)) world?.setContactListener(contactListener) if (hasBounds) { enableBounds() } for (i in 0 until viewGroup.childCount) { val body = createBody(viewGroup.getChildAt(i), oldBodiesArray[i]) onBodyCreatedListener?.onBodyCreated(viewGroup.getChildAt(i), body) } } 复制代码
- 在
onInterceptTouchEvent
,onTouchEvent
中处理手势事件,如果没有开启滑动拖拽,时间继续传递,如果开启了,则由viewDragHelper
来处理手势事件。fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (!isFlingEnabled) { return false } val action = ev.actionMasked if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) { viewDragHelper.cancel() return false } return viewDragHelper.shouldInterceptTouchEvent(ev) } fun onTouchEvent(ev: MotionEvent): Boolean { if (!isFlingEnabled) { return false } viewDragHelper.processTouchEvent(ev) return true } 复制代码
- 在
onDraw
中绘制view
的物理效果-
先设置世界的物理配置
val world = world if (!isPhysicsEnabled || world == null) { return } world.step(FRAME_RATE, velocityIterations, positionIterations) 复制代码
-
遍历
子 view
并获取此前在创建刚体时设置的刚体对象,对于正在被拖拽的view
将其移动到对应的位置translateBodyToView(body, view) view.rotation = radiansToDegrees(body.angle) % 360f 复制代码
-
否则的话,设置
view
的物理位置,这里的debugDraw
一直是false
所以并不会走这段逻辑,且由于是私有属性,外部无法修改,似乎永远不会走这里view.x = metersToPixels(body.position.x) - view.width / 2f view.y = metersToPixels(body.position.y) - view.height / 2f view.rotation = radiansToDegrees(body.angle) % 360f if (debugDraw) { val config = view.getTag(R.id.physics_layout_config_tag) as PhysicsConfig when (config.shape) { Shape.RECTANGLE -> { canvas.drawRect( metersToPixels(body.position.x) - view.width / 2, metersToPixels(body.position.y) - view.height / 2, metersToPixels(body.position.x) + view.width / 2, metersToPixels(body.position.y) + view.height / 2, debugPaint ) } Shape.CIRCLE -> { canvas.drawCircle( metersToPixels(body.position.x), metersToPixels(body.position.y), config.radius, debugPaint ) } } } 复制代码
-
最后提供了一个接口便于我们在需要的时候修改
JBox2D
处理view
对应的刚体的物理状态onPhysicsProcessedListeners.forEach { it.onPhysicsProcessed(this, world) } 复制代码
-
- 还有一个测试物理碰撞效果的随机碰撞方法
fun giveRandomImpulse() { var body: Body? var impulse: Vec2 val random = Random() for (i in 0 until viewGroup.childCount) { impulse = Vec2((random.nextInt(1000) - 1000).toFloat(), (random.nextInt(1000) - 1000).toFloat()) body = viewGroup.getChildAt(i).getTag(R.id.physics_layout_body_tag) as? Body body?.applyLinearImpulse(impulse, body.position) } } 复制代码
- 首先定义了一些伴生对象,主要是预设了几种重力值,模拟世界的边界尺寸,渲染帧率
Bonus
-
在上面分析代码的时候,多次提到手势拖拽,那怎么实现这个手势的效果,目前好像对手是没反应嘛~
其实也很简单,将
physics
的isFlingEnabled
属性设置为true
即可。val physicsLayout = findViewById<PhysicsLinearLayout>(R.id.physics_layout).apply { physics.isFlingEnabled = true } 复制代码
-
在浏览
PhysicsLayout
issue 的时候还意外的发现已经有国人实现了Compose
版本的 JetpackComposePhysicsLayout