注册

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

前言


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



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



x762f-m3kvm.gif


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


实现




  1. 添加依赖,sync


    implementation("com.jawnnypoo:physicslayout:3.0.1")



  2. 在布局文件中添加PhysicsLinearLayout,并添加一个 子 Viewrun 起来


    这里我给ImageView设置 3 个Physic的属性



    • layout_shape设置模拟物理形状为圆形
    • layout_circleRadius设置圆形的半径为25dp
    • layout_restitution设置物体弹性的系数,范围为 [0,1],0 表示完全不反弹,1 表示完全反弹



  3. 看上去好像效果还行,我们再多加几个试试 子 View 试试





  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(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(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()
      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(R.id.physics_layout).apply {
    physics.isFlingEnabled = true
    }




  2. 在浏览 PhysicsLayout issue 的时候还意外的发现已经有国人实现了 Compose 版本的
    JetpackComposePhysicsLayout


作者:Randall_Xia
链接:https://juejin.cn/post/7208508980162101308
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册