Andoid 仿自如裸眼 3D 效果

前言

  前段时间自如技术团队发布了一篇名为《自如客APP裸眼3D效果的实现》的技术分享文章,简述了通过将图层分为前中后景,监听手机倾斜角度,再根据倾斜角度反向移动前后景,实现类似裸眼 3D 的效果。 该文章中已将思路与原理讲述清楚,抱着好奇心尝试仿现了一下。

1.自如的思路分析探究

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

   UI 层面上:将普通的 2D 图像切割出 后景中景前景 三个部分

1.2 普通 2D 图像

1.3 切割出来的 后景、 中景 及 前景

   技术层面上:通过 Android 中的 磁场传感器加速度传感器 监听设备的倾斜角度,保持 中景 不动,根据倾斜角度反向移动 背景前景 ,将 2D 图像转化为景深效果,呈现出类似裸眼 3D 的视觉效果。
[图片上传失败...(image-b54255-1632972462366)]

思路上就是这么清晰和简单,现需求如下:
  根据设备倾斜角度 平稳移动 前后景,实现裸眼 3D 效果
  其中前后景在 Y 轴上的移动范围和速度均比 X 轴小和慢

2.具体实现

2.1 实现效果

2.1.1 仿现效果

2.2 具体实现

2.2.1 自定义 GravityRotationImageView :

   1.继承于 ImageView ,内部实现 Scroller
   2.提供自定义属性 isBack 区分该 View 用作前景还是后景,前后景移动方向不同,且后景 ImageView 的填充应存在一定的放大倍数

    /**
     * 设置当前 view 为前景或后景
     * @param isBack true 后景 ; false 前景
     */
    fun isBack(isBack: Boolean) {
        /**
         * 判断该 view 用作前景还是后景
         * 后景则需调整放大倍数使内容滚动时不会出现白边
         * 并根据前后景记录对应的滚动方向
         */
        if (isBack) {
            mDirection = DIRECTION_BACK
            scaleType = ScaleType.CENTER_CROP
            scaleX = 1.1f
            scaleY = 1.2f
        } else {
            mDirection = DIRECTION_FRONT
        }
    }

   3.提供 handleSensorChangedValues 方法,该方法中根据得到的传感器数据计算倾斜角度,过滤抖动(角度变化过小/过大),并得到需要移动的距离,最后通过 Scroller 辅助移动

    /**
     * 处理传感器得到的数据,过滤后再根据倾斜角度移动当前 view
     * 旋转移动过程中,前景后景随旋转角度偏移
     */
    internal fun handleSensorChangedValues(
        gravity: FloatArray,
        geomagnetic: FloatArray,
        maxMovingRange: Float = MOVING_RANGE_DEFAULT
    ) {
        if (maxMovingRange != MOVING_RANGE_DEFAULT) {
            mMaxMovingRange = dip2px(this.context, maxMovingRange)
        }
        //旋转角度值集
        val orientationValues = FloatArray(3)
        //旋转矩阵
        val rotationMatrix = FloatArray(9)
        SensorManager.getRotationMatrix(
            rotationMatrix,
            null,
            gravity,
            geomagnetic
        )
        SensorManager.getOrientation(rotationMatrix, orientationValues)
        // z 轴的偏转角度
        orientationValues[0] = Math.toDegrees(orientationValues[0].toDouble()).toFloat()
        // x 轴的偏转角度
        orientationValues[1] = Math.toDegrees(orientationValues[1].toDouble()).toFloat()
        // y 轴的偏转角度
        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.构造方法中得到已实现 LifecycleOwner 的 context 对象,通过 Lifecycle 特性在 context 对象的相应生命周期中进行 加速度传感器磁场传感器 的注册与反注册

    init {
        if (context is LifecycleOwner) {
            //获取传感器管理类实例
            mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
            //加速度传感器实例
            val accelerationSensor = mSensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
            //磁场传感器
            val magneticSensor = mSensorManager?.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
            context.lifecycle.addObserver(object : LifecycleObserver {
                @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
                fun onResume(@NotNull owner: LifecycleOwner) {
                    //注册监听
                    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 方法,得到外部需要实现裸眼 3D 效果的前景与后景 View ,旧持有前景后景 View 不为空时,记录并重置对应 scroll 值

    /**
     * 添加需要实现裸眼 3D 效果的视图组
     * 旋转移动过程中,前景后景随旋转角度偏移
     * @param frontView 前景
     * @param backView 后景
     * @param maxMovingRange 最大可移动范围 dp
     */
    fun attachViews(
        frontView: GravityRotationImageView,
        backView: GravityRotationImageView,
        maxMovingRange: Float = MOVING_RANGE_DEFAULT
    ) {
        //旧持有前景后景 View 不为空时,记录并重置对应 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.传感器数值变化时调用前后景 View 的 handleSensorChangedValues 方法进行移动

    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 -> {
                    //磁场
                    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.复制 Demo 中的 GravityRotationHelperGravityRotationImageView 以及自定义属性 attrs 到项目中
   2.布局中使用 GravityRotationImageView 作为需要实现 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 设置 android:clipChildren="false" 属性,控制前景移动到边界时是否裁剪 )
   3.使用帮助类 GravityRotationHelper 绑定前景和后景 View 实现目标效果

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

( ps:关于在 banner 中实现该效果,可参考 demo 中的 BannerActivity 类 )

3.补充说明

  • 提取成帮助类而不是在自定义 View 中进行传感器的创建与注册监听,主要是为了减少耦合及资源开销
  • 自定义 ImageView 是为了使用 Scroller 来进行辅助滚动,如果只是在 View 外部通过监听设备倾斜角再通过 View 的 scroll 方法进行移动,会出现抖动及跳动问题
  • 除了使用 磁场传感器加速度传感器 来感知设备倾斜角度变化,还能使用 陀螺仪传感器 来感知设备的倾斜角度变化,同样能实现目标效果
    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.最后

  关于该伪裸眼 3D 效果,自自如团队发布技术文章之后,网上也有一系列 Demo 及技术文章,本人在实现过程中遇到了抖动和跳动问题(主要由于传感器数值变化过于敏感及频繁导致),曾去下载一些 Demo 进行参考,发现同样是存在该问题。其中有篇文章是通过 陀螺仪传感器 来实现该效果的,也做了抖动过滤,但在小米 6 上运行时发现会出现卡顿效果,所以最后还是自己调整优化避免了该现象的出现。
  最后感谢 自如大前端团队 的实现方案分享,通过新颖取巧的方式,加强了用户的 UI 体验。而自如的技术文章更着重于分享思路,所以在此基础上进行实现与优化,也是一种不可多得的乐趣。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容