目录
- 前言
- 一、常用的几种交互方式
- 1.1 事件的拦截处理
- 1.2 自行处理事件的几种方式
- 1.3 子View的滚动与协调交互
- 1.4 ViewGroup之间的嵌套与协调效果
- 二、ViewDragHelper的侧滑菜单实现
- 三、回调与封装
- 后记
前言
前文我们理解了ViewGroup的测量与布局,但是并没有涉及到多少的交互逻辑,而 ViewGroup 的交互逻辑说起来范围其实是比较大的。从哪开始说起呢?
我们暂且把 ViewGroup 的交互分为几块知识区,
- 事件的拦截。
- 事件的处理(内部又分不同的处理方式)。
- 子View的移动与协调。
- 父ViewGroup的协调运动。
然后我们先简单的做一个介绍,需要注意的是下面每一种方式单独拿出来都是一个知识点或知识面,这里我个人理解的话,可以当做一个目录,我们先简单的复习学习一下,心里过一遍,如果遇到哪一个知识点不是那么了解,那我们也可以单独的对这个技术点进行搜索与对应的学习。
而本文介绍完目录之后,我们会针对其中的一种【子View的协调运动】,也就是本文的侧滑菜单效果做讲解,后期也会对一些其他常用的效果再做分析哦。
话不多说,Let's go
一、常用的几种交互方式
一般来说,常见的几种场景通常来说涉及到如下的几种方式。每一种方式又根据不同的效果可以分为不同的方式来实现。
需要注意的是有时候也并非唯一解,也可以通过不同的方式实现同样的效果。也可以通过不同的方式组合起来,实现一些特定的效果。
下面我们先从事件的分发与拦截说起:
1.1 事件的拦截处理
自定义 ViewGroup 的一种分类,还比较常用的就是解决事件的冲突,常用的就是事件的拦截,这一点就需要了解一点 View 的事件分发与拦截的机制了。不过相信大家多多少少都懂一点,毕竟也是面试必出题了,下面简单说一下。
事件分发方面的区别:
事件分发机制主要有三个方法:dispatchTouchEvent()、onInterceptTouchEvent()、onTouchEvent()
ViewGroup包含这三个方法,而View则只包含dispatchTouchEvent()、onTouchEvent()两个方法,不包含onInterceptTouchEvent()。
onTouchEvent() 与 dispatchTouchEvent() 相信大家都有所了解。
onTouchEvent() 是事件的响应与处理,而dispatchTouchEvent() 是事件的分发。
需要注意的是当某个子View的dispatchTouchEvent()返回true时,会中止Down事件的分发,同时在ViewGroup中记录该子View。接下来的Move和Up事件将由该子View直接进行处理。
而 onInterceptTouchEvent() 就是ViewGroup专有的拦截处理,虽然子 View 没有拦截的方法,但是子View可以通过调用方法 getParent().requestDisallowInterceptTouchEvent() 请求父ViewGroup不拦截事件。
通过 重写 onInterceptTouchEvent() 或者 使用 requestDisallowInterceptTouchEvent() 即可达到事件拦截的处理。
关于事件的处理这里可以引用一张图,非常的清晰:
实际的应用,我这里以 ViewPager2 嵌套 RecyclerView 的场景为例。
如图所示的分类列表,我们可以使用ViewPager2 嵌套 RV 来实现。(具体的实现方式有多种,这里不做讨论),那么就会出现一个问题。什么时候滚动子 RV 。什么时候滚动垂直的父 VP2 。如果大家有尝试过类似的场景,相信大家就能理解这其中的坑点,随机出现父布局与子布局的滚动,也就是说有还是有事件冲突的问题。
就算大家使用别的方案解决了这个问题,那么换成一个复杂的分类列表又如何?
再比如这种复杂的分类页面,由于数据量比较大,子 RV 的上拉滑动事件中还需要加入上拉加载的时间。这一个分类滑动完毕之后,还需要切换右上的横向Tab。当横向Tab到最后一个了,并且滑动完毕之后,左侧的滚动Tab才往下走一个。
面对如此复杂的分类列表滚动逻辑,我们就需要使用自定义ViewGroup时间拦截层,自己控制什么时机由子 RV 控制滑动,什么时机由父 VP2 控制滑动。
这里我们以上图的简单示例为主,也是默认的常用效果,当子 RV 滚动完成之后再交由父 VP2 滚动。我们定义的拦截层自定义ViewGroup如下:
class NestedScrollableHost : FrameLayout { | |
constructor(context: Context) : super(context) | |
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) | |
private var touchSlop = | |
private var initialX =f | |
private var initialY =f | |
private val parentViewPager: ViewPager? | |
get() { | |
var v: View? = parent as? View | |
while (v != null && v !is ViewPager) { | |
v = v.parent as? View | |
} | |
return v as? ViewPager | |
} | |
private val child: View? get() = if (childCount >) getChildAt(0) else null | |
init { | |
touchSlop = ViewConfiguration.get(context).scaledTouchSlop | |
} | |
private fun canChildScroll(orientation: Int, delta: Float): Boolean { | |
val direction = -delta.sign.toInt() | |
return when (orientation) { -> child?.canScrollHorizontally(direction) ?: false | |
-> child?.canScrollVertically(direction) ?: false | |
else -> throw IllegalArgumentException() | |
} | |
} | |
override fun onInterceptTouchEvent(e: MotionEvent): Boolean { | |
return handleInterceptTouchEvent(e) | |
} | |
private fun handleInterceptTouchEvent(e: MotionEvent): Boolean { | |
val orientation = parentViewPager?.orientation ?: return false | |
if (!canChildScroll(orientation, -f) && !canChildScroll(orientation, 1f)) { | |
return false | |
} | |
if (e.action == MotionEvent.ACTION_DOWN) { | |
initialX = e.x | |
initialY = e.y | |
parent.requestDisallowInterceptTouchEvent(true) | |
} else if (e.action == MotionEvent.ACTION_MOVE) { | |
val dx = e.x - initialX | |
val dy = e.y - initialY | |
val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL | |
val scaledDx = dx.absoluteValue * if (isVpHorizontal) .f else 1f | |
val scaledDy = dy.absoluteValue * if (isVpHorizontal)f else .5f | |
if (scaledDx > touchSlop || scaledDy > touchSlop) { | |
return if (isVpHorizontal == (scaledDy > scaledDx)) { | |
//垂直的手势拦截 | |
parent.requestDisallowInterceptTouchEvent(false) | |
true | |
} else { | |
if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) { | |
//子View能滚动,不拦截事件 | |
parent.requestDisallowInterceptTouchEvent(true) | |
false | |
} else { | |
//子View不能滚动,直接就拦截事件 | |
parent.requestDisallowInterceptTouchEvent(false) | |
true | |
} | |
} | |
} | |
} | |
return false | |
} | |
} |
这里主要的逻辑就是对拦截做处理,而如果是下图中复杂的分类页面,也是类似的逻辑,只是需要手动的控制是否拦截了。可以实现同样的效果的。
而除了拦截事件的自定义 ViewGroup 的场景之外,我们用的比较多的就是事件的处理了,事件的处理又分很多,可以自己手撕 onTouchEvent 。也可通过 Scroller 来实现滚动效果。也能通过 GestureDetector 手势识别器来帮我们完成。
下面一起来看看分别如何实现:
1.2 自行处理事件的几种方式
在之前的 View 和 ViewGroup 的学习中,我们一般都是自己来处理事件的响应与拦截,一般都是通过 MotionEvent 对象,拿到它的事件和一些位置信息,做绘制和事件拦截。
其实除了这一种最基本的方式,还有其他的方式也同样可以操作,分为不同的场景,我们可以选择性的使用不同的方式,都可以达到同样的效果。
onTouchEvent
我们比较常见的就是在 dispatchTouchEvent()、onTouchEvent() 两个方法中通过 MotionEvent 对象来操作属性。
比较常用的就是通过手势记录坐标点,然后进行绘制,或者进行事件的拦截。
例如,如果想绘制,我们可以记录变化的X与Y,然后通过指定的公式转换为绘制的变量,然后通过 invalidate 触发重绘,在 onDraw 中取到变化的变量绘制出来,达到动画或滚动或其他的一些效果。
@Override | |
public boolean onTouchEvent(MotionEvent event) { | |
if (event.getAction() == MotionEvent.ACTION_DOWN) { | |
//按下的时候记录当前操作的是左侧限制圆还是右侧的限制圆 | |
downX = event.getX(); | |
touchLeftCircle = checkTouchCircleLeftOrRight(downX); | |
if (touchLeftCircle) { | |
//如果是左侧 | |
//如果超过右侧最大值则不处理 | |
if (downX + perSlice > mRightCircleCenterX) { | |
return false; | |
} | |
mLeftCircleCenterX = downX; | |
} else { | |
//如果是右侧 | |
//如果超过左侧最小值则不处理 | |
if (downX - perSlice < mLeftCircleCenterX) { | |
return false; | |
} | |
mRightCircleCenterX = downX; | |
} | |
} | |
//中间的进度矩形是根据两边圆心点动态计算的 | |
mSelectedCornerLineRect.left = mLeftCircleCenterX; | |
mSelectedCornerLineRect.right = mRightCircleCenterX; | |
//全部的事件处理完毕,变量赋值完成之后,开始重绘 | |
invalidate(); | |
return true; | |
} |
或者我们可以通过记录X和Y的坐标,判断滑动的方向从而进行事件的拦截:
public boolean dispatchTouchEvent(MotionEvent ev) { | |
int x = (int) ev.getRawX(); | |
int y = (int) ev.getRawY(); | |
int dealtX =; | |
int dealtY =; | |
switch (ev.getAction()) { | |
case MotionEvent.ACTION_DOWN: | |
dealtX =; | |
dealtY =; | |
// 保证子View能够接收到Action_move事件 | |
getParent().requestDisallowInterceptTouchEvent(true); | |
break; | |
case MotionEvent.ACTION_MOVE: | |
dealtX += Math.abs(x - lastX); | |
dealtY += Math.abs(y - lastY); | |
// 这里是否拦截的判断依据是左右滑动,读者可根据自己的逻辑进行是否拦截 | |
if (dealtX >= dealtY) { // 左右滑动请求父 View 不要拦截 | |
getParent().requestDisallowInterceptTouchEvent(true); | |
} else { | |
getParent().requestDisallowInterceptTouchEvent(false); | |
} | |
lastX = x; | |
lastY = y; | |
break; | |
case MotionEvent.ACTION_CANCEL: | |
break; | |
case MotionEvent.ACTION_UP: | |
break; | |
} | |
return super.dispatchTouchEvent(ev); | |
} |
这种方式相信也是大家见的最多的,看见代码就知道是什么意思,所以这里就不放图与Demo了,如果想了解,也可以看看我之前的自定义View绘制文章,基本都是这个套路。
接下来我们继续,那么除了原始的 MotionEvent 做移动之外,我们甚至可以使用 Scroller 来专门做滚动的操作。只是相对来说 Scroller 是比较少用的。(毕竟谷歌给我们的太多的滚动的控件了),但是掌握之后可以实现一些特殊的效果,也是值得一学,下面一起看看吧。
Scroller
Scroller 译为滚动器,是 ViewGroup 类中原生支持的一个功能。Scroller 类并不负责滚动这个动作,只是根据要滚动的起始位置和结束位置生成中间的过渡位置,从而形成一个滚动的动画。
Scroller 本身并不神秘与复杂,它只是模拟提供了滚动时相应数值的变化,复写自定义 View 中的 computeScroll() 方法,在这里获取 Scroller 中的 mCurrentX 和 mCurrentY,根据自己的规则调用 scrollTo() 方法,就可以达到平稳滚动的效果。
本质上就是一个持续不断刷新 View 的绘图区域的过程,给定一个起始位置、结束位置、滚动的持续时间,Scroller 自动计算出中间位置和滚动节奏,再调用 invalidate()方法不断刷新。
需要注意的是调用scrollTo()和 scrollBy()的区别。其实也不复杂,我们翻译为中文的意思,scrollTo是滚动到xx,scrollBy是滚动了xx,这样是不是就一下就理解了。
剩下的就是需要重写computeScroll执行滚动的逻辑。
下面举个简单的栗子:
我们使用 Scroller模仿一个 简易的 ViewPager 效果。自定义ViewGroup中加入了9个View。并且占满全屏,然后我们上滑动切换布局,当停手会判断是回到当前View还是去下一个View。
ViewGroup的测量与布局在之前的文章中我们已经反复的复习了,这应该没什么问题:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { | |
super.onMeasure(widthMeasureSpec, heightMeasureSpec); | |
int count = getChildCount(); | |
for (int i =; i < count; i++) { | |
View childView = getChildAt(i); | |
measureChild(childView, widthMeasureSpec, heightMeasureSpec); | |
} | |
} | |
protected void onLayout(boolean changed, int l, int t, int r, int b) { | |
int childCount = getChildCount(); | |
//设置ViewGroup的高度 | |
MarginLayoutParams mlp = (MarginLayoutParams) getLayoutParams(); | |
mlp.height = mScreenHeight * childCount; | |
setLayoutParams(mlp); | |
for (int i =; i < childCount; i++) { | |
View child = getChildAt(i); | |
if (child.getVisibility() != View.GONE) { | |
child.layout(l, i * mScreenHeight, r, (i +) * mScreenHeight); | |
} | |
} | |
} |
然后就是对Touch和滚动的操作:
private int mLastY; | |
private int mStart; | |
private int mEnd; | |
private Scroller mScroller; | |
... | |
@Override | |
public void computeScroll() { | |
super.computeScroll(); | |
if (mScroller.computeScrollOffset()) { | |
scrollTo(, mScroller.getCurrY()); | |
postInvalidate(); | |
} | |
} | |
@Override | |
public boolean onTouch(View v, MotionEvent event) { | |
int y = (int) event.getY(); | |
switch (event.getAction()) { | |
case MotionEvent.ACTION_DOWN: | |
mLastY = y; | |
mStart = getScrollY(); | |
break; | |
case MotionEvent.ACTION_MOVE: | |
//当停止动画的时候,它会马上滚动到终点,然后向动画设置为结束。 | |
if (!mScroller.isFinished()) { | |
mScroller.abortAnimation(); | |
} | |
int dy = mLastY - y; | |
if (getScrollY() <) { | |
dy =; | |
} | |
//开始滚动 | |
scrollBy(, dy); | |
mLastY = y; | |
break; | |
case MotionEvent.ACTION_UP: | |
mEnd = getScrollY(); | |
int dScrollY = mEnd - mStart; | |
if (dScrollY >) { | |
if (dScrollY < mScreenHeight /) { | |
mScroller.startScroll(, getScrollY(), 0, -dScrollY); | |
} else { | |
mScroller.startScroll(, getScrollY(), 0, mScreenHeight - dScrollY); | |
} | |
} else { | |
if (-dScrollY < mScreenHeight /) { | |
mScroller.startScroll(, getScrollY(), 0, -dScrollY); | |
} else { | |
mScroller.startScroll(, getScrollY(), 0, -mScreenHeight - dScrollY); | |
} | |
} | |
invalidate(); | |
break; | |
} | |
return true; | |
} |
那么实现的效果就是如下图所示:
是不是相当于一个简配的ViewPager呢。。。
既然我们的一些事件点击和移动可以通过 MotionEvent 来实现,一些特定的滚动效果还能通过 Scroller 来实现。有没有更方便的一种方式全部帮我们实现呢?
接下来就是我们常用的 GestureDetector 类了。可以帮助我们快速实现点击与滚动效果。
GestureDetector
GestureDetector类,这个类指明是手势识别器,它内部封装了一些常用的手势操作的接口,让我们快速的处理手势事件,比如单机、双击、长按、滚动等。
通常来说我们使用 GestureDetector 分为三步:
- 初始化 GestureDetector 类。
- 定义自己的监听类OnGestureListener,例如实现 GestureDetector.SimpleOnGestureListener。
- 在 dispatchTouchEvent 或 onTouchEvent 方法中,通过GestureDetector将 MotionEvent 事件交给监听器 OnGestureListener
例如我们最简单的例子自定义View,控制View跟随手指移动,我们之前的做法是手撕 onTouchEvent,在按下的时候记录坐标,移动的时候计算坐标,然后重绘达到View跟随手指移动的效果。那么此时我们就能使用另一种方式来实现:
private GestureDetector mGestureDetector; | |
private float centerX; | |
private float centerY; | |
private void init(Context context) { | |
mGestureDetector = new GestureDetector(context, new MTouchDetector()); | |
setClickable(true); | |
} | |
public boolean dispatchTouchEvent(MotionEvent event) { | |
//将Event事件交给监听器 OnGestureListener | |
mGestureDetector.onTouchEvent(event); | |
return super.dispatchTouchEvent(event); | |
} | |
private class MTouchDetector extends GestureDetector.SimpleOnGestureListener { | |
public boolean onDown(MotionEvent e) { | |
YYLogUtils.w("MTouchDetector-onDown"); | |
return super.onDown(e); | |
} | |
public boolean onScroll(MotionEvent e, MotionEvent e2, float distanceX, float distanceY) { | |
centerY -= distanceY; | |
centerX -= distanceX; | |
//边界处理 ... | |
postInvalidate(); | |
} | |
} |
上面我们通过 GestureDetector 来实现了 onTouch 中的绘制效果,那么同样的我们也可以通过 GestureDetector 来实现 onTouch 中的时间拦截效果:
private GestureDetector mGestureDetector; | |
private void init(Context context) { | |
mGestureDetector = new GestureDetector(context, new MTouchDetector()); | |
setClickable(true); | |
} | |
public boolean dispatchTouchEvent(MotionEvent event) { | |
// 先告诉父Viewgroup,不要拦截,然后再内部判断是否拦截 | |
getParent().requestDisallowInterceptTouchEvent(true); | |
//将Event事件交给监听器 OnGestureListener | |
mGestureDetector.onTouchEvent(event); | |
return super.dispatchTouchEvent(event); | |
} | |
private class MTouchDetector extends GestureDetector.SimpleOnGestureListener { | |
public boolean onDown(MotionEvent e) { | |
YYLogUtils.w("MTouchDetector-onDown"); | |
return super.onDown(e); | |
} | |
public boolean onScroll(MotionEvent e, MotionEvent e2, float distanceX, float distanceY) { | |
if (.732 * Math.abs(distanceX) >= Math.abs(distanceY)) { | |
YYLogUtils.w("请求不要拦截我"); | |
getParent().requestDisallowInterceptTouchEvent(true); | |
return true; | |
} else { | |
YYLogUtils.w("拦截我"); | |
getParent().requestDisallowInterceptTouchEvent(false); | |
return false; | |
} | |
} | |
... | |
} |
GestureDetector 甚至能实现 Scroller 的效果,实现山寨ViewPager的效果,
private GestureDetector mGestureDetector; | |
private void init(Context context) { | |
mGestureDetector = new GestureDetector(context, new MTouchDetector()); | |
setClickable(true); | |
} | |
public boolean dispatchTouchEvent(MotionEvent event) { | |
//将Event事件交给监听器 OnGestureListener | |
mGestureDetector.onTouchEvent(event); | |
return super.dispatchTouchEvent(event); | |
} | |
private class MTouchDetector extends GestureDetector.SimpleOnGestureListener { | |
public boolean onDown(MotionEvent e) { | |
YYLogUtils.w("MTouchDetector-onDown"); | |
return super.onDown(e); | |
} | |
public boolean onScroll(MotionEvent e, MotionEvent e2, float distanceX, float distanceY) { | |
//直接移动 | |
scrollBy((int) distanceX, getScrollY()); | |
} | |
... | |
} |
可以看到我们直接在 GestureDetector 的 onScroll 回调中直接 scrollBy 有上面那种 Scroller 的效果了,比较跟手但是不能指定跳转到页面,但是如果想要更好的ViewPager效果,我们需要结合 Scroller 配合的使用就可以有更好的效果。
private GestureDetector mGestureDetector; | |
private int currentIndex; | |
private int startX; | |
private int endX; | |
private Scroller mScroller; | |
private void init(Context context) { | |
mGestureDetector = new GestureDetector(context, new MTouchDetector()); | |
setClickable(true); | |
} | |
public boolean onTouchEvent(MotionEvent event) { | |
mGestureDetector.onTouchEvent(event); | |
switch (event.getAction()) { | |
case MotionEvent.ACTION_DOWN: | |
startX = (int) event.getX(); | |
break; | |
case MotionEvent.ACTION_MOVE: | |
break; | |
case MotionEvent.ACTION_UP: | |
endX = (int) event.getX(); | |
int tempIndex = currentIndex; | |
if (startX - endX > getWidth() /) { | |
tempIndex++; | |
} else if (endX - startX > getWidth() /) { | |
tempIndex--; | |
} | |
scrollIndex(tempIndex); | |
break; | |
} | |
return true; | |
} | |
private class MTouchDetector extends GestureDetector.SimpleOnGestureListener { | |
public boolean onDown(MotionEvent e) { | |
YYLogUtils.w("MTouchDetector-onDown"); | |
return super.onDown(e); | |
} | |
public boolean onScroll(MotionEvent e, MotionEvent e2, float distanceX, float distanceY) { | |
//直接移动 | |
scrollBy((int) distanceX, getScrollY()); | |
return true; | |
} | |
... | |
} | |
private void scrollIndex(int tempIndex) { | |
//第一页不能滑动 | |
if (tempIndex <) { | |
tempIndex =; | |
} | |
//最后一页不能滑动 | |
if (tempIndex > getChildCount() -) { | |
tempIndex = getChildCount() -; | |
} | |
currentIndex = tempIndex; | |
mScroller.startScroll(getScrollX(),, currentIndex * getWidth() - getScrollX(), 0); | |
postInvalidate(); | |
} | |
public void computeScroll() { | |
super.computeScroll(); | |
if (mScroller.computeScrollOffset()) { | |
scrollTo(mScroller.getCurrX(),); | |
postInvalidate(); | |
} | |
} |
这样通过 GestureDetector 结合 Scroller 就可以达到,按着滚动的效果和放开自动滚动到指定索引的效果了。
GestureDetector 确实是很方便,帮助我们封装了事件的逻辑,我们只需要对相应的时间做出响应即可,我愿称之为万能事件处理器。
除了这些单独的事件的处理,在同一个ViewGroup中如果有多个子View,我们还能通过 ViewDragHelper 来实现子 View 的自由滚动,甚至当其中一个View滚动的同时,我可以做对应的变化,(哟,是不是有behavior那味了)
1.3 子View的滚动与协调交互
一句话来介绍 ViewDragHelper ,它是用于在 ViewGroup 内部拖动视图的。
ViewDragHelper 也是谷歌帮我们封装好的工具类, 其本质就是内部封装了MotionEvent 和 Scroller,记录了移动的X和Y,让 Scroller 去执行滚动逻辑,从而实现让 ViewGroup 内部的子 View 可以实滚动与协调滚动的逻辑。
如何使用?固定的套路:
private void initView() { | |
//通过回调,告知告诉了移动了多少,触摸位置,触摸速度 | |
viewDragHelper = ViewDragHelper.create(this, callback); | |
} | |
/** | |
* 触摸事件传递给ViewDragHelper | |
*/ | |
public boolean onTouchEvent(MotionEvent event) { | |
viewDragHelper.processTouchEvent(event); | |
return true; //传递给viewDragHelper。返回true,消费此事件 | |
} | |
/** | |
* 是否需要传递给viewDragHelper拦截事件 | |
*/ | |
public boolean onInterceptTouchEvent(MotionEvent ev) { | |
boolean result = viewDragHelper.shouldInterceptTouchEvent(ev); | |
return result; //让传递给viewDragHelper判断是否需要拦截 | |
} | |
//回调处理有很多,根据不同的需求来实现 | |
private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() { | |
//是否捕获child的触摸事件,是否能移动 | |
public boolean tryCaptureView(View child, int pointerId) { | |
return child == redView || child == blueView; //可以移动红色view | |
} | |
//chlid的移动后的回调,监听 | |
public void onViewCaptured(View capturedChild, int activePointerId) { | |
super.onViewCaptured(capturedChild, activePointerId); | |
// Log.d("tag", "被移动了"); | |
} | |
//控件水平可拖拽的范围,目前不能限制边界,用于手指抬起,view动画移动到的位置 | |
public int getViewHorizontalDragRange(View child) { | |
return getMeasuredWidth() - child.getMeasuredWidth(); | |
} | |
//控件垂直可拖拽的范围,目前不能限制边界,用于手指抬起,view动画移动到的位置 | |
public int getViewVerticalDragRange(View child) { | |
return getMeasuredHeight() - child.getMeasuredHeight(); | |
} | |
//控制水平移动的方向。多少距离,left = child.getleft() + dx; | |
public int clampViewPositionHorizontal(View child, int left, int dx) { | |
//在这里限制最大的移动距离,不能出边界 | |
if (left <) { | |
left =; | |
} else if (left > getMeasuredWidth() - child.getMeasuredWidth()) { | |
left = getMeasuredWidth() - child.getMeasuredWidth(); | |
} | |
return left; | |
} | |
//控制垂直移动的方向。多少距离 | |
public int clampViewPositionVertical(View child, int top, int dy) { | |
//在这里限制最大的移动距离,不能出边界 | |
if (top <) { | |
top =; | |
} else if (top > getMeasuredHeight() - child.getMeasuredHeight()) { | |
top = getMeasuredHeight() - child.getMeasuredHeight(); | |
} | |
return top; | |
} | |
//当前child移动后,别的view跟着做对应的移动。用于做伴随移动 | |
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { | |
super.onViewPositionChanged(changedView, left, top, dx, dy); | |
//判断当蓝色的移动的时候,红色跟着移动相同的距离 | |
if (changedView == blueView) { | |
redView.layout(redView.getLeft() + dx, redView.getTop() + dy, redView.getRight() | |
+ dx, redView.getBottom() + dy); | |
} else if (changedView == redView) { | |
blueView.layout(blueView.getLeft() + dx, blueView.getTop() + dy, blueView.getRight() | |
+ dx, blueView.getBottom() + dy); | |
} | |
} | |
//手指抬起后,执行相应的逻辑 | |
public void onViewReleased(View releasedChild, float xvel, float yvel) { | |
super.onViewReleased(releasedChild, xvel, yvel); | |
//以分界线判断在左边还是右边 | |
int centerLeft = getMeasuredWidth() / - releasedChild.getMeasuredWidth() / 2; | |
if (releasedChild.getLeft() < centerLeft) { | |
//左边移动。移动到的距离 | |
viewDragHelper.smoothSlideViewTo(releasedChild,, releasedChild.getTop()); | |
ViewCompat.postInvalidateOnAnimation(DragLayout.this); //刷新整个view | |
} else { | |
//右边移动。移动到的距离 | |
viewDragHelper.smoothSlideViewTo(releasedChild, getMeasuredWidth() - | |
releasedChild.getMeasuredWidth(), releasedChild.getTop()); | |
ViewCompat.postInvalidateOnAnimation(DragLayout.this); //刷新整个view | |
} | |
} | |
}; | |
public void computeScroll() { | |
//如果正在移动中,继续刷新 | |
if (viewDragHelper.continueSettling(true)) { | |
ViewCompat.postInvalidateOnAnimation(DragLayout.this); | |
} | |
} |
ViewDragHelper (这名字真的取的很好),其实就是滚动(拖拽)的帮助类,可以单独的滚动 ViewGroup 其中的一个子View,也可以用于多个子View的协调滚动。
这也是本期侧滑菜单选用的方案,多个子View的协调滚动的应用。
关于更多 ViewDragHelper 的基础使用,大家如果不了解可以看鸿洋的老文章【传送门】
关于View/ViewGroup的事件,除了这些常用的之外,还有例如多指触控事件,缩放的事件 ScaleGestureDecetor 等,由于比较少用,这里就不过多的介绍,其实逻辑与道理都是差不多的,如果有用到的话,可以再查阅对应的文档哦。
1.4 ViewGroup之间的嵌套与协调效果
前面讲到的都是ViewGroup内部的事件处理,关于ViewGroup之间的嵌套滚动来说的话,其实这是另一个话题了,跟自定义ViewGroup内部的事件处理相比,属实是另一个分支了,演变为多个解决方案,多个知识点了。
我之前的文章有过简单的介绍,目前主要是分几种思路
- NestedScrolling机制
- CoordinatorLayout + Behavior
- CoordinatorLayout + AppBarLayout
- ConstraintLayout / MotionLayout 机制
NestedScrollingParent 与 NestedScrollingChild,NestedScrolling 机制能够让父view和子view在滚动时进行配合,其基本流程如下:当子view开始滚动之前,可以通知父view,让其先于自己进行滚动,子view滚动之后,还可以通知父view继续滚动。
可以看看我之前的文章【传送门】
由于手撕 NestedScrolling 还是有点难度,对于一些嵌套滚动的需求,谷歌推出了 NestedScrollView 来实现嵌套滚动。而对于一些常见的、场景化的协调效果来说,谷歌推出 CoordinatorLayout 封装类,可以结合 Behavior 实现一些自定义的协调效果。
虽说 Behavior 的定义比 NestedScrolling 算简单一点了,但是也比较复杂啊,有没有更简单的,对于一些更常见的场景,谷歌说可以结合 AppBarLayout 做出一些常见的滚动效果。也确实解决了我们大部分滚动效果。
关于这一点可以看看我之前的文章【传送门】
虽然通过监听 AppBarLayout 的高度变化百分比,可以做出各种各样的其他布局的协调动画效果。但是一个是效率问题,一个是难度问题,总有一些特定的效果无法实现。
所以谷歌推出了 ConstraintLayout / MotionLayout 能更方便的做出各种协调效果。
关于这一点可以看看我之前的文章【传送门】
那么到此基本就解决了外部ViewGroup之前的嵌套与协调问题。
这里就不展开说了,这是另外一个体系,有需求的同学可以自行搜索了解一些。我们还是回归正题。
关于自定义 ViewGroup 的事件相关,我们就先初步的整理出一个目录了,接下来我们还是快看看如何定义一个侧滑菜单吧。
二、ViewDragHelper的侧滑菜单实现
目录列好了之后,我们就可以按需选择或组合就可以实现对应的效果。
比如我们这一期的侧滑菜单,其实就是涉及到了交互与嵌套的问题,而我们通过上述的学习,我们就知道我们可以有多种方式来实现。
- 比如手撕 onTouchEvent + Scroller(为了自动返回)
- 再简单点 GestureDetector + Scroller(为了自动返回)
- 再简单点 ViewDragHelper 即可(就是对Scroller的封装)
我们这里就以最简单的 ViewDragHelper 方案来实现
我们分为内容布局和右侧隐藏的删除布局,默认的布局方式是内容布局占满布局宽度,让删除布局到屏幕外。
首先我们要测量与布局:
private View contentView; | |
private View deleteView; | |
private int contentWidth; | |
private int contentHeight; | |
private int deleteWidth; | |
private int deleteHeight; | |
public class SwipeLayout extends FrameLayout { | |
//完成初始化,获取控件 | |
protected void onFinishInflate() { | |
super.onFinishInflate(); | |
contentView = getChildAt(); | |
deleteView = getChildAt(); | |
} | |
//完成测量,获取高度,宽度 | |
protected void onSizeChanged(int w, int h, int oldw, int oldh) { | |
super.onSizeChanged(w, h, oldw, oldh); | |
contentWidth = contentView.getMeasuredWidth(); | |
contentHeight = contentView.getMeasuredHeight(); | |
deleteWidth = deleteView.getMeasuredWidth(); | |
deleteHeight = deleteView.getMeasuredHeight(); | |
} | |
protected void onLayout(boolean changed, int left, int top, int right, int bottom) { | |
contentView.layout(, 0, contentWidth, contentHeight); | |
deleteView.layout(contentView.getRight(),, contentView.getRight() + deleteWidth, deleteHeight); | |
} | |
} |
我们直接继承 FrameLayout 也不用自行测量了,布局的时候我们布局到屏幕外的右侧即可。
接下来我们就使用 viewDragHelper 来操作子View了。都是固定的写法
private void init() { | |
//是否处理触摸,是否处理拦截 | |
viewDragHelper = ViewDragHelper.create(this, callback); | |
} | |
@Override | |
public boolean onInterceptTouchEvent(MotionEvent ev) { | |
return viewDragHelper.shouldInterceptTouchEvent | |
} | |
@Override | |
public boolean onTouchEvent(MotionEvent event) { | |
switch (event.getAction()) { | |
case MotionEvent.ACTION_DOWN: | |
downX = event.getX(); | |
downY = event.getY(); | |
break; | |
case MotionEvent.ACTION_MOVE: | |
float moveX = event.getX(); | |
float moveY = event.getY(); | |
float dx = moveX - downX; | |
float dy = moveY - downY; | |
if (Math.abs(dx) > Math.abs(dy)) { | |
//在水平移动。请求父类不要拦截 | |
requestDisallowInterceptTouchEvent(true); | |
} | |
downX = moveX; | |
downY = moveY; | |
break; | |
case MotionEvent.ACTION_UP: | |
break; | |
} | |
viewDragHelper.processTouchEvent(event); | |
return true; | |
} |
注意的是这里对拦截的事件做了方向上的判断,都是已学的内容。接下来的重点就是 callback 回调的处理。
private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() { | |
//点击ContentView和右侧的DeleteView都可以触发事件 | |
public boolean tryCaptureView(View child, int pointerId) { | |
return child == contentView || child == deleteView; | |
} | |
//控件水平可拖拽的范围,最多也就拖出一个右侧DeleteView的宽度 | |
public int getViewHorizontalDragRange(View child) { | |
return deleteWidth; | |
} | |
//控制水平移动的方向距离 | |
public int clampViewPositionHorizontal(View child, int left, int dx) { | |
//做边界的限制 | |
if (child == contentView) { | |
if (left >) left = 0; | |
if (left < -deleteWidth) left = -deleteWidth; | |
} else if (child == deleteView) { | |
if (left > contentWidth) left = contentWidth; | |
if (left < contentWidth - deleteWidth) left = contentWidth - deleteWidth; | |
} | |
return left; | |
} | |
//当前child移动后,别的view跟着做对应的移动。用于做伴随移动 | |
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { | |
super.onViewPositionChanged(changedView, left, top, dx, dy); | |
//做内容布局移动的时候,删除布局跟着同样的移动 | |
if (changedView == contentView) { | |
deleteView.layout(deleteView.getLeft() + dx, deleteView.getTop() + dy, | |
deleteView.getRight() + dx, deleteView.getBottom() + dy); | |
} else if (changedView == deleteView) { | |
//当删除布局移动的时候,内容布局做同样的移动 | |
contentView.layout(contentView.getLeft() + dx, contentView.getTop() + dy, | |
contentView.getRight() + dx, contentView.getBottom() + dy); | |
} | |
} | |
public void onViewReleased(View releasedChild, float xvel, float yvel) { | |
super.onViewReleased(releasedChild, xvel, yvel); | |
//松开之后,缓慢滑动,看是到打开状态还是到关闭状态 | |
if (contentView.getLeft() < -deleteWidth /) { | |
//打开 | |
open(); | |
} else { | |
//关闭 | |
close(); | |
} | |
} | |
}; | |
/** | |
* 打开开关的的方法 | |
*/ | |
public void open() { | |
viewDragHelper.smoothSlideViewTo(contentView, -deleteWidth,); | |
ViewCompat.postInvalidateOnAnimation(SwipeLayout.this); | |
} | |
/** | |
* 关闭开关的方法 | |
*/ | |
public void close() { | |
viewDragHelper.smoothSlideViewTo(contentView,, 0); | |
ViewCompat.postInvalidateOnAnimation(SwipeLayout.this); | |
} | |
/** | |
* 重写移动的方法 | |
*/ | |
public void computeScroll() { | |
if (viewDragHelper.continueSettling(true)) { | |
ViewCompat.postInvalidateOnAnimation(SwipeLayout.this); | |
} | |
} |
已经做了详细的注释了,是不是很清楚了呢? 效果图如下:
三、回调与封装
在一些列表上使用的时候我们需要一个Item只能打开一个删除布局,那么我们需要一个管理类来管理,手动的打开和关闭删除布局。
public class SwipeLayoutManager { | |
private SwipeLayoutManager() { | |
} | |
private static SwipeLayoutManager mInstance = new SwipeLayoutManager(); | |
public static SwipeLayoutManager getInstance() { | |
return mInstance; | |
} | |
//记录当前打开的item | |
private SwipeLayout currentSwipeLayout; | |
public void setSwipeLayout(SwipeLayout layout) { | |
this.currentSwipeLayout = layout; | |
} | |
//关闭当前打开的item。layout | |
public void closeCurrentLayout() { | |
if (currentSwipeLayout != null) { | |
currentSwipeLayout.close(); //调用的自定义控件的close方法 | |
currentSwipeLayout=null; | |
} | |
} | |
public boolean isShouldSwipe(SwipeLayout layout) { | |
if (currentSwipeLayout == null) { | |
//没有打开 | |
return true; | |
} else { | |
//有打开的 | |
return currentSwipeLayout == layout; | |
} | |
} | |
//清空currentLayout | |
public void clearCurrentLayout() { | |
currentSwipeLayout = null; | |
} | |
} |
我们还需要对打开关闭的状态做管理
enum SwipeState { | |
Open, Close; | |
} | |
private SwipeState currentState = SwipeState.Close; //默认为关闭 |
如果是打开的状态,我们还需要对事件做拦截的处理
public boolean onInterceptTouchEvent(MotionEvent ev) { | |
boolean result = viewDragHelper.shouldInterceptTouchEvent(ev); | |
if (!SwipeLayoutManager.getInstance().isShouldSwipe(this)) { | |
//在此关闭已经打开的item。 | |
SwipeLayoutManager.getInstance().closeCurrentLayout(); | |
result = true; | |
} | |
return result; | |
} | |
public boolean onTouchEvent(MotionEvent event) { | |
//如果当前的是打开的,下面的逻辑不能执行了 | |
if (!SwipeLayoutManager.getInstance().isShouldSwipe(this)) { | |
requestDisallowInterceptTouchEvent(true); | |
return true; | |
} | |
... | |
} |
回调的处理,在 onViewPositionChanged 的移动回调中,我们可以通过内容布局的left是否为0 或者 -deleteWidth 就可以判断当前的布局状态是否是打开状态。
private OnSwipeStateChangeListener listener; | |
public void seOnSwipeStateChangeListener(OnSwipeStateChangeListener listener) { | |
this.listener = listener; | |
} | |
public interface OnSwipeStateChangeListener { | |
void Open(); | |
void Close(); | |
} | |
... | |
//当前child移动后,别的view跟着做对应的移动。用于做伴随移动 | |
@Override | |
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { | |
super.onViewPositionChanged(changedView, left, top, dx, dy); | |
//做内容布局移动的时候,删除布局跟着同样的移动 | |
if (changedView == contentView) { | |
deleteView.layout(deleteView.getLeft() + dx, deleteView.getTop() + dy, | |
deleteView.getRight() + dx, deleteView.getBottom() + dy); | |
} else if (changedView == deleteView) { | |
//当删除布局移动的时候,内容布局做同样的移动 | |
contentView.layout(contentView.getLeft() + dx, contentView.getTop() + dy, | |
contentView.getRight() + dx, contentView.getBottom() + dy); | |
} | |
//判断开,关的逻辑 | |
if (contentView.getLeft() == && currentState != SwipeState.Close) { | |
//关闭删除栏.删除实例 | |
currentState = SwipeState.Close; | |
if (listener != null) { | |
listener.Close(); //在此回调关闭方法 | |
} | |
SwipeLayoutManager.getInstance().clearCurrentLayout(); | |
} else if (contentView.getLeft() == -deleteWidth && currentState != SwipeState.Open) { | |
//开启删除栏。获取实例 | |
currentState = SwipeState.Open; | |
if (listener != null) { | |
listener.Open(); //在此回调打开方法 | |
} | |
SwipeLayoutManager.getInstance().setSwipeLayout(SwipeLayout.this); | |
} | |
} |
这样就完成了全部的逻辑啦,其实理解之后并不复杂。
后记
其实关于侧滑返回的效果,网络上有很多的方案,这也只是其中的一种,为了方便大家理解 viewDragHelper 的使用,其实它还可以用于很多其他的场景,比如底部菜单的展示,Grid网格的动态变换等等。