写点什么

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

作者:yechaoa
  • 2022 年 8 月 03 日
  • 本文字数:5738 字

    阅读完需:约 19 分钟

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 FrameLayoutcontentView.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 坐标用的是 offsetTopAndBottom offsetLeftAndRight,分别是垂直方向和水平方向的偏移,当然也还有别的方式可以修改坐标:

  • 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 遮住了,我们再优化一下,把 ToolBar NavigationBar 的高度也计算进去。

看下优化后的效果:


这样看起来就好很多了。

上图效果最终代码:

    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()            }        }    }
复制代码

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
复制代码

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、最后

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

发布于: 刚刚阅读数: 5
用户头像

yechaoa

关注

优质作者 2018.10.23 加入

知名互联网大厂技术专家,多平台博客专家、优秀博主、人气作者,博客风格深入浅出,专注于Android领域,同时探索于大前端方向,持续研究并落地前端、小程序、Flutter、Kotlin等相关热门技术

评论

发布
暂无评论
带你造轮子,自定义一个随意拖拽可吸边的悬浮View组件_android_yechaoa_InfoQ写作社区