• 带你造轮子,自定义一个随意拖拽可吸边的View


    1、效果

    在这里插入图片描述

    2、前言

    在开发中,随意拖拽可吸边的悬浮View还是比较常见的,这种功能网上也有各种各样的轮子,其实写起来并不复杂,看完本文,你也可以手写一个,而且不到400行代码就能实现一个通用的随意拖拽可吸边的悬浮View组件。

    3、功能拆解

    在这里插入图片描述

    4、功能实现

    4.1、基础实现

    4.1.1、自定义view类

    先定义一个FloatView类,继承自FrameLayout,实现构造方法。

    创建一个ShapeableImageView,并添加到这个FloatView中。

    class FloatView : FrameLayout {
    
        constructor(context: Context) : this(context, null)
    
        constructor(context: Context, attributeSet: AttributeSet?) : this(context, attributeSet, 0)
    
        constructor(context: Context, attributeSet: AttributeSet?, defStyle: Int) : super(context, attributeSet, defStyle) {
            initView()
        }
    
        private fun initView() {
            val lp = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
            layoutParams = lp
    
            val imageView = ShapeableImageView(context)
            imageView.setImageResource(R.mipmap.ic_avatar)
    
            addView(imageView)
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    4.1.2、添加到window

    在页面的点击事件中,通过DecorView把这个FloatView添加到window中

    mBinding.btnAddFloat.setOnClickListener {
        val contentView = this.window.decorView as FrameLayout
        contentView.addView(FloatView(this))
    }
    
    • 1
    • 2
    • 3
    • 4

    来看下效果:

    在这里插入图片描述

    默认在左上角,盖住了标题栏,也延伸到了状态栏,不是很美观。

    从这个视图层级关系中可以看出,我们是把FloatView添加到DecorView的根布局(rootView)里面了,实际下面还有一层contentView,contentView是不包含状态栏、导航栏和ActionBar的。

    我们改一下添加的层级(content):

    val contentView = this.window.decorView.findViewById(android.R.id.content) as FrameLayout
    contentView.addView(FloatView(this))
    
    • 1
    • 2

    再看下效果:
    在这里插入图片描述

    此时,是默认显示在状态栏下面了,但还是盖住了标题栏。

    这是因为标题栏是在activity的layout中加的toolbar,不是默认的ActionBar,app主题是Theme.Material3.DayNight.NoActionBar,所以显示效果其实是正确的。

    手动加上ActionBar看看效果:
    在这里插入图片描述

    这就验证了我们之前的论点了。

    不管我们添加的根布局是rootView还是contentView,实际上可能都有需求不要盖住原有页面上的某些元素,这时候可以通过margin或者x/y坐标位置来限制view显示的位置。

    4.1.3、视图层级关系

    在这里插入图片描述

    4.2、拖拽

    4.2.1、View.OnTouchListener

    实现View.OnTouchListener接口,重写onTouch方法,在onTouch方法中根据拖动的坐标实时修改view的位置。

        override fun onTouch(v: View, event: MotionEvent): Boolean {
            val x = event.x
            val y = event.y
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    mDownX = event.x
                    mDownY = event.y
                }
                MotionEvent.ACTION_MOVE -> {
                    offsetTopAndBottom((y - mDownY).toInt())
                    offsetLeftAndRight((x - mDownX).toInt())
                }
                MotionEvent.ACTION_UP -> {
    
                }
            }
            return true
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • MotionEvent.ACTION_DOWN 手指按下
    • MotionEvent.ACTION_MOVE 手指滑动
    • MotionEvent.ACTION_UP 手指抬起

    效果:

    在这里插入图片描述

    ok,这就实现随意拖拽了。

    4.2.2、动态修改view坐标

    上面我们修改view坐标用的是offsetTopAndBottomoffsetLeftAndRight,分别是垂直方向和水平方向的偏移,当然也还有别的方式可以修改坐标

    • view.layout()
    • view.setX/view.setY
    • view.setTranslationX/view.setTranslationY
    • layoutParams.topMargin…
    • offsetTopAndBottom/offsetLeftAndRight

    4.2.3、view坐标系

    上面我们获取坐标用的是event.x,实际上还有event.rawX,他们的区别是什么,view在视图上的坐标又是怎么定义的?

    搞清楚了这些,在做偏移计算时,就能达到事半功倍的效果,省去不必要的调试工作。

    一图胜千言:

    在这里插入图片描述

    4.3、吸边

    吸边的场景基本可以分为两种:

    1. 上下吸边
    2. 左右吸边

    要么左右吸,要么上下吸,上下左右同时吸一般是违背交互逻辑的(四象限),用户也会觉得很奇怪。

    吸边的效果其实就是当手指抬起(MotionEvent.ACTION_UP)的时候,根据滑动的距离,以及初始的位置,来决定view最终的位置。

    比如默认在顶部,向下滑动的距离不足半屏,那就还是吸附在顶部,超过半屏,则自动吸附在底部,左右同理。

    4.3.1、上下吸边

    计算公式:

    1.上半屏:
    1.1.滑动距离<半屏=吸顶
    1.2.滑动距离>半屏=吸底
    
    2.下半屏:
    2.1.滑动距离<半屏=吸底
    2.2.滑动距离>半屏=吸顶
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    先看下效果:

    在这里插入图片描述

    可以看到基础效果我们已经实现了,但是顶部盖住了ToolBar,底部也被NavigationBar遮住了,我们再优化一下,把ToolBarNavigationBar的高度也计算进去。

    看下优化后的效果:

    在这里插入图片描述

    这样看起来就好很多了。

    上图效果最终代码:

        private fun adsorbTopAndBottom(event: MotionEvent) {
            if (isOriginalFromTop()) {
                // 上半屏
                val centerY = mViewHeight / 2 + abs(event.rawY - mFirstY)
                if (centerY < getScreenHeight() / 2) {
                    //滑动距离<半屏=吸顶
                    val topY = 0f + mToolBarHeight
                    animate().setInterpolator(DecelerateInterpolator()).setDuration(300).y(topY).start()
                } else {
                    //滑动距离>半屏=吸底
                    val bottomY = getContentHeight() - mViewHeight
                    animate().setInterpolator(DecelerateInterpolator()).setDuration(300).y(bottomY.toFloat()).start()
                }
            } else {
                // 下半屏
                val centerY = mViewHeight / 2 + abs(event.rawY - mFirstY)
                if (centerY < getScreenHeight() / 2) {
                    //滑动距离<半屏=吸底
                    val bottomY = getContentHeight() - mViewHeight
                    animate().setInterpolator(DecelerateInterpolator()).setDuration(300).y(bottomY.toFloat()).start()
                } else {
                    //滑动距离>半屏=吸顶
                    val topY = 0f + mToolBarHeight
                    animate().setInterpolator(DecelerateInterpolator()).setDuration(300).y(topY).start()
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    4.3.2、左右吸边

    计算公式:

    1.左半屏:
    1.1.滑动距离<半屏=吸左
    1.2.滑动距离>半屏=吸右
    
    2.右半屏:
    2.1.滑动距离<半屏=吸右
    2.2.滑动距离>半屏=吸左
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    看下效果:

    在这里插入图片描述

    左右吸边的效果相对上下吸边来说要简单些,因为不用计算ToolBar和NavigationBar,计算逻辑与上下吸边相通,只不过参数是从屏幕高度变为屏幕宽度,Y轴变为X轴。

    代码:

        private fun adsorbLeftAndRight(event: MotionEvent) {
            if (isOriginalFromLeft()) {
                // 左半屏
                val centerX = mViewWidth / 2 + abs(event.rawX - mFirstX)
                if (centerX < getScreenWidth() / 2) {
                    //滑动距离<半屏=吸左
                    val leftX = 0f
                    animate().setInterpolator(DecelerateInterpolator()).setDuration(300).x(leftX).start()
                } else {
                    //滑动距离<半屏=吸右
                    val rightX = getScreenWidth() - mViewWidth
                    animate().setInterpolator(DecelerateInterpolator()).setDuration(300).x(rightX.toFloat()).start()
                }
            } else {
                // 右半屏
                val centerX = mViewWidth / 2 + abs(event.rawX - mFirstX)
                if (centerX < getScreenWidth() / 2) {
                    //滑动距离<半屏=吸右
                    val rightX = getScreenWidth() - mViewWidth
                    animate().setInterpolator(DecelerateInterpolator()).setDuration(300).x(rightX.toFloat()).start()
                } else {
                    //滑动距离<半屏=吸左
                    val leftX = 0f
                    animate().setInterpolator(DecelerateInterpolator()).setDuration(300).x(leftX).start()
                }
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    Author:yechaoa

    5、进阶封装

    为什么要封装一下呢,因为现在的计算逻辑、参数配置都是在FloatView这一个类里,定制化太强反而不具备通用性,可以进行一个简单的抽取封装,向外暴露一些配置和接口,这样在其他的业务场景下也可以复用,避免重复造轮子。

    5.1、View封装

    5.1.1、BaseFloatView

    把FloatView改成BaseFloatView,然后把一些定制化的能力交给子view去实现。

    这里列举了3个方法:

        /**
         * 获取子view
         */
        protected abstract fun getChildView(): View
    
        /**
         * 是否可以拖拽
         */
        protected abstract fun getIsCanDrag(): Boolean
    
        /**
         * 吸边的方式
         */
        protected abstract fun getAdsorbType(): Int
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    5.1.2、子view

    class AvatarFloatView(context: Context) : BaseFloatView(context) {
    
        override fun getChildView(): View {
            val imageView = ShapeableImageView(context)
            imageView.setImageResource(R.mipmap.ic_avatar)
            return imageView
        }
    
        override fun getIsCanDrag(): Boolean {
            return true
        }
    
        override fun getAdsorbType(): Int {
            return ADSORB_VERTICAL
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    这样稍微抽一下,代码看起来就简洁很多了,只需要配置一下就可以拥有随意拖拽的能力了。

    5.2、调用封装

    5.2.1、管理类

    新建一个FloatManager的管理类,它来负责FloatView的显示隐藏,以及回收逻辑。

    设计模式还是使用单例,我们需要在这个单例类里持有Activity,因为需要通过Activity的window获取decorView然后把FloatView添加进去,但是Activity与单例的生命周期是不对等的,这就很容易造成内存泄露。

    怎么解?也好办,管理一下activity的生命周期就好了。

    在之前分析LifecycleScope源码的文章中有提到关于Activity生命周期的管理,得益于lifecycle的强大,这个问题解起来也变得更简单。

        private fun addLifecycle(activity: ComponentActivity?) {
            activity?.lifecycle?.addObserver(mLifecycleEventObserver)
        }
    
        private var mLifecycleEventObserver = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_DESTROY) {
                hide()
            }
        }
    
        fun hide() {
            if (::mContentView.isInitialized && mFloatView != null && mContentView.contains(mFloatView!!)) {
                mContentView.removeView(mFloatView)
            }
            mFloatView?.release()
            mFloatView = null
            mActivity?.lifecycle?.removeObserver(mLifecycleEventObserver)
            mActivity = null
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    1. 添加生命周期的监听
    2. 在ON_DESTROY的时候处理回收逻辑

    5.2.2、FloatManager完整代码

    @SuppressLint("StaticFieldLeak")
    object FloatManager {
    
        private lateinit var mContentView: FrameLayout
        private var mActivity: ComponentActivity? = null
        private var mFloatView: BaseFloatView? = null
    
        fun with(activity: ComponentActivity): FloatManager {
            mContentView = activity.window.decorView.findViewById(android.R.id.content) as FrameLayout
            mActivity = activity
            addLifecycle(mActivity)
            return this
        }
    
        fun add(floatView: BaseFloatView): FloatManager {
            if (::mContentView.isInitialized && mContentView.contains(floatView)) {
                mContentView.removeView(floatView)
            }
            mFloatView = floatView
            return this
        }
        
        fun setClick(listener: BaseFloatView.OnFloatClickListener): FloatManager {
            mFloatView?.setOnFloatClickListener(listener)
            return this
        }
        
        fun show() {
            checkParams()
            mContentView.addView(mFloatView)
        }
    
        private fun checkParams() {
            if (mActivity == null) {
                throw NullPointerException("You must set the 'Activity' params before the show()")
            }
            if (mFloatView == null) {
                throw NullPointerException("You must set the 'FloatView' params before the show()")
            }
        }
    
        private fun addLifecycle(activity: ComponentActivity?) {
            activity?.lifecycle?.addObserver(mLifecycleEventObserver)
        }
    
        private var mLifecycleEventObserver = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_DESTROY) {
                hide()
            }
        }
    
        fun hide() {
            if (::mContentView.isInitialized && mFloatView != null && mContentView.contains(mFloatView!!)) {
                mContentView.removeView(mFloatView)
            }
            mFloatView?.release()
            mFloatView = null
            mActivity?.lifecycle?.removeObserver(mLifecycleEventObserver)
            mActivity = null
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61

    5.2.3、调用方式

    • 显示
    FloatManager.with(this).add(AvatarFloatView(this)).show()
    
    • 1
    • 隐藏
    FloatManager.hide()
    
    • 1
    • 带点击事件
    FloatManager.with(this).add(AvatarFloatView(this))
        .setClick(object : BaseFloatView.OnFloatClickListener {
            override fun onClick(view: View) {
                Toast.makeText(this@FloatViewActivity, "click", Toast.LENGTH_SHORT).show()
            }
        })
        .show()
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    6、Github

    https://github.com/yechaoa/MaterialDesign

    7、最后

    写作不易,且看且珍惜啊喂~

  • 相关阅读:
    [附源码]计算机毕业设计基于SpringBoot的在线作业批改系统
    windows安装的Nexus 启动失败
    重定向:基于神经网络优化的方法
    自己动手从零写桌面操作系统GrapeOS系列教程——9.实模式介绍
    文献学习-14-一种用于高精度微创手术的纤维机器人
    视频号视频如何下载(WeChatVideoDownloader)
    Java版企业电子招标采购系统源代码Spring Boot + 二次开发 + 前后端分离 构建企业电子招采平台之立项流程图
    如何进行心态调整
    别试错了,是该关注一下软件内在质量了
    Kubesphere之部署MySQL
  • 原文地址:https://blog.csdn.net/yechaoa/article/details/126166519