ViewDragHelper是用于编写自定义ViewGroup的帮助类。它提供了许多有用的操作和状态跟踪,允许用户在其父ViewGroup中拖动和重新定位视图。
ViewDragHelper特点
- 构造函数私有,通过静态方法create()创建。
- 必须指定一个父容器ViewGroup。
- 可以指定允许被拖拽、移动的子View。
- 可以限定被拖拽的方向、距离、范围。
- 可以检测子view的位置变化信息。
- 可以检测是否触及到边缘。
- 可以检测手势松开的动作。
ViewDragHelper入门
一、创建实例
public static ViewDragHelper create(@NonNull ViewGroup forParent, @NonNull ViewDragHelper.Callback cb) {
return new ViewDragHelper(forParent.getContext(), forParent, cb);
}
public static ViewDragHelper create(@NonNull ViewGroup forParent, float sensitivity, @NonNull ViewDragHelper.Callback cb) {
ViewDragHelper helper = create(forParent, cb);
helper.mTouchSlop = (int)((float)helper.mTouchSlop * (1.0F / sensitivity));
return helper;
}
参数:
- forParent:要监视的父容器。
- sensitivity:敏感度,1.0正常,较大的值会更敏感。
- ViewDragHelper.Callback:最关键的一个参数,回调检测到的状态信息和事件。
二、Callback回调相关方法
ViewDragHelper.Callback相当于ViewDragHelper和父ViewGroup的一个通信通道,callback可以决定子view是否可以被拖动,子view的范围和状态等。
基本方法:
tryCaptureView
public abstract boolean tryCaptureView(View child, int pointerId);
参数:
- child:正在触摸的子view。
- pointerId:正在触摸的手指id。
返回值:
返回true表示可以捕获(拖动)该子view。
其他功能性方法:
clampViewPositionHorizontal,clampViewPositionVertical
/**
* 限制子view在水平方向上的位置
*/
public int clampViewPositionHorizontal(View child, int left, int dx) {
return 0;
}
/**
* 限制子view在垂直方向上的位置
*/
public int clampViewPositionVertical(View child, int top, int dy) {
return 0;
}
参数:
- child:正在拖拽的子view。
- left / top:在x轴/y轴尝试运动到的位置,相对于初始位置。在初始位置的左/上方为负值,右/下方为正值。
- dx / dy:在x轴/y轴尝试运动的距离。向左/上方滑为负值,右/下方滑为正值。
返回值:
返回值表示最终该子view在x / y轴相对于初始坐标的位置。比如说直接返回left / top表示可以跟随手指随意拖动,默认返回0表示不能拖动。
getViewHorizontalDragRange,getViewVerticalDragRange
public int getViewHorizontalDragRange(View child) {
return 0;
}
public int getViewVerticalDragRange(View child) {
return 0;
}
参数:
- child:要检查的子view。
返回值:
官方文档上写的是以像素为单位返回子view在水平 / 垂直方向的运动范围。但在我的使用过程中,发现这个返回值只要>0,即代表可以在水平 / 垂直方向拖拽移动,且该方法一般在子view有事件监听的情况下使用,因为该方法主要用于ViewDragHelper的shouldInterceptTouchEvent方法,判断是否拦截事件,只有在子view会消费事件的情况下才需要由父ViewGroup判断要不要拦截事件;如果子view不消费事件,那事件最终还是会返回到父ViewGroup,由父ViewGroup消费,也就没有重写onInterceptTouchEvent的必要了。
getOrderedChildIndex
public int getOrderedChildIndex(int index) {
return index;
}
调整捕获子view时的Z顺序,用于有多个子view层叠时想改变要捕获的子view。
源码中在findTopChildUnder方法中使用,默认是从上到下的顺序。
public View findTopChildUnder(int x, int y) {
final int childCount = mParentView.getChildCount();
for (int i = childCount - 1; i >= 0; i--) {
//从上到下依次筛选
final View child = mParentView.getChildAt(mCallback.getOrderedChildIndex(i));
if (x >= child.getLeft() && x < child.getRight()
&& y >= child.getTop() && y < child.getBottom()) {
return child;
}
}
return null;
}
状态回调方法
onViewCaptured
public void onViewCaptured(View capturedChild, int activePointerId) {}
在子view被捕获成功进行拖拽或自动回滚时调用。
onViewDragStateChanged
public void onViewDragStateChanged(int state) {}
子view的拖拽状态发生改变时回调,state有3个取值:
- STATE_IDLE:空闲状态。
- STATE_DRAGGING:拖拽状态。
- STATE_SETTLING:松手自动回滚状态。
onViewPositionChanged
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {}
被拖拽或回滚的子view位置发生改变时回调。
- changedView:位置发生改变的view。
- left:新位置相对于初始位置的x方向的距离,左负右正。
- top:新位置相对于初始位置的y方向的距离,上负下正。
- dx:本次位移的x偏移量,左负右正。
- dy:本次位移的y偏移量,上负下正。
onViewReleased
public void onViewReleased(View releasedChild, float xvel, float yvel) {}
拖拽松手时的回调。
- releasedChild:被捕获的子view。
- xvel:松手时x方向的速度,单位px/s。
- yvel:松手时y方向的速度,单位px/s。
该方法内可以通过调用settleCapturedViewAt(int finalLeft, int finalTop)或flingCapturedView(int minLeft, int minTop, int maxLeft, int maxTop)方法使子view进入STATE_SETTLING状态,最终回滚至某个位置,在这个过程中子view的捕获不会完全停止。如果没有调用这些方法,则子view进入STATE_IDLE状态。
onEdgeTouched,onEdgeLock,onEdgeDragStarted
public void onEdgeTouched(int edgeFlags, int pointerId) {}
public boolean onEdgeLock(int edgeFlags) {
return false;
}
public void onEdgeDragStarted(int edgeFlags, int pointerId) {}
- onEdgeTouched:当用户触摸到边界,且没有捕获子view时调用。
- onEdgeLock:边缘锁定。
- onEdgeDragStarted:当用户开始拖拽某个边界,且没有捕获子view时调用。可以在该方法中调用ViewDragHelper的captureChildView方法进行子view捕获。
edgeFlags表示边界标记,有EDGE_LEFT、EDGE_TOP、EDGE_RIGHT、EDGE_BOTTOM四个值。
三、触摸事件委托给ViewDragHelper
/**
* 拦截事件并交给viewDraghelper判断是否需要拦截。如果子view不消费事件,该方法不重写也可以。
*/
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return viewDragHelper.shouldInterceptTouchEvent(ev)
}
/**
* 将交给viewDraghelper处理,并消费该事件。
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
viewDragHelper.processTouchEvent(event)
return true
}
四、重写computeScroll
如果需要释放View有滚动效果,则还需要重写computeScroll,这是因为ViewDragHelper内部使用了Scroller来处理view的滚动。
override fun computeScroll() {
super.computeScroll()
if (viewDragHelper.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(this)
}
}
ViewDragHelper实践,自定义MenuCardView
这是一个很常见的左滑菜单的自定义View,先思考一下我们达到这样的效果需要做些什么:
- 一个双层结构的ViewGroup,一层表示上层的展示内容,一层表示菜单。
- 内容层要可以滑动,且只可以x方向左滑。
- 内容层的滑动要有最大可滑动距离。
- 松开手指后内容层要可以自动滚动到最左或最右。
- 菜单层可以响应点击事件。
层级结构
我们让MenuCardView继承FrameLayout,规定该View有且只能有两个子View,第一个子View表示菜单层,第二个子View表示内容层。
class MenuCardView(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int
) : CardView(context, attrs, defStyleAttr) {
private lateinit var content: View
private lateinit var menu: View
override fun onFinishInflate() {
super.onFinishInflate()
content = getChildAt(1)
menu = getChildAt(0)
}
}
创建ViewDragHelper实例
private var viewDragHelper: ViewDragHelper
init {
viewDragHelper = ViewDragHelper.create(this, DragCallback())
}
委托ViewDragHelper处理触摸事件
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
return viewDragHelper.shouldInterceptTouchEvent(ev)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
viewDragHelper.processTouchEvent(event)
return true
}
override fun computeScroll() {
super.computeScroll()
if (viewDragHelper.continueSettling(true)) {
ViewCompat.postInvalidateOnAnimation(this)
}
}
内容层可以滑动
inner class DragCallback : ViewDragHelper.Callback() {
override fun tryCaptureView(child: View, pointerId: Int): Boolean {
return child == content
}
}
内容层只能左滑,且限制最大滑动距离
只能左滑,所以我们只重写clampViewPositionHorizontal方法。
override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
this.left = left
if (left < 0) {
return if (Math.abs(left) < menu.width)
left
else
menu.width * -1
}
return 0
}
松开手指自动滚动
松开手指时计算当前滑动的位置,超过界限则自动滚动到最大边界,否则滚动到初始位置。
override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
if (Math.abs(left) > menu.width / 2)
viewDragHelper.settleCapturedViewAt(menu.width * -1, 0)
else
viewDragHelper.settleCapturedViewAt(0, 0)
postInvalidate()
}
菜单层点击事件和内容层滑动兼容的处理
本来在写demo的时候,进行到上一步已经基本实现效果了,当时还美滋滋的用到了正式环境,结果一运行发现根本滑动不了,因为正式环境下添加了菜单的点击事件,此时viewDragHelper.shouldInterceptTouchEvent(ev)的返回值为false,然后事件被子view消费掉了。简单了解了下shouldInterceptTouchEvent的源码,发现getViewHorizontalDragRange可以影响其返回值,做了以下处理后正常。
override fun getViewHorizontalDragRange(child: View): Int {
return menu.width
}