Andoid 仿自如裸眼 3D 效果

前言

  前段時(shí)間自如技術(shù)團(tuán)隊(duì)發(fā)布了一篇名為《自如客APP裸眼3D效果的實(shí)現(xiàn)》的技術(shù)分享文章,簡(jiǎn)述了通過(guò)將圖層分為前中后景,監(jiān)聽(tīng)手機(jī)傾斜角度,再根據(jù)傾斜角度反向移動(dòng)前后景,實(shí)現(xiàn)類似裸眼 3D 的效果。 該文章中已將思路與原理講述清楚,抱著好奇心嘗試仿現(xiàn)了一下。

1.自如的思路分析探究

1.1 自如 APP 上的裸眼 3D 效果

   UI 層面上:將普通的 2D 圖像切割出 后景、中景、前景 三個(gè)部分

1.2 普通 2D 圖像

1.3 切割出來(lái)的 后景、 中景 及 前景

   技術(shù)層面上:通過(guò) Android 中的 磁場(chǎng)傳感器加速度傳感器 監(jiān)聽(tīng)設(shè)備的傾斜角度,保持 中景 不動(dòng),根據(jù)傾斜角度反向移動(dòng) 背景前景 ,將 2D 圖像轉(zhuǎn)化為景深效果,呈現(xiàn)出類似裸眼 3D 的視覺(jué)效果。
[圖片上傳失敗...(image-b54255-1632972462366)]

思路上就是這么清晰和簡(jiǎn)單,現(xiàn)需求如下:
  根據(jù)設(shè)備傾斜角度 平穩(wěn)移動(dòng) 前后景,實(shí)現(xiàn)裸眼 3D 效果
  其中前后景在 Y 軸上的移動(dòng)范圍和速度均比 X 軸小和慢

2.具體實(shí)現(xiàn)

2.1 實(shí)現(xiàn)效果

2.1.1 仿現(xiàn)效果

2.2 具體實(shí)現(xiàn)

2.2.1 自定義 GravityRotationImageView :

   1.繼承于 ImageView ,內(nèi)部實(shí)現(xiàn) Scroller
   2.提供自定義屬性 isBack 區(qū)分該 View 用作前景還是后景,前后景移動(dòng)方向不同,且后景 ImageView 的填充應(yīng)存在一定的放大倍數(shù)

    /**
     * 設(shè)置當(dāng)前 view 為前景或后景
     * @param isBack true 后景 ; false 前景
     */
    fun isBack(isBack: Boolean) {
        /**
         * 判斷該 view 用作前景還是后景
         * 后景則需調(diào)整放大倍數(shù)使內(nèi)容滾動(dòng)時(shí)不會(huì)出現(xiàn)白邊
         * 并根據(jù)前后景記錄對(duì)應(yīng)的滾動(dòng)方向
         */
        if (isBack) {
            mDirection = DIRECTION_BACK
            scaleType = ScaleType.CENTER_CROP
            scaleX = 1.1f
            scaleY = 1.2f
        } else {
            mDirection = DIRECTION_FRONT
        }
    }

   3.提供 handleSensorChangedValues 方法,該方法中根據(jù)得到的傳感器數(shù)據(jù)計(jì)算傾斜角度,過(guò)濾抖動(dòng)(角度變化過(guò)小/過(guò)大),并得到需要移動(dòng)的距離,最后通過(guò) Scroller 輔助移動(dòng)

    /**
     * 處理傳感器得到的數(shù)據(jù),過(guò)濾后再根據(jù)傾斜角度移動(dòng)當(dāng)前 view
     * 旋轉(zhuǎn)移動(dòng)過(guò)程中,前景后景隨旋轉(zhuǎn)角度偏移
     */
    internal fun handleSensorChangedValues(
        gravity: FloatArray,
        geomagnetic: FloatArray,
        maxMovingRange: Float = MOVING_RANGE_DEFAULT
    ) {
        if (maxMovingRange != MOVING_RANGE_DEFAULT) {
            mMaxMovingRange = dip2px(this.context, maxMovingRange)
        }
        //旋轉(zhuǎn)角度值集
        val orientationValues = FloatArray(3)
        //旋轉(zhuǎn)矩陣
        val rotationMatrix = FloatArray(9)
        SensorManager.getRotationMatrix(
            rotationMatrix,
            null,
            gravity,
            geomagnetic
        )
        SensorManager.getOrientation(rotationMatrix, orientationValues)
        // z 軸的偏轉(zhuǎn)角度
        orientationValues[0] = Math.toDegrees(orientationValues[0].toDouble()).toFloat()
        // x 軸的偏轉(zhuǎn)角度
        orientationValues[1] = Math.toDegrees(orientationValues[1].toDouble()).toFloat()
        // y 軸的偏轉(zhuǎn)角度
        orientationValues[2] = Math.toDegrees(orientationValues[2].toDouble()).toFloat()
        val newAngleX = orientationValues[1].toInt()
        val newAngleY = orientationValues[2].toInt()
        // x 、 y 軸角度變化值
        val rotationAngleXChangeValue = abs(newAngleX - rotationAngleX)
        val rotationAngleYChangeValue = abs(newAngleY - rotationAngleY)
        var targetX = mScroller.finalX
        var targetY = mScroller.finalY
        if (rotationAngleYChangeValue in (RESPONSE_ANGLE_CHANGE_MIN + 1) until RESPONSE_ANGLE_CHANGE_MAX
            || rotationAngleXChangeValue in (RESPONSE_ANGLE_CHANGE_MIN + 1) until RESPONSE_ANGLE_CHANGE_MAX
        ) {
            if (newAngleX <= 0 && newAngleX > -MAX_ROTATION_ANGLE || newAngleX in 1 until MAX_ROTATION_ANGLE) {
                targetY = mMaxMovingRange * -mDirection * newAngleX / MAX_ROTATION_ANGLE_Y
            }
            if (newAngleY <= 0 && newAngleY > -MAX_ROTATION_ANGLE || newAngleY in 1 until MAX_ROTATION_ANGLE) {
                targetX = mMaxMovingRange * mDirection * newAngleY / MAX_ROTATION_ANGLE
            }
            val dx = targetX - scrollX
            val dy = targetY - scrollY
            smoothScroll(dx, dy)
            //更新角度
            rotationAngleX = newAngleX
            rotationAngleY = newAngleY
        }
    }

2.2.2 自定義幫助類 GravityRotationHelper:

   1.構(gòu)造方法中得到已實(shí)現(xiàn) LifecycleOwner 的 context 對(duì)象,通過(guò) Lifecycle 特性在 context 對(duì)象的相應(yīng)生命周期中進(jìn)行 加速度傳感器磁場(chǎng)傳感器 的注冊(cè)與反注冊(cè)

    init {
        if (context is LifecycleOwner) {
            //獲取傳感器管理類實(shí)例
            mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
            //加速度傳感器實(shí)例
            val accelerationSensor = mSensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
            //磁場(chǎng)傳感器
            val magneticSensor = mSensorManager?.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
            context.lifecycle.addObserver(object : LifecycleObserver {
                @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
                fun onResume(@NotNull owner: LifecycleOwner) {
                    //注冊(cè)監(jiān)聽(tīng)
                    mSensorManager?.registerListener(
                        mSensorEventListener,
                        accelerationSensor,
                        SensorManager.SENSOR_DELAY_GAME
                    )
                    mSensorManager?.registerListener(
                        mSensorEventListener,
                        magneticSensor,
                        SensorManager.SENSOR_DELAY_GAME
                    )
                }
 
                @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE)
                fun onPause(@NotNull owner: LifecycleOwner) {
                    mSensorManager?.unregisterListener(mSensorEventListener)
                }
            })
        } else {
            Log.e(
                "GravityRotationHelper",
                "GravityRotationHelper init error : context is LifecycleOwner = false "
            )
        }
    }

   2.提供 attachViews 方法,得到外部需要實(shí)現(xiàn)裸眼 3D 效果的前景與后景 View ,舊持有前景后景 View 不為空時(shí),記錄并重置對(duì)應(yīng) scroll 值

    /**
     * 添加需要實(shí)現(xiàn)裸眼 3D 效果的視圖組
     * 旋轉(zhuǎn)移動(dòng)過(guò)程中,前景后景隨旋轉(zhuǎn)角度偏移
     * @param frontView 前景
     * @param backView 后景
     * @param maxMovingRange 最大可移動(dòng)范圍 dp
     */
    fun attachViews(
        frontView: GravityRotationImageView,
        backView: GravityRotationImageView,
        maxMovingRange: Float = MOVING_RANGE_DEFAULT
    ) {
        //舊持有前景后景 View 不為空時(shí),記錄并重置對(duì)應(yīng) scroll 值
        val oldFrontViewScrollX = mFrontView?.scrollX ?: 0
        val oldFrontViewScrollY = mFrontView?.scrollY ?: 0
        val oldBackViewScrollX = mBackView?.scrollX ?: 0
        val oldBackViewScrollY = mBackView?.scrollY ?: 0
        val oldRotationAngleX = mFrontView?.rotationAngleX ?: 0
        val oldRotationAngleY = mFrontView?.rotationAngleY ?: 0
        mFrontView = frontView
        mBackView = backView
        mFrontView?.rotationAngleX = oldRotationAngleX
        mFrontView?.rotationAngleY = oldRotationAngleY
        mBackView?.rotationAngleX = oldRotationAngleX
        mBackView?.rotationAngleY = oldRotationAngleY
        //繼承上一組前景后景 View 的 scroll 值
        mFrontView?.scrollTo(oldFrontViewScrollX, oldFrontViewScrollY)
        mBackView?.scrollTo(oldBackViewScrollX, oldBackViewScrollY)
        mMaxMovingRange = maxMovingRange
    }

   3.傳感器數(shù)值變化時(shí)調(diào)用前后景 View 的 handleSensorChangedValues 方法進(jìn)行移動(dòng)

    private var mSensorEventListener = object : SensorEventListener {
        override fun onSensorChanged(event: SensorEvent) {
            when (event.sensor.type) {
                Sensor.TYPE_ACCELEROMETER -> {
                    //加速度
                    mAccelerationValues = event.values
                    handleAccelerometerAndMagneticData()
                }
                Sensor.TYPE_MAGNETIC_FIELD -> {
                    //磁場(chǎng)
                    mMagneticValues = event.values
                    handleAccelerometerAndMagneticData()
                }
            }
 
        }
 
        override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
 
        }
    }
 
    private fun handleAccelerometerAndMagneticData() {
        if (mAccelerationValues != null && mMagneticValues != null) {
            if (mFrontView != null && mBackView !== null) {
                mFrontView?.handleSensorChangedValues(
                    mAccelerationValues!!,
                    mMagneticValues!!,
                    mMaxMovingRange
                )
                mBackView?.handleSensorChangedValues(
                    mAccelerationValues!!,
                    mMagneticValues!!,
                    mMaxMovingRange
                )
            }
        }
    }

2.3 使用步驟

   1.復(fù)制 Demo 中的 GravityRotationHelperGravityRotationImageView 以及自定義屬性 attrs 到項(xiàng)目中
   2.布局中使用 GravityRotationImageView 作為需要實(shí)現(xiàn) 3D 效果的前景與后景 View

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipChildren="false">
 
    <com.ziwenl.library.GravityRotationImageView
        android:id="@+id/iv_back"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:paddingBottom="40dp"
        android:src="@mipmap/banner_a_back"
        app:isBack="true"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
    
    <FrameLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="20dp"
        android:clipChildren="false"
        app:layout_constraintBottom_toBottomOf="@+id/iv_back"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent">
 
        <ImageView
            android:id="@+id/iv_middle"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@mipmap/banner_a_middle" />
 
 
        <com.ziwenl.library.GravityRotationImageView
            android:id="@+id/iv_front"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:src="@mipmap/banner_a_front" />
    </FrameLayout>
 
</androidx.constraintlayout.widget.ConstraintLayout>

( ps : 可按需給父 View 設(shè)置 android:clipChildren="false" 屬性,控制前景移動(dòng)到邊界時(shí)是否裁剪 )
   3.使用幫助類 GravityRotationHelper 綁定前景和后景 View 實(shí)現(xiàn)目標(biāo)效果

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val viewBinding = ActivitySinglepageBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)
 
        GravityRotationHelper(this).attachViews(viewBinding.ivFront, viewBinding.ivBack)
    }

( ps:關(guān)于在 banner 中實(shí)現(xiàn)該效果,可參考 demo 中的 BannerActivity 類 )

3.補(bǔ)充說(shuō)明

  • 提取成幫助類而不是在自定義 View 中進(jìn)行傳感器的創(chuàng)建與注冊(cè)監(jiān)聽(tīng),主要是為了減少耦合及資源開(kāi)銷
  • 自定義 ImageView 是為了使用 Scroller 來(lái)進(jìn)行輔助滾動(dòng),如果只是在 View 外部通過(guò)監(jiān)聽(tīng)設(shè)備傾斜角再通過(guò) View 的 scroll 方法進(jìn)行移動(dòng),會(huì)出現(xiàn)抖動(dòng)及跳動(dòng)問(wèn)題
  • 除了使用 磁場(chǎng)傳感器加速度傳感器 來(lái)感知設(shè)備傾斜角度變化,還能使用 陀螺儀傳感器 來(lái)感知設(shè)備的傾斜角度變化,同樣能實(shí)現(xiàn)目標(biāo)效果
    private val NS2S = 1.0f / 1000000000.0f
    private var timestamp = 0f
 
    private fun init(context: Context){
        val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
        val gyroscopeSensor = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
        sensorManager?.registerListener(object : SensorEventListener {
            override fun onSensorChanged(event: SensorEvent) {
                if (event.sensor.type == Sensor.TYPE_GYROSCOPE) {
                    if (timestamp != 0f) {
                        val dT = (event.timestamp - timestamp) * NS2S
                        angle[0] += event.values[0] * dT
                        angle[1] += event.values[1] * dT
                        val angleY = Math.toDegrees(angle[0].toDouble()).toFloat()
                        val angleX = Math.toDegrees(angle[1].toDouble()).toFloat()
                        //TODO
 
                    }
                    timestamp = event.timestamp.toFloat()
                }
            }
 
            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
 
            }
        }, gyroscopeSensor, SENSOR_DELAY_GAME)
    }

4.最后

  關(guān)于該偽裸眼 3D 效果,自自如團(tuán)隊(duì)發(fā)布技術(shù)文章之后,網(wǎng)上也有一系列 Demo 及技術(shù)文章,本人在實(shí)現(xiàn)過(guò)程中遇到了抖動(dòng)和跳動(dòng)問(wèn)題(主要由于傳感器數(shù)值變化過(guò)于敏感及頻繁導(dǎo)致),曾去下載一些 Demo 進(jìn)行參考,發(fā)現(xiàn)同樣是存在該問(wèn)題。其中有篇文章是通過(guò) 陀螺儀傳感器 來(lái)實(shí)現(xiàn)該效果的,也做了抖動(dòng)過(guò)濾,但在小米 6 上運(yùn)行時(shí)發(fā)現(xiàn)會(huì)出現(xiàn)卡頓效果,所以最后還是自己調(diào)整優(yōu)化避免了該現(xiàn)象的出現(xiàn)。
  最后感謝 自如大前端團(tuán)隊(duì) 的實(shí)現(xiàn)方案分享,通過(guò)新穎取巧的方式,加強(qiáng)了用戶的 UI 體驗(yàn)。而自如的技術(shù)文章更著重于分享思路,所以在此基礎(chǔ)上進(jìn)行實(shí)現(xiàn)與優(yōu)化,也是一種不可多得的樂(lè)趣。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過(guò)簡(jiǎn)信或評(píng)論聯(lián)系作者。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容