Loading [MathJax]/jax/output/CommonHTML/config.js
前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >带你造轮子,自定义一个随意拖拽可吸边的View

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

作者头像
yechaoa
发布于 2022-09-20 11:56:28
发布于 2022-09-20 11:56:28
62100
代码可运行
举报
文章被收录于专栏:移动开发专栏移动开发专栏
运行总次数:0
代码可运行

1、效果

2、前言

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

3、功能拆解

4、功能实现

4.1、基础实现

4.1.1、自定义view类

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

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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中

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
mBinding.btnAddFloat.setOnClickListener {
    val contentView = this.window.decorView as FrameLayout
    contentView.addView(FloatView(this))
}

来看下效果:

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

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

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

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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的位置。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
    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、上下吸边

计算公式:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
1.上半屏:
1.1.滑动距离<半屏=吸顶
1.2.滑动距离>半屏=吸底

2.下半屏:
2.1.滑动距离<半屏=吸底
2.2.滑动距离>半屏=吸顶

先看下效果:

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

看下优化后的效果:

这样看起来就好很多了。

上图效果最终代码:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
    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、左右吸边

计算公式:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
1.左半屏:
1.1.滑动距离<半屏=吸左
1.2.滑动距离>半屏=吸右

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

看下效果:

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

代码:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
    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个方法:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
    /**
     * 获取子view
     */
    protected abstract fun getChildView(): View

    /**
     * 是否可以拖拽
     */
    protected abstract fun getIsCanDrag(): Boolean

    /**
     * 吸边的方式
     */
    protected abstract fun getAdsorbType(): Int

5.1.2、子view

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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的强大,这个问题解起来也变得更简单。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
    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完整代码

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
@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、调用方式

  • 显示
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
FloatManager.with(this).add(AvatarFloatView(this)).show()
  • 隐藏
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
FloatManager.hide()
  • 带点击事件
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
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、最后

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

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2022-08-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
Android自定义实现滚动选择器
在开发的过程中,现有的控件满足不了功能的需求,这个时候就需要我们自定义控件了。最近在开发中需要实现滚动进行类别的选择,也就是我们所说的滚动选择器,这里我们自定义来实现这个功能。
SoullessCoder
2019/08/07
4K2
自定义view仿写今日头条点赞动画!
平时喜欢看今日头条,上面的财经、科技和NBA栏目都很喜欢,无意中发现他的点赞动画还不错,一下子就吸引到了我。遂即想要不自己实现一下。
用户9239674
2021/12/28
4430
Android | 自定义格子拖拽填充效果
前言 最近遇到一个需求,需要实现一个格子填充的效果,具体效果如下所示: 分析 格子的拖动效果 整个 View 的边界判断 二维网格边界的判断 拖动后格子填充时的位置判断 网格的绘制 填充后进行复位 实现 @SuppressLint("ClickableViewAccessibility") class DragGridGroupView : FrameLayout { constructor(context: Context) : this(context, null) constru
345
2022/10/05
4700
Android | 自定义格子拖拽填充效果
Android自定义View之仿QQ未读消息拖拽效果
绘制以上两条贝塞尔曲线和直线需要五个点:P1,P2,P3,P4,M,其中P1,P2,P3,P4是圆的切点,现在只知道两个圆的中心圆点O1和O2,那么怎么根据这两个点来求其余四个圆的切点呢?先分析:
Rouse
2019/07/17
1.9K1
Android自定义View之仿QQ未读消息拖拽效果
Android自定义View
前几天在郭霖大神的博客上看了自定义View的知识,感觉受益良多,大神毕竟大神。在此总结一下关于Android 自定义View的用法:
指点
2019/01/18
6340
Android自定义View
仿QQ6.1手势锁
项目地址:https://github.com/103style/QQ6.1GestureLock
103style
2022/12/19
2070
仿QQ6.1手势锁
View篇:玩一下自定义ViewGroup
自定义ViewGroup可不像自定义View那么简单 今日聚焦: 1.自定义ViewGroup中花样布局子View 2.移动View用layout、translation、TranslationA
张风捷特烈
2019/03/08
1.1K0
View篇:玩一下自定义ViewGroup
【Android 内存优化】自定义组件长图组件 ( 长图滚动区域解码 | 手势识别 GestureDetector | 滑动计算类 Scroller | 代码示例 )
在【Android 内存优化】自定义组件长图组件 ( 获取图像宽高 | 计算解码区域 | 设置图像解码属性 复用 像素格式 | 图像绘制 ) 博客中完成了图像的区域解码 , 并显示在界面中 ; 本篇博客中主要完成长图滑动功能 , 触摸滑动 , 惯性滑动 , 操作 ;
韩曙亮
2023/03/27
1.6K0
【Android 内存优化】自定义组件长图组件 ( 长图滚动区域解码 | 手势识别 GestureDetector | 滑动计算类 Scroller | 代码示例 )
Android 贝塞尔曲线解析
相信很多同学都知道“贝塞尔曲线”这个词,我们在很多地方都能经常看到。利用“贝塞尔曲线”可以做出很多好看的UI效果,本篇博客就让我们一起学习“贝塞尔曲线”。
老马的编程之旅
2022/06/22
1.2K0
Android 贝塞尔曲线解析
Android开发笔记(一百一十八)自定义悬浮窗
在前面《Android开发笔记(六十六)自定义对话框》中,我们提到每个页面都是一个Window窗口,许多的Window对象需要一个管家来打理,这个管家我们称之为WindowManager窗口管理。在手机屏幕上新增或删除页面窗口,都可以归结为WindowManager的操作,下面是该管理类的常用方法说明: getDefaultDisplay : 获取默认的显示屏信息。通常用该方法获取屏幕分辨率,详情参见《Android开发笔记(三)屏幕分辨率》。 addView : 往窗口添加视图。第二个参数为WindowManager.LayoutParams对象。 updateViewLayout : 更新指定视图的布局参数。第二个参数为WindowManager.LayoutParams对象。 removeView : 往窗口移除指定视图。 下面是窗口布局参数WindowManager.LayoutParams的常用属性说明: format : 窗口的像素点格式。取值见PixelFormat类中的常量定义,一般取值PixelFormat.RGBA_8888。 type : 窗口的显示类型,常用的类型说明如下: --TYPE_SYSTEM_ALERT : 系统警告提示。 --TYPE_SYSTEM_ERROR : 系统错误提示。 --TYPE_SYSTEM_OVERLAY : 页面顶层提示。 --TYPE_SYSTEM_DIALOG : 系统对话框。 --TYPE_STATUS_BAR : 状态栏 --TYPE_TOAST : 短暂通知Toast flags : 窗口的行为准则,常用的标志位如下说明(对于悬浮窗来说,一般只需设置FLAG_NOT_FOCUSABLE): --FLAG_NOT_FOCUSABLE : 不能抢占焦点,即不接受任何按键或按钮事件。 --FLAG_NOT_TOUCHABLE : 不接受触摸屏事件。悬浮窗一般不设置该标志,因为一旦设置该标志,将无法拖动悬浮窗。 --FLAG_NOT_TOUCH_MODAL : 当窗口允许获得焦点时(即没有设置FLAG_NOT_FOCUSALBE标志),仍然将窗口之外的按键事件发送给后面的窗口处理。否则它将独占所有的按键事件,而不管它们是不是发生在窗口范围之内。 -- :  --FLAG_LAYOUT_IN_SCREEN : 允许窗口占满整个屏幕。 --FLAG_LAYOUT_NO_LIMITS : 允许窗口扩展到屏幕之外。 --FLAG_WATCH_OUTSIDE_TOUCH : 如果设置了FLAG_NOT_TOUCH_MODAL标志,则当按键动作发生在窗口之外时,将接收到一个MotionEvent.ACTION_OUTSIDE事件。 alpha : 窗口的透明度,取值为0-1。 gravity : 取值同View的setGravity方法。 x : 窗口左上角的X坐标。 y : 窗口左上角的Y坐标。 width : 窗口的宽度。 height : 窗口的高度。
aqi00
2019/01/18
2.2K1
Android 可拖动悬浮窗实现
博客:https://www.jianshu.com/p/1d22edea2647
陈宇明
2020/12/16
2.2K0
仿IOS 带字母索引的滑轮控件
做开发的时候,经常碰到产品经理设计出来的界面是参考IOS控件设计出来的 ,比如上图效果  ios有个控件是UIPickerView  就是可以上下滑动 并有些3d效果,非常炫。
再见孙悟空_
2023/02/10
1.1K0
仿IOS 带字母索引的滑轮控件
Android 自定义view中实现LifecycleOwner
Google官方提供的Activity和Fragment都默认实现了LifecycleOwner,而使用LiveData一般又都是在Activity和Fragment类中使用,因为在调用LiveData的Observer方法时需要传一个LifecycleOwner对象,而我们自定义的view中没有实现这个接口,所以在自定义view中使用LiveData往往需要传递一个Activity或者Fragment的对象才行,使用起来比较麻烦。网上找了一圈也没几个写自定义View中该如何实现自己的LifecycleOwner,本篇文章就介绍如何在自定义的view中简单的实现当前view的LifecycleOwner。
AWeiLoveAndroid
2022/05/13
2.1K0
Android 自定义view中实现LifecycleOwner
listview滑动删除
今天还是给大家带来自定义控件的编写,自定义一个ListView的左右滑动删除Item的效果,这个效果之前已经实现过了,有兴趣的可以看下Android 使用Scroller实现绚丽的ListView左右滑动删除Item效果,之前使用的是滑动类Scroller来实现的,但是看了下通知栏的左右滑动删除效果,确实很棒,当我们滑动Item超过一半的时候,item的透明度就变成了0,我们就知道抬起手指的时候item就被删除了,当item的透明度不为0的时候,我们抬起手指Item会回到起始位置,这样我们就知道拖动到什么位
xiangzhihong
2018/01/29
1.8K0
实现Activity滑动退出
很多应用在二级详情页面加入了滑动退出activity的效果,很方便,心血来潮,想着自己也来实现这个效果,就当做练手吧。
三好码农
2018/09/11
1.1K0
实现Activity滑动退出
Android自定义控件总结
自定义控件分类: 1、使用系统控件,实现自定义的效果 2、自己定义一个类继承View ,如textView、ImageView等,通过重写相关的方法来实现新的效果 3、自己定义一个类继承ViewGroup,实现相应的效果 继承view类或viewgroup类,来创建所需要的控件。一般来讲,通过继承已有的控件来自定义控件要简单一点。 介绍下实现一个自定义view的基本流程 1.明确需求,确定你想实现的效果。 2.确定是使用组合控件的形式还是全新自定义的形式,组合控件即使用多个系统控件来合成一个新控件,你比如t
六月的雨
2018/05/14
1.3K0
相关推荐
Android自定义实现滚动选择器
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验