关键字:ViewDragHelper,Navigation,拖拽,悬浮
前言
我们可能需要在APP中或者某个模块内加个悬浮拖拽按钮。首先来拆解一下这个需求。悬浮好办,弄一个带阴影的View就行。拖拽的话,那需要自己实现对手指移动监听和view的移动,不过ViewDragHelper
可以帮你更便捷的实现。
主要是APP中或者是某个模块中使用,那就涉及到组件跨页面了,网上的方案大致分为俩种,一种是跳转到下个页面重绘控件,另一种是在根布局的外层去加View。不过这俩种方案在跨页面的数据处理方面是不够清晰的。所以我们为了清晰处理跨页面数据,我们不妨选择另一种“降维打击”的方案:将页面变成和悬浮控件同层级的。
那你肯定在问,页面怎么可能跟控件同级啊,页面不是承载控件的吗?不绝对是!Activity页面不可以,但是Fragment页面可以和控件同级。我们经常用到的ViewPager就是一个Activity管理多fragment页面以及页面和控件同级的典型例子。将Activity职责从显示和处理页面逻辑到管理多个Fragment页面的添加、替换、显示和隐藏,在Actvity布局中再加个控件,这个控件就算和fragment同级的了。
这样做的明显优点有俩点:一是所有跨页面数据都能在Activity中管理,Fragment需要的跨页面数据可以从上级的Activity中取;二是跨页面的控件可以像布局一样简单实现。
在实现之前,我们先来看一下效果演示:
代码链接在文末
实现拖拽
目前实现有三种:
- 监听touch事件实现拖动
- 实现OnDragListener接口:拖动组件的虚影,提示用户将会删除数据或者增加数据,虚影(数据)可以跨进程
- ViewDragHelper:实实在在的拖动组件,要重写ViewGroup
这里主要讨论第二个和第三个的区别。
OnDragListener主要是用于可视化操作数据的,甚至可以在分屏情况下跨APP实现数据的移动,不过想要实现普通的拖拽是要做一些多余的工作的。而ViewDragHelper就是专门做这个的,只需要简单地重写ViewGroup就能实现一个拖动布局。
ViewDragHelper功能强大,抽象方法有点儿多,这里只是抛砖引玉,介绍几个能满足需求的方法,需要增加边框监测以及靠边等效果的请移步官网文档。
首先我们自定义一个ViewGroup,为了布局方便继承了RelativeLayout。
class DragLayout constructor(context: Context, attrs: AttributeSet) :
RelativeLayout(context, attrs) {}
复制代码
然后我们来实例化一个ViewDragHelper对象,用ViewDragHelper.create()实例化,第一个值是一个ViewGroup,第二个值是ViewDragHelper.Callback()的实现。接下来我需要实现一个ViewDragHelper.Callback()
private val mViewDragHelper: ViewDragHelper = ViewDragHelper.create(this, dragCallback)
复制代码
第一个的重点来了,,,只需要理解ViewDragHelper.Callback的这5个方法就能应付绝大部分场景了。
- tryCaptureView()
该方法是判定ViewGroup的子View哪个是可以拖动的,判断为true即为可拖动,而我们只需要拖动一个控件,只需要指定tag为特定的值”canDrag”就可以了。
override fun tryCaptureView(child: View, pointerId: Int): Boolean {
return child.tag == "canDrag"
}
复制代码
- getViewHorizontalDragRange()和getViewVerticalDragRange()
这俩个方法规定了view的活动范围。咱们简单粗暴点,直接用ViewGroup的宽高为范围,让组件随便滑。
override fun getViewHorizontalDragRange(child: View): Int {
return measuredWidth
}
override fun getViewVerticalDragRange(child: View): Int {
return measuredHeight
}
复制代码
- clampViewPositionVertical()和clampViewPositionHorizontal()
这俩个方法返回的值是拖动过程位置以及距离,ViewDragHelper接收ViewGroup的触摸事件会用到。一般就返回接收参数的left和top。
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int {
return top
}
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
return left
}
复制代码
至此ViewDragHelper.Callback中必要的锁定控件、确定范围以及返回数据的方法以及介绍完了,接下来我们来康康ViewGroup怎么使用ViewDragHelper的。
- 重写onInterceptTouchEvent()
是否拦截事件流(点击事件和拖动不冲突也是shouldInterceptTouchEvent实现的,这方法源码里面的拦截触摸事件流操作值得看一下)
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return mViewDragHelper.shouldInterceptTouchEvent(ev)
}
复制代码
- 重写onTouchEvent()
是否向父级发送事件回调
override fun onTouchEvent(event: MotionEvent): Boolean {
mViewDragHelper.processTouchEvent(event)
return true
}
复制代码
ViewGroup中的事件流使用的是ViewDragHelper返回的事件流处理方法,相关的计算ViewDragHelper已经帮我们做好了。
接下来提供完整的DragLayout代码:
class DragLayout constructor(context: Context, attrs: AttributeSet) :
RelativeLayout(context, attrs) {
private val dragCallback = object : ViewDragHelper.Callback() {
override fun tryCaptureView(child: View, pointerId: Int): Boolean = child.tag == "canDrag"
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int = left
override fun getViewHorizontalDragRange(child: View): Int = measuredWidth
override fun getViewVerticalDragRange(child: View): Int = measuredHeight
override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int = top
}
private val mViewDragHelper: ViewDragHelper = ViewDragHelper.create(this, dragCallback)
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean =
mViewDragHelper.shouldInterceptTouchEvent(ev)
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
mViewDragHelper.processTouchEvent(event)
return true
}
}
复制代码
至此,只要给你想要拖动的控件,例如button加上tag=”canDrag”,botton就能拖动了。
<Button android:tag="canDrag"/>
复制代码
简单的跨页面
我们已经实现了Activity中可以拖拽控件,接下来看看这拖拽控件是如何实现跨页面的。
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<com.example.dragfloatactivity.DragLayout >
<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment" />
<Button
android:id="@+id/button"
android:tag="canDrag"/>
</com.example.dragfloatactivity.DragLayout>
复制代码
以上就是MainAtivity的页面(去掉了部分代码),FragmentContainerView
是用来承载Fragment页面的,Button
就是可以拖动的组件,指定了它的tag属性为canDrag。
Fragment由来已久,是Android 3.0推出为了大屏设备而生的库。它托管在Activity中,在页面内使用跟Activity几乎一样;而在跨页面数据传输方面,因为它必定托管在一个Activity中或者Fragment中,原则上页面间数据最好保存在它的上层,这一点跟Activity间常用的传递bundle数据包的方式是不一样的,官方也是强烈不建议Fragment间直接通信的。
官方推出的Jetpack后很多开发文档已经改了,那段强烈不建议Fragment间直接通信的提示也没了,还好我记了下来。
Often you will want one Fragment to communicate with another, for example to change the content based on a user event. All Fragment-to-Fragment communication is done either through a shared ViewModel or through the associated Activity. Two Fragments should never communicate directly.
通常,您会希望一个Fragment与另一个Fragment进行通信,例如,根据用户事件更改内容。 所有片段到片段的通信都是通过共享的ViewModel或关联的Activity完成的。 两个片段永远不要直接通信。
为了简单的实现Fragment页面的跳转,我们需要在MainActivity中写一个用于帮助Fragment页面跳转的方法navigateTo()
MainActivity.kt
class MainActivity : AppCompatActivity() {
fun navigateTo(destination: Fragment) {
val transaction = supportFragmentManager.beginTransaction()
transaction.replace(R.id.fragment, destination)
transaction.addToBackStack(null)
transaction.commit()
}
}
复制代码
我们再来实现2个fragment
,因为布局和代码很简单,下面只提供部分代码,详细可看文末代码仓库链接,主要是调用MainActivity
的navigateTo
方法用来实现fragment之间的跳转。
Test1Fragment.kt
class Test1Fragment : Fragment(R.layout.fragment_1) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val btn = view.findViewById<Button>(R.id.button)
btn.setOnClickListener {
跳转页面
(activity as MainActivity).navigateTo(Test2Fragment())
}
}
}
复制代码
这样我们的拖拽控件跨页面demo就实现了。
很简单是吧。不过你用上面的代码跑起demo,会发现一个小问题:不管在前一个页面拖拽组件被拖拽到哪,跳到下一个页面还是会回到起始位置。这个问题可能是fragment的切换(不管是replace还是show)导致了DragLayout的重新测量与布局。不管什么原因,既然触发了重绘,那通过重写一下DragLayout
的onLayout()
来解决,把上个页面拖拽的位置记录下来,在onLayout()重新给拖拽控件布局。
(隐藏了上节代码已有代码)
class DragLayout constructor(context: Context, attrs: AttributeSet) :
RelativeLayout(context, attrs) {
private var dragTop = -1
private var dragLeft = -1
private val dragCallback = object : ViewDragHelper.Callback() {
...
松开时触发,记录拖拽位置
override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
dragLeft = releasedChild.left
dragTop = releasedChild.top
}
}
...
触发重绘时,将目标拖拽组件按记录的位置重新布局
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
if (dragLeft != -1 && dragTop != -1) {
for (i in 0 until childCount) {
if (getChildAt(i).tag == "canDrag"){
val childView=getChildAt(i)
val height = childView.measuredHeight
val width = childView.measuredWidth
childView.layout(dragLeft,dragTop,dragLeft + width,dragTop + height)
break
}
}
}
}
}
复制代码
以上就是本demo全部的相关代码了。
我们再来理一下流程,首先将页面的职责交给Fragment,在支持Fragment展示的Activity中加入可拖拽组件,最后处理一下Fragment导致重绘的问题。
总结
本文的思路基础是将Fragment作为主要的承载UI逻辑的一个组件,而将Activity职责更多地变成一个管理者。一个Activity管理一个模块,甚至一整个App。这也是随着Jetpack组件库的逐渐稳定(18年推出)和官方开发范式的逐渐明朗而变得简单的。官方推出的Navigation组件将Fragment管理变得简单了许多,本来文章思路就是因为用了Navigation之后才想起来的,不过考虑到Navigation并非本文的重点,我就选择用最基础的知识点来实现demo了。另外一个我觉得很重要的Jetpack组件是ViewModel,就算是将Activity的职能交给Fragment了,也不表明Fragment中就应该将UI和逻辑全写在一起,这也是mvc,mvp以及现在的mvvm的一个主要的作用。将UI操作之外的逻辑交给ViewModel,它可以将数据限制在App级别、模块级别、Activit级别或者Fragment级别,跨页面的逻辑再也不用乱写了。
附上demo链接:gitee.com/stanza/cros…
参考: