【自定义 View】Android 实现物理碰撞效果的徽章墙
前言
在还没有疫情的年代,外出经常会选择高铁,等高铁的时候我就喜欢打开 掌上高铁 的成就,签到领个徽章,顺便玩一下那个类似碰撞小球的徽章墙,当时我就在想这东西怎么实现的,但是吧,实在太懒了/doge,这几年都没尝试去自己实现过。最近有时间倒逼自己做了一些学习和尝试,就分享一下这种功能的实现。
不过,当我为写这篇文章做准备的时候,据不完全考古发现,似乎摩拜的 app 更早就实现了这个需求,但有没有更早的我就不知道了/doge
其实呢,我想起来做这个尝试是我在一个 Android
自定义 View 合集的库 里看到了一个叫 PhysicsLayout 的库,当时我就虎躯一震,我心心念念的徽章墙不就是这个效果嘛,于是也就有了这篇文章。这个 PhysicsLayout
其实是借助 JBox2D
来实现的,但不妨先借助 PhysicsLayout
实现徽章墙,然后再来探索 PhysicsLayout
的实现方式。
实现
添加依赖,
sync
implementation("com.jawnnypoo:physicslayout:3.0.1")
在布局文件中添加
PhysicsLinearLayout
,并添加一个子 View
,run
起来这里我给
ImageView
设置 3 个Physic
的属性layout_shape
设置模拟物理形状为圆形layout_circleRadius
设置圆形的半径为25dp
layout_restitution
设置物体弹性的系数,范围为 [0,1],0 表示完全不反弹,1 表示完全反弹
看上去好像效果还行,我们再多加几个试试
子 View
试试有下坠效果了,但是还不能随手机转动自由转动,在我阅读了
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(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(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()
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(R.id.physics_layout).apply {
physics.isFlingEnabled = true
}在浏览
PhysicsLayout
issue 的时候还意外的发现已经有国人实现了Compose
版本的
JetpackComposePhysicsLayout
链接:https://juejin.cn/post/7208508980162101308
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。