Android滑动分析:
View滑动的本质是对View的位置(坐标)进行(X轴和Y轴)移动,而为了体现滑动的效果(即不断的进行移动,类似平移动画一样,视觉效果原理都是利用人眼的视觉残留效应),需要监听用户的触摸事件,通过事件获取到具体的坐标情况,来动态改变View的坐标,从而实现View跟随用户的“指尖”进行滑动的效果。
基本概念:
Android中的坐标系:
描述物体位置运动的必须要有一个参考系,关于Android中的坐标系,大致如下:
Android中的坐标系:
1.相对于设备屏幕的坐标系:
将屏幕左上角做为坐标原点,向右为X轴正方向,向下为Y轴正方向。可通过getRowX和getRowY方法获得具体坐标。
2.相对于View的视图坐标系:
描述子View相对于父View的关系,与1中的坐标系区别在于视图坐标系的原点是父View的左上角。
参看下面两张图
蓝色是相对设备屏幕的坐标系,绿色是视图坐标系,黑色是View的自身坐标系。
Android系统提供了很多的坐标信息的get方法,做一个简单的整理:
View自身提供的API
getHeight():获取View自身高度。
getWidth():获取View自身宽度。
getTop():获取View自身顶边到其父布局顶边的距离。
getLeft():获取View自身左边到其父布局左边的距离。
getRight():获取View自身右边到其父布局左边的距离。
getBottom():获取View自身底边到其父布局顶边的距离。
MotionEvent提供的API
getX():获取点击事件距离控件左边的距离,即视图坐标
getY():获取点击事件距离控件顶边的距离,即视图坐标
getRawX():获取点击事件距离整个屏幕左边距离,即绝对坐标
getRawY():获取点击事件距离整个屏幕顶边的的距离,即绝对坐标
Android触摸事件:
触摸事件即MotionEvent对象:与用户触摸相关的时间序列(既然是列,即是一个事件的集合),这个时间序列从用户首次触摸屏幕开始(ACTION_DOWN),经过用户手指在屏幕上的移动(ACTION_MOVE),到用户手指离开屏幕结束(ACTION_UP);一次触摸,至少会触发这三个事件。
MotionEvent不止记录这三个操作,具体情况如下:
ACTION_DOWN:当屏幕检测到第一个触点按下之后就会触发到这个事件。
ACTION_MOVE:当触点在屏幕上移动时触发,触点在屏幕上停留也是会触发的,该事件灵敏度极高,几乎不存在点按屏幕不触发该事件的情况。
ACTION_UP:当触点松开时被触发。
MotionEvent.ACTION_POINTER_DOWN:当屏幕上已经有触点处于按下的状态的时候,再有新的触点被按下时触发。
ACTION_POINTER_UP:当屏幕上有多个点被按住,松开其中一个点时触发(即非最后一个点被放开时)触发。
MotionEvent.ACTION_OUTSIDE: 表示用户触碰超出了正常的UI边界.
更多参看官方文档:http://www.android-doc.com/reference/android/view/MotionEvent.html
View的滑动:
View的滑动的一般实现方式:
①使用layout方法
②使用LayoutParemas
③使用ScrollTo与ScrollBy
④使用Scroller
⑤属性动画
前四种方法都是在onTouchEvent方法中来操作,首先我们需要在处理触摸事件之前先获取触摸点的坐标:
int x = (int) event.getX();
int y = (int) event.getY();
接着在ACTION_DOWN事件中记录触摸点的坐标:
case MotionEvent.ACTION_DOWN:
lastX = x;
lastY = y;
break;
在ACTION_MOVE中计算偏移量,应用于View的移动代码中:
case MotionEvent.ACTION_MOVE:
int offsetX = x-lastX;
int offsetY = y-lastY;
//处理移动
使用layout方法:
在View进行绘制的时候,通过调用onLayout方法来设置显示的位置。而一个View的坐标由它的left,right,top,bottom四个属性决定,通过修改这四个属性的值完成View的移动。
而layout方法最终也是调用onLayout方法:
public void layout(int l, int t, int r, int b) {
...
if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
onLayout(changed, l, t, r, b);
...
}
...
}
在onLayout方法的java_doc中:
left Left position, relative to parent
top Top position, relative to parent
right Right position, relative to parent
bottom Bottom position, relative to parent
标明了这四个参数都是view同parent/viewgroup之间的关系
通过前面获取到的offsetX,offsetY,处理View的移动:
layout(getLeft()+offsetX,getTop()+offsetY,getRight()+offsetX,getBottom()+offsetY);
还可以使用另一种系统已经封装好的方法对View进行偏移:
offsetLeftAndRight(offsetX);
offsetTopAndBottom(offsetY);
使用LayoutParemas:
在View中,有一个关于View布局的极为重要的参数:LayoutParams(static类),关于该类,doc中指出:View通过该参数告诉Parent应该如何放置它们。
LayoutParams are used by views to tell their parents how they want to be laid out.
通过getLayoutParams方法可以获取一个具体的LayoutParams对象。
但是,获取这个对象的时候注意要类型匹配,同时需要当前View是存在父布局的,不然无法获取LayoutParams对象。
LinearLayout.LayoutParams layoutParams = (LinearLayout.LayoutParams) getLayoutParams();
RelativeLayout.LayoutParams layoutParams1 = (RelativeLayout.LayoutParams) getLayoutParams();
FrameLayout.LayoutParams layoutParams2 = (FrameLayout.LayoutParams) getLayoutParams();
获取这个参数之后:
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);
//requestLayout();
在通过LayoutParams来修改View的位置的时候,通常都是改变view的margin属性值来实现的,前面也说过,使用LayoutParams的前提是View存在父布局(我的理解是存在它上层的ViewGroup),而ViewGroup同样有一个LayoutParams参数:MarginLayoutParams:
关于这个参数,doc中的说明是:
Per-child layout information for layouts that support margins.
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
使用ViewGroup.MarginLayoutParams的方便之处在于不用考虑父布局类型。
使用ScrollTo与ScrollBy:
在View中,使用ScrollTo和ScrollBy也可以改变View的位置。To表示移动到一个具体的位置(x,y),By表示移动位置的增量(dx,dy)
而实际上ScrollBy调用的也是ScrollTo的代码:
//参看下面两个方法的源码
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
Set the scrolled position of your view. This will cause a call to
{@link #onScrollChanged(int, int, int, int)} and the view will be
invalidated.
public void scrollTo(int x, int y) {
...
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
...
}
}
This is called in response to an internal scroll in this view (i.e., the
view scrolled its own contents). This is typically as a result of
{@link #scrollBy(int, int)} or {@link #scrollTo(int, int)} having been
called.
protected void onScrollChanged(int l, int t, int oldl, int oldt) {
}
View直接调用Scroll方法并不能改变View的位置,原因在于这两个方法改变的都是Content的值,而Content就是TextView的text,ImageView的drawable对象:
case MotionEvent.ACTION_MOVE:
int offsetX = x-lastX;
int offsetY = y-lastY;
//至于为什么是-offsetX,下面会提到:
scrollBy(-offsetX,-offsetY);
break;
这两个方法改变的是Content的属性,如果要改变View的位置,我们就应该使用ViewGroup的Scroll方法来改变它的Content(也就是它里面的View)。
((View)getParent()).scrollBy(-offsetX,-offsetY);
这里,传入的偏移参数依旧需要置反,如果不置反,View将不能跟随我们的手指进行移动(实际上也不一定是向反方向移动,就是乱动):
理解这个问题,首先清楚一个概念:Android的“可视区域”:
个人对于Android可视区域的理解:
一个手机的屏幕大小是固定的,而View的内容并不仅仅是显示在屏幕内的内容,它不仅仅局限在屏幕大小的区域,它可以是“无限大”的,当View越来越大的时候,我们需要明白,这个时候是屏幕的尺寸限制了View内容的展示,为了解决这个问题,Android给出了移动View这个方案。而当前呈现在手机屏幕上的内容,就是“可视区域”,而当前没有在手机屏幕上的内容,则是“可视区域”之外的内容,它们并非不存在,只是没有出现在屏幕的框框里。
通过图解来具体理解就是:
图中,蓝色是一个View的内容,红色是手机屏幕,button是View里面的一个子View。当我们给ScrollBy传入参数为正的时候(即我们预期目标Button向左下角移动)。
实际上产生的效果:
ScrollBy移动的并不是目标Button,而是屏幕,也就是以屏幕左上角为原点的坐标系的原点(使得这个原点向右下偏移),导致的结果就是:Button在我们的屏幕上移。
所以,我们需要传入偏移量的相反数。
使用Scroller:
使用View的ScrollTo和ScrollBy方法可以实现滑动,但是这个滑动是瞬间就完成的,没有过渡效果,而借助Scroller可以实现有过渡效果的滑动。而Scroller本身其实是并不能实现View的移动的,它需要借助于computeScroller方法才能够完成弹性滑动。
Scroller的实现平滑过度的原理:
简单来说就是Scroller将View的移动由大的瞬移改为小的瞬移。
而Scroller本身其实是并不能实现View的移动的,它需要借助于computeScroller方法才能够完成弹性滑动,通过不断的对View重绘 ,利用重绘距离滑动起始时的时间间隔,通过这个时间间隔就可以计算出当前View应该滑动到哪个位置,计算出新的位置之后,借助View的重绘,将大幅度滑动变为小幅度滑动。
invalidate方法会导致View的重绘,即调用draw方法,在draw方法中,会调用computeScroll方法,这个方法是由我们自己去实现的。在这个方法里面有另一个核心方法:computeScrollOffset(),在这个方法里面系统完成了新坐标的计算,后面通过getCurrent方法得到的就是更新之后的坐标。
//构造Scroller对象
Scroller mScroller = new Scroller(context);
//这个重写方法并不能主动调用,而是在startScroll方法之后调用重绘方法最终回调到这里。
@Override
public void computeScroll() {
super.computeScroll();
// 判断Scroller是否执行完毕
if (mScroller.computeScrollOffset()) {
//在if的条件语句里面执行的方法会计算出当前View应该移动到的具体坐标,
//之后是ScrollTo方法进行移动。
((View) getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
// 通过重绘来不断调用computeScroll
invalidate();
//也可以使用postInvalidate()方法,区别在于后者用于非UI线程调用的重绘方法
}
}
//松开手指后缓慢滑动回到最初的位置
private void smoothScrollTo() {
View viewGroup = ((View) getParent());
//调用startScroll方法,该方法的作用是保存传入的几个参数
//分别是(int startX, int startY, int dx, int dy, int duration)
mScroller.startScroll(viewGroup.getScrollX(),viewGroup.getScrollY(),-viewGroup.getScrollX(),-viewGroup.getScrollY(),1000);
//startScroll方法并并没有滑动相关工作代码,实现滑动效果的是invalidate方法
invalidate();
}
invalidate和postInvalidate方法:
View的绘制流程中,最后都会调用到invalidate方法,该方法是用来刷新View的,且必须是在UI线程中工作,更具体的说就是一个View进行更新之后必须调用invalidate方法才能够看到重绘之后的界面,该方法会将旧有的View从UI线程队列中pop掉。
invalidate:
这个方法不止一个重写,但是实质上最终调用的都是是invalidateInternal方法
/**
* Invalidate the whole view. If the view is visible,
* {@link #onDraw(android.graphics.Canvas)} will be called at some point in
* the future.
* <p>
* This must be called from a UI thread. To call from a non-UI thread, call
* {@link #postInvalidate()}.
*/
public void invalidate() {
invalidate(true);
}
public void invalidate(boolean invalidateCache) {
invalidateInternal(0, 0, mRight - mLeft, mBottom - mTop, invalidateCache, true);
}
void invalidateInternal(int l, int t, int r, int b, boolean invalidateCache,
boolean fullInvalidate) {
...
//这里会调用ViewGroup的invalidateChild方法
if (p != null && ai != null && l < r && t < b) {
final Rect damage = ai.mTmpInvalRect;
//设置刷新区域,对应View的区域
damage.set(l, t, r, b);
p.invalidateChild(this, damage);
}
...
}
}
在ViewGroup的invalidateChild方法中,该方法不允许调用和重写,这个方法是给View层来实现的。
@Deprecated
@Override
public final void invalidateChild(View child, final Rect dirty) {
final AttachInfo attachInfo = mAttachInfo;
//如果dirty区域不为null且应用开启了硬件加速
if (attachInfo != null && attachInfo.mHardwareAccelerated) {
//调用该方法将会导致父View的重绘整个区域
//该方法的参数表(当前父View直接包含的子View,需要被重绘的目标View)
//目标View是指:视图已经失效或者绘图属性已经改变了的需要重新渲染视图层次结构
//该方法会逐层向上回溯调用,视图如果不需要重新记录它的绘图指令,
//就是绘图属性已经被改变,这也是View如何安排绘图遍历的
//具体参照源码doc
onDescendantInvalidated(child, child);
return;
}
ViewParent parent = this;
if (attachInfo != null) {
//如果子View正在进行动画的绘制,那么就需要动画绘制相关的flag复制给父View以满足重绘过程中的需要
final boolean drawAnimation = (child.mPrivateFlags & PFLAG_DRAW_ANIMATION) != 0;
//检查子View所请求的刷新是否是完全透明的,View所进行的动画或者变化不是透明状态
//因为我们需要销毁子View就有的左边并在此之后由父View对他们进行绘制。
Matrix childMatrix = child.getMatrix();
//判断子View是否是透明状态:透明&&未处于绘制动画&&子View没有动画&&子View的Martix矩阵已经定义
final boolean isOpaque = child.isOpaque() && !drawAnimation &&
child.getAnimation() == null && childMatrix.isIdentity();
//设置透明标志
int opaqueFlag = isOpaque ? PFLAG_DIRTY_OPAQUE : PFLAG_DIRTY;
if (child.mLayerType != LAYER_TYPE_NONE) {
mPrivateFlags |= PFLAG_INVALIDATED;
mPrivateFlags &= ~PFLAG_DRAWING_CACHE_VALID;
}
final int[] location = attachInfo.mInvalidateChildLocation;
location[CHILD_LEFT_INDEX] = child.mLeft;
location[CHILD_TOP_INDEX] = child.mTop;
if (!childMatrix.isIdentity() ||
(mGroupFlags & ViewGroup.FLAG_SUPPORT_STATIC_TRANSFORMATIONS) != 0) {
RectF boundingRect = attachInfo.mTmpTransformRect;
boundingRect.set(dirty);
Matrix transformMatrix;
if ((mGroupFlags & ViewGroup.FLAG_SUPPORT_STATIC_TRANSFORMATIONS) != 0) {
Transformation t = attachInfo.mTmpTransformation;
boolean transformed = getChildStaticTransformation(child, t);
if (transformed) {
transformMatrix = attachInfo.mTmpMatrix;
transformMatrix.set(t.getMatrix());
if (!childMatrix.isIdentity()) {
transformMatrix.preConcat(childMatrix);
}
} else {
transformMatrix = childMatrix;
}
} else {
transformMatrix = childMatrix;
}
transformMatrix.mapRect(boundingRect);
dirty.set((int) Math.floor(boundingRect.left),
(int) Math.floor(boundingRect.top),
(int) Math.ceil(boundingRect.right),
(int) Math.ceil(boundingRect.bottom));
}
do {
View view = null;
if (parent instanceof View) {
view = (View) parent;
}
if (drawAnimation) {
if (view != null) {
view.mPrivateFlags |= PFLAG_DRAW_ANIMATION;
} else if (parent instanceof ViewRootImpl) {
((ViewRootImpl) parent).mIsAnimating = true;
}
}
// If the parent is dirty opaque or not dirty, mark it dirty with the opaque
// flag coming from the child that initiated the invalidate
if (view != null) {
if ((view.mViewFlags & FADING_EDGE_MASK) != 0 &&
view.getSolidColor() == 0) {
opaqueFlag = PFLAG_DIRTY;
}
if ((view.mPrivateFlags & PFLAG_DIRTY_MASK) != PFLAG_DIRTY) {
view.mPrivateFlags = (view.mPrivateFlags & ~PFLAG_DIRTY_MASK) | opaqueFlag;
}
}
parent = parent.invalidateChildInParent(location, dirty);
if (view != null) {
// Account for transform on current parent
Matrix m = view.getMatrix();
if (!m.isIdentity()) {
RectF boundingRect = attachInfo.mTmpTransformRect;
boundingRect.set(dirty);
m.mapRect(boundingRect);
dirty.set((int) Math.floor(boundingRect.left),
(int) Math.floor(boundingRect.top),
(int) Math.ceil(boundingRect.right),
(int) Math.ceil(boundingRect.bottom));
}
}
} while (parent != null);
}
}