跨页面拖拽悬浮控件新思路

1,396 阅读8分钟

关键字:ViewDragHelper,Navigation,拖拽,悬浮

前言

我们可能需要在APP中或者某个模块内加个悬浮拖拽按钮。首先来拆解一下这个需求。悬浮好办,弄一个带阴影的View就行。拖拽的话,那需要自己实现对手指移动监听和view的移动,不过ViewDragHelper可以帮你更便捷的实现。

主要是APP中或者是某个模块中使用,那就涉及到组件跨页面了,网上的方案大致分为俩种,一种是跳转到下个页面重绘控件,另一种是在根布局的外层去加View。不过这俩种方案在跨页面的数据处理方面是不够清晰的。所以我们为了清晰处理跨页面数据,我们不妨选择另一种“降维打击”的方案:将页面变成和悬浮控件同层级的。

那你肯定在问,页面怎么可能跟控件同级啊,页面不是承载控件的吗?不绝对是!Activity页面不可以,但是Fragment页面可以和控件同级。我们经常用到的ViewPager就是一个Activity管理多fragment页面以及页面和控件同级的典型例子。将Activity职责从显示和处理页面逻辑到管理多个Fragment页面的添加、替换、显示和隐藏,在Actvity布局中再加个控件,这个控件就算和fragment同级的了。

这样做的明显优点有俩点:一是所有跨页面数据都能在Activity中管理,Fragment需要的跨页面数据可以从上级的Activity中取;二是跨页面的控件可以像布局一样简单实现。

在实现之前,我们先来看一下效果演示:

dragdemo.gif

代码链接在文末

实现拖拽

目前实现有三种:

  • 监听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个方法就能应付绝大部分场景了。

  1. tryCaptureView()

该方法是判定ViewGroup的子View哪个是可以拖动的,判断为true即为可拖动,而我们只需要拖动一个控件,只需要指定tag为特定的值"canDrag"就可以了。

override fun tryCaptureView(child: View, pointerId: Int): Boolean {
            return child.tag == "canDrag"
}
  1. getViewHorizontalDragRange()和getViewVerticalDragRange()

这俩个方法规定了view的活动范围。咱们简单粗暴点,直接用ViewGroup的宽高为范围,让组件随便滑。

override fun getViewHorizontalDragRange(child: View): Int {
            return measuredWidth  
}
override fun getViewVerticalDragRange(child: View): Int {
            return measuredHeight
}
  1. 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的。

  1. 重写onInterceptTouchEvent()

是否拦截事件流(点击事件和拖动不冲突也是shouldInterceptTouchEvent实现的,这方法源码里面的拦截触摸事件流操作值得看一下)

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
	return mViewDragHelper.shouldInterceptTouchEvent(ev)
}
  1. 重写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,因为布局和代码很简单,下面只提供部分代码,详细可看文末代码仓库链接,主要是调用MainActivitynavigateTo方法用来实现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的重新测量与布局。不管什么原因,既然触发了重绘,那通过重写一下DragLayoutonLayout()来解决,把上个页面拖拽的位置记录下来,在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…

参考:

Android拖拽辅助类ViewDragHelper的使用说明