【自定义 View】Android 实现物理碰撞效果的徽章墙

【自定义 View】Android 实现物理碰撞效果的徽章墙

前言

在还没有疫情的年代,外出经常会选择高铁,等高铁的时候我就喜欢打开 掌上高铁 的成就,签到领个徽章,顺便玩一下那个类似碰撞小球的徽章墙,当时我就在想这东西怎么实现的,但是吧,实在太懒了/doge,这几年都没尝试去自己实现过。最近有时间倒逼自己做了一些学习和尝试,就分享一下这种功能的实现。

不过,当我为写这篇文章做准备的时候,据不完全考古发现,似乎摩拜的 app 更早就实现了这个需求,但有没有更早的我就不知道了/doge

其实呢,我想起来做这个尝试是我在一个 Android 自定义 View 合集的库 里看到了一个叫 PhysicsLayout 的库,当时我就虎躯一震,我心心念念的徽章墙不就是这个效果嘛,于是也就有了这篇文章。这个 PhysicsLayout 其实是借助 JBox2D 来实现的,但不妨先借助 PhysicsLayout 实现徽章墙,然后再来探索 PhysicsLayout 的实现方式。

实现

  1. 添加依赖,sync

    implementation("com.jawnnypoo:physicslayout:3.0.1")
    复制代码
  2. 在布局文件中添加PhysicsLinearLayout,并添加一个 子 Viewrun 起来

    <?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 表示完全反弹
  3. 看上去好像效果还行,我们再多加几个试试 子 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" />
    复制代码
  4. 有下坠效果了,但是还不能随手机转动自由转动,在我阅读了 PhysicsLayout 之后发现其并未提供随陀螺仪自由晃动的方法,那我们自己加一个,在 MainActivityPhysicsLayout 添加一个扩展方法

        /**
         * 随手机的转动,施加相应的矢量
         * @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)
            }
        }
    复制代码
  5. MainActivityonCreate() 中获取陀螺仪数据,并将陀螺仪数据设置给我们为 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)
        }
    复制代码

    动了,但是好像和预期的效果不太符合呀,而且也不符合用户直觉。

  6. 那不知道这时候大家是怎么处理问题的,我是先去看看这个库的 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)
        }
    复制代码

    这下碰撞效果就正常了,但是好像会卡住不动啊!

  7. 不急,回到 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
            }
        }
        
        ···
    复制代码
  8. 到这里,这个需求基本就算实现了。

原理

看完了徽章墙的实现方式,我们再来看看 PhysicsLayout 是如何实现这种物理模拟效果的。

  1. 初看一下代码结构,可以说非常简单

    image.png

  2. 那我们先看一下我上面使用到的 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)
        }
    }
    复制代码

    主要有下面几个重点

    1. 首先是在构造函数创建了 Physics 实例
    2. 然后把 View 的绘制,位置,变化,点击事件的处理统统交给了 physics 去处理
    3. 最后由 PhysicsLayoutParamsProcessor 创建 PhysicsConfig 的实例
  3. 那我们先来看一下简单一点的 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 的属性

  4. 现在我们来看最关键的 Physics,这个类代码相对比较长,我就不完全贴出来了,一段一段的来分析

    1. 首先定义了一些伴生对象,主要是预设了几种重力值,模拟世界的边界尺寸,渲染帧率
      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)
          }
      }
      复制代码
    2. 然后定义了很多的成员变量,这里挑几个重要的说一说吧
      /**
       * 模拟世界每一步渲染的计算速度,默认是 8
       */
      var velocityIterations = 8
      
      /**
       * 模拟世界每一步渲染的迭代速度,默认是 3
       */
      var positionIterations = 3
      
      /**
       * 模拟世界每一米对应多少个像素,可以用来调整模拟世界的大小
       */
      var pixelsPerMeter = 0f
      
      /**
       * 当前控制着 view 的物理状态的模拟世界
       */
      var world: World? = null
          private set
      复制代码
    3. 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()
          }
      }
      复制代码
    4. 然后提供了一些物理长度,角度的换算方法
    5. 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)
          }
      }
      复制代码
    6. onInterceptTouchEventonTouchEvent 中处理手势事件,如果没有开启滑动拖拽,时间继续传递,如果开启了,则由 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
      }
      复制代码
    7. onDraw 中绘制 view 的物理效果
      1. 先设置世界的物理配置

        val world = world
        if (!isPhysicsEnabled || world == null) {
            return
        }
        world.step(FRAME_RATE, velocityIterations, positionIterations)
        复制代码
      2. 遍历 子 view 并获取此前在创建刚体时设置的刚体对象,对于正在被拖拽的 view 将其移动到对应的位置

        translateBodyToView(body, view)
        view.rotation = radiansToDegrees(body.angle) % 360f
        复制代码
      3. 否则的话,设置 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
                     )
                 }
             }
         }
        复制代码
      4. 最后提供了一个接口便于我们在需要的时候修改 JBox2D 处理 view 对应的刚体的物理状态

        onPhysicsProcessedListeners.forEach { it.onPhysicsProcessed(this, world) }
        复制代码
    8. 还有一个测试物理碰撞效果的随机碰撞方法
      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

  1. 在上面分析代码的时候,多次提到手势拖拽,那怎么实现这个手势的效果,目前好像对手是没反应嘛~

    其实也很简单,将 physicsisFlingEnabled 属性设置为 true 即可。

    val physicsLayout = findViewById<PhysicsLinearLayout>(R.id.physics_layout).apply {
        physics.isFlingEnabled = true
    }
    复制代码
  2. 在浏览 PhysicsLayout issue 的时候还意外的发现已经有国人实现了 Compose 版本的 JetpackComposePhysicsLayout

参考文章

PhysicsLayout

使用jbox2d物理引擎打造摩拜单车贴纸动画效果

JBox2D详解

分类:
Android
标签: