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

简介: 在开发中,随意拖拽可吸边的View还是比较常见的,这种功能网上也有各种各样的轮子,其实写起来并不复杂,看完本文,你也可以手写一个,不到400行代码就能实现一个通用的随意拖拽可吸边的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)
    }
}

4.1.2、添加到window

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

mBinding.btnAddFloat.setOnClickListener {
    val contentView = this.window.decorView as FrameLayout
    contentView.addView(FloatView(this))
}

来看下效果:

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

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

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

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

再看下效果:

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

这是因为标题栏是在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
    }
  • 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.滑动距离>半屏=吸顶

先看下效果:

可以看到基础效果我们已经实现了,但是顶部盖住了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()
            }
        }
    }


4.3.2、左右吸边

计算公式:

1.左半屏:
     1.1.滑动距离<半屏=吸左
     1.2.滑动距离>半屏=吸右
2.右半屏:
     2.1.滑动距离<半屏=吸右
     2.2.滑动距离>半屏=吸左

看下效果:

左右吸边的效果相对上下吸边来说要简单些,因为不用计算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()
            }
        }
    }

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 

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
    }
}

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

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. 在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
    }
}

5.2.3、调用方式

  • 显示
FloatManager.with(this).add(AvatarFloatView(this)).show()
  • 隐藏
FloatManager.hide()
  • 带点击事件
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()

6、Github

https://github.com/yechaoa/MaterialDesign

7、最后

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

目录
相关文章
|
Web App开发 iOS开发
a标签实现文件流下载
a标签实现文件流下载
552 0
|
JavaScript
vue监听dom元素的宽高变化和自定义指令监听dom元素的宽高变化
vue监听dom元素的宽高变化和自定义指令监听dom元素的宽高变化
1574 0
|
Web App开发 JavaScript iOS开发
Xcode9用Safari调试模拟器JS代码显示“无可检查的应用程序”的原因
Xcode9用Safari调试模拟器JS代码显示“无可检查的应用程序”的原因
1462 0
|
Swift iOS开发 Ruby
iOS CocoaPods 使用以及常见问题(上)
iOS CocoaPods 使用以及常见问题
863 0
|
Android开发 Kotlin
Android Studio 制作聊天界面实践(Kotlin版)
Android Studio 制作聊天界面实践(Kotlin版)
1095 0
Android Studio 制作聊天界面实践(Kotlin版)
|
11月前
|
缓存 JavaScript 前端开发
《凭什么撼动Node.js?Bun和Zig性能优势深度揭秘》
Node.js长期主导服务器端运行时,但新兴的Bun和Zig正带来新挑战。Bun是一款高性能JavaScript运行时,基于Zig语言开发,启动速度快4倍于Node.js,依赖管理效率提升25倍。它集成了打包、转译、测试等功能,简化开发流程。Zig则以精细的内存管理和跨平台能力助力Bun性能飞跃,同时在服务端渲染、命令行工具开发等场景中表现出色。尽管Node.js生态成熟,Bun和Zig正逐步改写JavaScript运行时格局,推动技术进步。
644 15
|
人工智能 Java 程序员
一文彻底拿下HarmonyOS实战开发之HMRouter实现跳转
本文介绍HarmonyOS页面跳转的两种方式:组件导航(Navigation)和页面路由(@ohos.router)。重点推荐使用组件导航,因其灵活性和多端部署能力更强。此外,还介绍了HMRouter,一个简化页面跳转的工具,支持自定义注解、路由拦截、动画配置等功能。通过详细步骤,展示了如何在项目中集成HMRouter并实现页面跳转,帮助开发者更高效地开发鸿蒙应用。君志所向,一往无前!关注我,带你起飞鸿蒙开发!
1061 0
|
人工智能 自然语言处理 数据可视化
AutoAgents:比LangChain更激进的AI开发神器!自然语言生成AI智能体军团,1句话搞定复杂任务
AutoAgents 是基于大型语言模型的自动智能体生成框架,能够根据用户设定的目标自动生成多个专家角色的智能体,通过协作完成复杂任务。支持动态生成智能体、任务规划与执行、多智能体协作等功能。
1769 91
|
XML Java API
23. 【Android教程】轮播滚动视图:ViewFlipper
23. 【Android教程】轮播滚动视图:ViewFlipper
852 2
Windows程序的数字签名证书怎么申请
Windows程序的数字签名证书申请流程包括:准备企业资料(营业执照、税务登记证等),提交申请表及企业资料。经过初审、实名认证和二审后,等待1-5个工作日审核结果。审核通过后,CA机构颁发证书并通过邮件或邮寄方式发送。收到证书后按指南安装并使用签名工具对程序进行数字签名,确保软件完整性和可信度。注意证书有效期、管理和兼容性问题。

热门文章

最新文章