Android View的滑动
文章目录
私以为View的滑动可能说成View的移动更好理解,无论是跟从手指触点对View进行移动还是响应用户操作逻辑所作出的对View进行的滑动效果,还是将View进行移动来体现的,比如说RecyclerView的滑动是将单独Item进行移动。
一、实现移动
首先需要意识到一点,我们对View进行移动的时候,应该通过View本身来捕获触点的坐标,也就是需要重写View内部的onTouchEvent方法或者外部设置onTouch监听来实现,而不是外部ViewGroup来确定。
1.1 layout()
由于View在进行绘制的时候会调用到onLayout方法来确认自身的显示位置,而一个View位置的确定由:left,top,right,bottom这四个参数来确定,所以我们可以在onTouchEvent中获取触点的坐标,然后根据坐标计算出对应的参数值设置给layout方法,而每次调用layout方法,都会对View进行重新绘制,这样就可以达到移动的效果。
关键代码如下:
//记录down事件时候的触点
float downX,downY;
@Override
public boolean onTouchEvent(MotionEvent event) {
//捕获当前事件坐标
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
downX = event.getX();
downY = event.getY();
break;
case MotionEvent.ACTION_MOVE:
//计算偏移量
int offsetX = (int) (x - downX);
int offsetY = (int) (y - downY);
//赋值偏移量
layout(getLeft()+offsetX,getTop()+offsetY,getRight()+offsetX,getBottom()+offsetY);
break;
}
return super.onTouchEvent(event);
}
1.2 设置位置偏移量
在通过layout方法进行View的移动的时候,关键在于计算出手指当前触点(MOVE事件得到的坐标)相对与触发DOWN事件的时候所记录下的坐标的偏移量。而View自身也提供了offsetLeftAndRight()与offsetTopAndBottom()这两个方法来实现便宜,将MOVE中计算得到的便宜量传入即可。
1.3 改变布局参数
通过改变布局参数同样可以实现View的移动,LayoutParams中保存了各个View的位置信息,不同的ViewGroup有着不同的LayoutParams,但都是继承自ViewGroup.MarginLayoutParams,在子View中可以通过getLayoutParams方法获取父ViewGroup的布局参数对象,即不同的Layout有着不同的LayoutParams,需要根据实际情况进行来,但也可以使用MarginLayoutParams来实现View的滑动,这个是无论Layout的类别的。
LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) getLayoutParams();
//对marigin属性累加,累加的值就是每一次的偏移量,从而实现移动效果
lp.leftMargin += offsetX;
lp.topMargin += offsetY;
setLayoutParams(lp);
//requestLayout();也可以
1.4 动画
平移动画可以实现将View移动的效果,需要留意的是如果使用View动画进行平移,它存在的不足(并没有实际改变View的位置),如果使用属性动画进行平移,使用对应于平移的动画属性translationX。
简单代码如下:
//将View从原来的位置移动到300,300
ObjectAnimator.ofFloat(tvMove,"translationX",0,300).setDuration(1000).start();
ObjectAnimator.ofFloat(tvMove,"translationY",0,300).setDuration(1000).start();
使用动画的适用场景是对View的移动过程不需要响应用户交互,如果说要通过对用户触点的追踪加上动画来实现对View的实时移动,效果可能会是很糟糕。但对于不需要相应用户交互的View的移动,动画却是一种非常简便的实现方式,尤其是对于那些移动效果很复杂的交互设计而言更是如此。
1.5 ScrollTo以及ScrollBy
scrollTo(x,y),scrollBy(dx,dy)
两个方法的作用都是将View进行移动,to的参数是移动的终点坐标(与起点无关),by的参数是起点想对于终点的偏移量(起点的坐标会影响最终的移动位置)。
//如果是View,移动的就是它的内容,比如TextView的Text
this.scrollBy(-offsetX,-offsetY);
//如果是ViewGroup,移动的就是它的>>所有<<子View
((View)getParent).scrollBy(-offsetX,-offsetY);
为了达到我们的目的效果,我们将传入scrollBy的偏移量置反,原因很简单,移动方向的参考系不同,而实际上可以认为系统移动的不是View(或者画布)而是屏幕。
想象一个场景:
透过放大镜看报纸:
我们错误习惯性思维是:移动方向指的就是内容区域,也就是画布(报纸)的移动方向,我们以为它是跟随我们的手指移动方向移动的,当我们向下(向右)移动的时候,画布(报纸)是向左下角移动。
但是系统认为,画布(报纸)的位置是固定不变的,我们能看到的只有屏幕(放大镜)里面的区域,但并不代表屏幕(放大镜)外面的区域不存在,它只是超出了显示范围而已,scrollBy传入正值,实际上是将屏幕方向向右下方移动,于是画布相对于屏幕而言就是向左上角(也就是我们看到的朝相反的方向移动)移动。
所以为了体现跟随手指移动的效果,需要将偏移量置反。
scrollBy的内部调用到了scrollTo,是对to方法的一个扩展封装。
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
关于滑动实现的原理,从源码来看,还是借助不断重绘来实现的,这里就不贴代码了。
1.6 使用Scroller
scrollTo和scrollBy两个方法固然可以实现View的移动,不足在于,移动效果是瞬时完成的,我们之所以能用这两个API来实现跟随手指移动的效果,那也是不断调用他们每次瞬时移动一小段距离来实现的,实质上这也是实现弹性滑动(有一个渐进的过程)的思想:把”一瞬间”大的滑动分割成若干次”一瞬间“小的滑动,除了使用动画(设置duration的值)之外,还有就是这个Scroller了,当然,利用线程的延时策略也可以。
Scroller本身无法实现弹性滑动,需要配合View的computeScroll方法来实现。
典型使用方式:
a.首先创建Scroller对象:
mScroller = new Scroller(context);
b.重写View的computeScroll方法:
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
invalidate();
}
}
c.调用Scroller的startScroll方法:
public void smoothScrollTo(int destX,int destY){
//startX,startY,dX,dY,duration
mScroller.startScroll(getScrollX(),getScrollY(),destX,destY,1000);
invalidate();
}
1.7 几种滑动总结:
1.scrollTo和scrollBy适用于对View内容的移动(ViewGroup的内容就是他的子View啦)。
2.动画操作简单,适用于不需要响应用户交互的移动。
3.如果要响应用户交互的移动,改变布局参数的方式更为理想。
二、Scroller解析
上面回顾了Scroller的使用方式,首先创建对象然后重写View的comoute方法,最后调用startScroll方法就可以了,下面就探究一下Scroller是怎样一个工作流程。上面的smoothScrollTo方法内部就调用了startScroll和invalidate两个方法,所以主要也就围绕这两个方法的源码展开:
a.startScroll
传入的参数上面已经标注过了,直接贴代码:
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
mMode = SCROLL_MODE;
mFinished = false;
mDuration = duration;
mStartTime = AnimationUtils.currentAnimationTimeMillis();
mStartX = startX;
mStartY = startY;
mFinalX = startX + dx;
mFinalY = startY + dy;
mDeltaX = dx;
mDeltaY = dy;
mDurationReciprocal = 1.0f / (float) mDuration;
}
赋值赋值赋值,整个方法做的工作就这一个。将滑动的起始坐标,终点坐标统,滑动间隔时间等传入View中。
b.invalidate
该方法是Android提供的View更新的方法之一(另一个是postInvalidate),二者在使用上的区别在于前者在主线程(UI线程中使用),后者是在子线程(非UI线程中使用),通过调用该方法,可以让View进行重绘。View绘制的时候会调用到draw方法,在draw方法内会调用到我们重写的computeScroll方法。
流程图如下:
(到这里本想继续探究下去,但涉及的源码量大且与View的绘制有很大关系,故先就此打住,就暂时埋个伏笔以后再写吧)
c.computeScroll
@Override
public void computeScroll() {
super.computeScroll();
if (mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
invalidate();
}
}
super.computeScroll();父View的compute方法同子View基本如出一辙,重点在于,computeScrollOffset这个方法,该方法源码如下
/**
* Call this when you want to know the new location. If it returns true,
* the animation is not yet finished.
*/
public boolean computeScrollOffset() {
...
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
case FLING_MODE:
...
break;
}
}
else {
mCurrX = mFinalX;
mCurrY = mFinalY;
mFinished = true;
}
return true;
}
删去了一些数学计算的代码,从源码注释来看,当需要知道View的新的位置的时候调用该方法,返回的值是这个移动的动画是否完毕,看到了这个方法里面有一个熟悉的东西,插值器,他就是根据时间的流逝百分比来计算出新的scrollX和scrollY的坐标,调用完这个方法,我们在compute方法里面直接获取新的scrollXY,直接使用ScrollTo方法即可,就完成了将大段的滑动改为一小段一小段的滑动,从而体现出弹性滑动的效果。
三、View的滑动冲突
View的滑动冲突主要是因为可滑动View出现嵌套导致控件对滑动逻辑出现处理冲突的情况,比如说一个横向的ScrollerView和一个纵向的ScrollerView嵌套在一起,因为两个控件View都能处理滑动事件,可能出现用户希望纵向华东却成了横向滑动,这就是滑动冲突。
3.1 常见的滑动冲突与处理规则
3.1.1 内外滑动方向不一致
做过比较多的ViewPager配合RecyclerView实现轮播的头布局,像这样:
这里存在方向不一致的滑动冲突,但是因为ViewPager已经帮我们处理好了这种冲突,所以我们无需手动去解决它。但如果是遇到像ScrollView这样并没有自动处理这些冲突的可能就得手动处理了。
比如下面这种:
这个是我自己遇到过的例子,当初看鸿洋大佬以前写的实现仿QQ5.0的侧滑菜单(借助了HorizontalScrollView来实现)的时候,后面自己放学后也实践了一下,在测试写的整体效果的时候,为了节省时间我采用了一个纵向的ScrollView来实现内容区域,之后就遇到了滑动冲突,横向侧滑经常打不开菜单区域,后面在3.4部分再详细说明。
解决的办法其实并不难,借助外部拦截法即可。
外部拦截法:
外部拦截法就是将点击事件先经过父容器的拦截方法去处理,如过父容器需要对应的事件就拦截下来,否则就不拦截从而将事件交给子View(子容器)去处理。所以需要重写父容器的onInterceptTouchEvent方法。
3.1.2 内外滑动方向一致
当两个View的滑动方向一致的时候,系统没办法判断用户要哪一层的View来进行滑动,所以就需要开发者根据具体的业务逻辑来处理了,处理的办法可以借助内部拦截法。
这里我借助了两个同向的ScrollView嵌套来实现这样一个业务场景。
内部拦截法
上一篇博客的补充3中提到过一个特殊的标记FLAG_DISALLOW_INTERCEPT,就是靠它来实现事件的内部拦截,子View(子容器)可以获取到父View的实例,通过父View的requestDisallowInterceptTouchEvent方法可以设置这个标志位,子View可以决定父View是否需要拦截对应的事件,子View需要重写dispathcTouchEvent方法,在其内部根据自己的操作逻辑决定是否将这次事件拦截下来。
具体的情景看最后的滑动冲突解决实例。
3.1.3 上述两种情况复合嵌套
复合嵌套引起的滑动冲突的解决方式就是复杂问题简单化了,将每一层的嵌套逐一分解,分别处理各层存在的滑动冲突就行了。
3.2 滑动冲突解决实例
3.2.1 实例1:
3.1.1中已经简单说过了借助ScrollView实现侧滑菜单时候遇到的滑动冲突情景,具体的示意图如下:
因为ScrollView本身不能处理两个方向上的滑动,灰色背景部分是一个横向的ScrollView,只能处理横向的滑动,绿色部分是一个纵向的ScrollView,当在绿色部分左右滑动基本上是不能拉出菜单的,这里就出现了滑动冲突,简单贴一下这个DrawerLayout的代码:
class ScrollDrawerLayout : HorizontalScrollView {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
...
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
super.onLayout(changed, l, t, r, b)
...
}
override fun onTouchEvent(ev: MotionEvent?): Boolean {
var action = ev?.action
var x = ev!!.x
var y = ev!!.y
var downX:Float = 0F
when(action){
MotionEvent.ACTION_DOWN->{
downX = ev!!.x
}
MotionEvent.ACTION_UP->{
//手指松开,根据拉出程度决定是打开菜单还是关闭
isMenuOpen = if (scrollX>=mMenuWidth/2){
this.smoothScrollTo(mMenuWidth,0)
false
} else{
this.smoothScrollTo(0,0)
true
}
return true
}
}
return super.onTouchEvent(ev)
}
//关键方法
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
var x = ev!!.x
var y = ev!!.y
var action = ev?.action
when(action){
MotionEvent.ACTION_DOWN->{
downX = x
downY = y
}
MotionEvent.ACTION_MOVE->{
var dx = Math.abs(x-downX)
var dy = Math.abs(y-downY)
//冲突处理的关键地方
if (dx==0F||Math.abs(dy/dx)>(1/3F)) return false
else if (dy==0F||Math.abs(dy/dx)<(1/3F)) return true
}
}
return super.onInterceptTouchEvent(ev)
}
}
上面的代码删去了一些,处理滑动冲突的关键在于,具体的操作情景是怎样的,比如说这里,我的想法是将根据用户滑动屏幕的轨迹的斜率来处理,如果斜率大于1/3就表示用户是竖向滑动,否则就是横向滑动,要拉出菜单,那么就在外层横向ScrollView的onInterceptTouchEvent中的MOVE事件中编写处理代码,竖向滑动就返回false表示不拦截,事件将交给子LinearLayout(因为ScrollView不允许多个子View(ViewGroup),所以我还嵌套了一个LinearLayout,但没多大关系)下的纵向ScrollView处理,来进行竖向滑动;否则的话返回true表示拦截,那么事件就交到了横向ScrollView这里,就可以响应横向滑动拉出菜单了。
3.3.2 实例2:
示意图如下:
外层嵌套内层,图中绿色的item可以正常滑动,但是当触点在蓝色部分(子ScrollVew)的时候并不能响应内层深蓝色item的滑动,利用内部拦截法处理,不多说直接贴解决的代码:
class MyVerticalScroll:ScrollView {
var isScrolledToBottom = false
var isScrolledToTop = false
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
val ac = ev.action
when(ac){
//当触点落在子ScrollView范围内的时候直接让父ScrollView不要拦截此次事件
MotionEvent.ACTION_DOWN->{
parent.requestDisallowInterceptTouchEvent(true)
}
MotionEvent.ACTION_MOVE->{
//不是滑动到了顶部或者底部的话,就应该将事件交给父ScrollView去处理
if (isScrolledToTop||isScrolledToBottom){
parent.requestDisallowInterceptTouchEvent(false)
}
}
}
//归位标记位
isScrolledToBottom = false
isScrolledToTop = false
return super.dispatchTouchEvent(ev)
}
//对ScrollView的滑动状态进行监听的API,level 9以后才可以,直接获取是否滑动到了顶部或者底部,在这里面给标志滑动位置的标记赋值
override fun onOverScrolled(scrollX: Int, scrollY: Int, clampedX: Boolean, clampedY: Boolean) {
super.onOverScrolled(scrollX, scrollY, clampedX, clampedY)
if (scrollY == 0) {
isScrolledToTop = clampedY;
isScrolledToBottom = false;
} else {
isScrolledToTop = false;
isScrolledToBottom = clampedY;
}
}
}
上述的核心代码就在dispatchTouchEvent中的parent.requestDisallowInterceptTouchEvent(boolean)这一句。
参考资料:
《Android开发艺术探索》
《Android进阶之光》
《Android群英传》