Android View的滑动

时间:2024-01-08 08:51:44

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传入正值,实际上是将屏幕方向向右下方移动,于是画布相对于屏幕而言就是向左上角(也就是我们看到的朝相反的方向移动)移动。

Android View的滑动

所以为了体现跟随手指移动的效果,需要将偏移量置反。

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方法。

流程图如下:

Android View的滑动

(到这里本想继续探究下去,但涉及的源码量大且与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实现轮播的头布局,像这样:

Android View的滑动

这里存在方向不一致的滑动冲突,但是因为ViewPager已经帮我们处理好了这种冲突,所以我们无需手动去解决它。但如果是遇到像ScrollView这样并没有自动处理这些冲突的可能就得手动处理了。

比如下面这种:

Android View的滑动

这个是我自己遇到过的例子,当初看鸿洋大佬以前写的实现仿QQ5.0的侧滑菜单(借助了HorizontalScrollView来实现)的时候,后面自己放学后也实践了一下,在测试写的整体效果的时候,为了节省时间我采用了一个纵向的ScrollView来实现内容区域,之后就遇到了滑动冲突,横向侧滑经常打不开菜单区域,后面在3.4部分再详细说明。

解决的办法其实并不难,借助外部拦截法即可。

外部拦截法:

外部拦截法就是将点击事件先经过父容器的拦截方法去处理,如过父容器需要对应的事件就拦截下来,否则就不拦截从而将事件交给子View(子容器)去处理。所以需要重写父容器的onInterceptTouchEvent方法。

3.1.2 内外滑动方向一致

当两个View的滑动方向一致的时候,系统没办法判断用户要哪一层的View来进行滑动,所以就需要开发者根据具体的业务逻辑来处理了,处理的办法可以借助内部拦截法。

这里我借助了两个同向的ScrollView嵌套来实现这样一个业务场景。

Android View的滑动

内部拦截法

上一篇博客的补充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实现侧滑菜单时候遇到的滑动冲突情景,具体的示意图如下:

Android View的滑动

因为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:

示意图如下:

Android View的滑动

外层嵌套内层,图中绿色的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群英传》