前言
在还没有疫情的时代,外出常常会挑选高铁,等高铁的时分我就喜欢打开 掌上高铁 的成果,报到领个徽章,趁便玩一下那个类似磕碰小球的徽章墙,其时我就在想这东西怎样完成的,可是吧,实在太懒了/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
参阅文章
PhysicsLayout
使用jbox2d物理引擎打造摩拜单车贴纸动画作用
JBox2D详解