当前位置:网站首页>Take you to build a wheel and customize a View that can be dragged and sucked at will
Take you to build a wheel and customize a View that can be dragged and sucked at will
2022-08-10 23:11:00 【yechaoa】
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坐标用的是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、吸边
吸边的场景基本可以分为两种:
- 上下吸边
- 左右吸边
要么左右吸,要么上下吸,上下左右同时吸一般是违背交互逻辑的(四象限),用户也会觉得很奇怪.
吸边的效果其实就是当手指抬起(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
}
- 添加生命周期的监听
- 在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([email protected], "click", Toast.LENGTH_SHORT).show()
}
})
.show()
6、Github
https://github.com/yechaoa/MaterialDesign
7、最后
写作不易,且看且珍惜啊喂~
边栏推荐
- MUI框架开发app中出现的问题
- Distribution Network Expansion Planning: Consider Decisions Using Probabilistic Energy Production and Consumption Profiles (Matlab Code Implementation)
- Glide监听Activity生命周期源码分析
- Spark基础【RDD转换算子】
- 学会开会|成为有连接感组织的重要技能
- RecyclerView滑动监听
- XSLeaks 侧信道攻击 (unfinished)
- 如何成为一名正义黑客?你应该学习什么?
- 阿里云新增三大高性能计算解决方案,助力生命科学行业快速发展
- PlaidCTF 2022 Amongst Ourselves:Shipmate writeup
猜你喜欢

配电网络扩展规划:考虑使用概率性能源生产和消费概况的决策(Matlab代码实现)

虚拟地址空间

BM13判断一个链表是否为回文结构
![68: Chapter 6: Develop article services: 1: Content sorting; article table introduction; creating [article] article services;](/img/95/7f21ecda19030c2faecbe373893d66.png)
68: Chapter 6: Develop article services: 1: Content sorting; article table introduction; creating [article] article services;

H3C S5130 IRF做堆叠

德科立科创板上市:年营收7.3亿 市值59亿

Distribution Network Expansion Planning: Consider Decisions Using Probabilistic Energy Production and Consumption Profiles (Matlab Code Implementation)

解析方法的参数列表(包含参数名称)

音乐播放器(未完成版本)

阿里云新增三大高性能计算解决方案,助力生命科学行业快速发展
随机推荐
August 10, 2022: Building Web Applications for Beginners with ASP.NET Core -- Creating Web UIs with ASP.NET Core
二叉树 | 翻转二叉树 | leecode刷题笔记
Addition of linked lists (2)
MySQL学习笔记(1)——基础操作
MUI框架开发app中出现的问题
Why general company will say "go back messages such as" after the end of the interview, rather than just tell the interviewer the result?
蓝帽杯 2022 web/misc writeup
ASCII、Unicode和UTF-8
Introduction to the use of counter instructions in Rockwell AB PLC RSLogix5000
MySQL: MySQL Cluster - Principle and Configuration of Master-Slave Replication
带着昇腾去旅行:一日看尽金陵城里的AI胜景
CFdiv2-Beautiful Mirrors-(期望)
LeetCode Daily 2 Questions 01: Reverse Strings (both 1200) Method: Double Pointer
Splunk中解决数据质量问题常见日期格式化
web项目访问引用jar内部的静态资源
fme csmapreprojector转换器使用高程异常模型进行高程基准转换
2021IDEA创建web工程
ArcGIS中的坐标系统和投影变换
Mysql之部分表主从搭建及新增表
Lambda