前言

在还没有疫情的时代,外出常常会挑选高铁,等高铁的时分我就喜欢打开 掌上高铁 的成果,报到领个徽章,趁便玩一下那个类似磕碰小球的徽章墙,其时我就在想这东西怎样完成的,可是吧,实在太懒了/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. 初看一下代码结构,能够说非常简略

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

  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
    }
    
    【自定义 View】Android 实现物理碰撞效果的徽章墙
  2. 在浏览 PhysicsLayout issue 的时分还意外的发现现已有国人完成了 Compose 版本的 JetpackComposePhysicsLayout

参阅文章

PhysicsLayout

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

JBox2D详解