Android进阶 View事件体系(一):概要介绍和实现View的滑动
内容概要
本篇文章为总结View事件体系的第一篇文章,将介绍的内容主要有:
- 什么是View和ViewGroup
- Android中View的坐标轴
- 手势检测和速度检测
- 如何实现View的滑动
Android中的View与ViewGroup
在更加深入地了解Android中View的事件体系之前,我们需要了解究竟什么是Android中的View。
在Android系统中,View类是继承于Object,用于构建用户界面的基本块之一。从官网来说:
This class represents the basic building block for user interface components. A View occupies a rectangular area on the screen and is responsible for drawing and event handling. View is the base class for widgets, which are used to create interactive UI components (buttons, text fields, etc.). The ViewGroup subclass is the base class for layouts, which are invisible containers that hold other Views (or other ViewGroups) and define their layout properties.
翻译过来就是:
这个类代表用户界面组件的基本构建块。View在屏幕上占据一个矩形区域,并负责绘制和事件处理。View是小部件(widgets)的基类,用于创建交互式的用户界面组件(按钮、文本字段等)。ViewGroup子类是布局(layouts)的基类,它们是不可见的容器,可以容纳其他的View(或其他ViewGroup),并定义它们的布局属性。
上面是比较官方的解释,实际上View就是Android中所有控件的基类,所以说,View是一种界面层的控件的一种抽象,其他所有的控件(比如:TextView,Button等)都是继承于View这个基类,这就会使所有的控件都会遵循一定的规则,我们也可以把这种规则理解为View的体系。
除此之外,还提到到了ViewGroup,ViewGroup就是容纳View的容器,一个ViewGroup中可以放置多个View,同时,ViewGroup自身也继承于View。ViewGroup作为View或者ViewGroup这些组件的容器,派生了多种布局控件子类,比如LinearLayout,RelativeLayout等。
如上图👆所示,就形成了一个View的树。一般我们都不会直接使用View或者ViewGroup,而是使用他们的派生类。不过,在我们了解完整个View的事件和体系之后,我们也可以拓展基类来实现我们想要的自定义View。
Android中的坐标系
如同所有其他系统一样,如果要描述一个实体在空间中的位置,无论是二维还是三维空间,都需要引入坐标系的概念。Android系统也不例外,在Android系统中存在两种坐标系,分别是Android坐标系和View坐标系。
Android坐标系(绝对坐标系)
在Android系统中,将屏幕左上角的顶点作为坐标系的原点,原点向右是X轴正方向,向下是Y轴正方向;在触控事件中,使用getRawX方法和getRawY方法获得的都是Android坐标系的坐标,Android坐标系也称为绝对坐标系。 具体如下图所示:
View坐标系(相对坐标系)
上面说到Android坐标系是绝对坐标系,那么View坐标系就可以称作是相对坐标系了。它与Android坐标系共同存在帮助我们控制控件。
View坐标系是描述View在其父ViewGroup中的位置的,主要有四个属性:top,left,right和bottom。top是左上角纵坐标,left是左上角横坐标,right是右下角横坐标,bottom是右下角纵坐标,并且这些坐标都是相对于View的ViewGroup来说的,即,以ViewGroup的左上角而非屏幕的左上角为原点,所以说View坐标系又称为相对坐标系,具体可以看下图👇:
在处理点击事件的时候,我们一般都是用的相对坐标系,这里也有一些方法可以获取相对坐标和绝对坐标,具体看下图所示:
getX和getY方法可以获取触摸点距离所在View的左边界和上边界的距离,也就是相对坐标系的值;
getRawX和getRawY方法可以获取触摸点的绝对坐标系值;
除此之外,View也有一些方法:
- getTop():获取View自身顶边到其父布局顶边的距离;
- getLeft():获取View自身左边到其父布局左边的距离;
- getBottom():获取View自身底边到其父布局底边的距离;
- getRight():获取View自身右边到其父布局右边的距离;
有了以上这些信息,我们也可以得出许多别的信息,比如我们可以View的宽和高:
width = getRight() - getLeft(); //View的宽度
height = getBottom() - getTop(); //View的高度
手势检测和速度检测
MotionEvent
在这一节中我们将要处理一些触摸事件,所以我们先来了解手指碰到屏幕后会产生哪些事件。首先先看MotionEvent,MotionEvent中定义了一系列手指触摸到屏幕后所产生的一系列事件,主要有以下:
事件 | 含义 |
---|---|
ACTION_DOWN | 表示手指按下屏幕时触发的事件。它是触摸事件序列的第一个事件。 |
ACTION_MOVE | 表示手指在屏幕上滑动时触发的事件。在手指滑动期间,会多次触发该事件。 |
ACTION_UP | 表示手指从屏幕上抬起时触发的事件。它是触摸事件序列的最后一个事件。 |
ACTION_CANCEL | 表示触摸事件序列被取消时触发的事件。在某些情况下,例如手指按下后突然有来电或系统通知,会导致触摸事件被取消。 |
ACTION_POINTER_DOWN | 当有多个手指同时按下屏幕时,除第一个手指外的其他手指按下时触发的事件。 |
ACTION_POINTER_UP | 当有多个手指同时按下屏幕时,除最后一个手指外的其他手指抬起时触发的事件。 |
ACTION_HOVER_MOVE | 当没有手指触摸屏幕,但有物体(如手指悬停、笔等)接近屏幕时触发的事件。 |
ACTION_BUTTON_PRESS | 代表鼠标按键按下的事件。它是指在连接到Android设备的鼠标设备上,当按下鼠标按钮时触发的事件。 |
正常情况下,一次手指触摸屏幕的行为会触发一系列点击事件,比如说:
- 点击屏幕后离开松手,事件序列为DOWN -> UP;
- 点击屏幕后滑动一会再松手,事件序列DOWN -> MOVE -> … -> UP;
在后面我们实现View跟手移动的时候就需要在onTouchEvent中处理这些事件。
TouchSlop
TouchSlop是系统所能够识别出的被认为是滑动的最小距离,也可以说是能触发MOVE事件的一个距离单元。换句话说,当手指在屏幕上滑动时,如果距离小于TouchSlop,那么系统将不会触发MOVE事件。TouchSlop是一个常量,一般与设备有关,我们可以通过getScaledTouSlop方法获取,返回值为int值:
//在Activity的onCreate方法中获取最小滑动值
int mindp = ViewConfiguration.get(getApplicationContext()).getScaledTouchSlop();
手势检测和速度检测
速度跟踪
速度跟踪就是用于追踪手指在滑动过程中的速度,包括水平和竖直方向上的速度。这里我们需要用到VelocityTracker类,顾名思义,这个类就是用来帮助我们进行速度跟踪的。我们可以在我们自定的View中滑动并检测滑动速度。
这里我们首次提到了自定义View,其实实现自定义View很简单,只需要新建一个类继承View然后实现其构造方法就行了,这里我们在自定义View的onTouchEvent方法中实现速度检测。上面提到过,手指触摸到手机屏幕后就会产生一系列事件,在onTouchEvent方法中我们就可以处理这些事件,这里给出整个自定义View的代码:
public class VelocityTrackerView extends View {
private static final String TAG = "VelocityTrackerView";
VelocityTracker velocityTracker;
public VelocityTrackerView(Context context) {
super(context);
}
public VelocityTrackerView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public VelocityTrackerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public VelocityTrackerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
velocityTracker = VelocityTracker.obtain();//获取速度追踪对象
break;
case MotionEvent.ACTION_MOVE:
velocityTracker.addMovement(event);//添加移动事件
velocityTracker.computeCurrentVelocity(1000);//计算1000ms内的平均速度
int xVelocity = (int) velocityTracker.getXVelocity(); //获取XY的速度值
int yVelocity = (int) velocityTracker.getYVelocity();
Log.d(TAG, "xVelocity is : "+ xVelocity);
Log.d(TAG, "yVelocity is : "+ yVelocity);
break;
case MotionEvent.ACTION_UP:
velocityTracker.clear();//清空速度追踪器
velocityTracker.recycle();//回收速度追踪器
break;
default:
break;
}
return true;
}
}
可以发现,使用VelocityTracker主要分为以下步骤:
- 获取VelocityTracker对象:
velocityTracker = VelocityTracker.obtain();//获取速度追踪对象
- 添加需要追踪的移动事件组:
velocityTracker.addMovement(event);
- 计算并且获取速度值:
velocityTracker.computeCurrentVelocity(1000);//计算1000ms内的平均速度
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();
这里的computeCurrentVelocity中传入的是时间间隔,单位为毫秒,这里传入1000即计算每一秒手指移动的像素数。需要说明的是,在获取X和Y轴的移动速度前需要先调用computeCurrentVelocity方法。另外,根据坐标系的定义,获取的速度也有正有负,正说明往坐标轴的正方向移动,同理负就说明往坐标轴的负方向移动。速度的计算可由以下公式表示:
速度 = (终点位置 - 起点位置)/ 时间间隔;
- 重置并回收速度检测器
velocityTracker.clear(); //重置速度检测器
velocityTracker.recycle(); //回收速度检测器的内存
最后,在我们的布局文件中添加我们的自定义View就可以使用了(这里用的是约束布局):
<com.example.zidingyi.VelocityTrackerView
android:id="@+id/mVelocityTrackerView"
android:layout_width="418dp"
android:layout_height="265dp"
android:layout_marginTop="100dp"
android:background="#E3F2FD"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
手势检测
手势检测,是用于辅助检测用户的单机,滑动,长按,双击等行为。这里需要用到GestureDetector,这个和VelocityTracker异曲同工,相对来说使用比较简单。
首先我们要创建一个GestureDetector对象并且实现OnGestureListener接口,根据需要我们还可以实现OnDoubleTapListener从而能够监听双击行为:
gestureDetector = new GestureDetector(getContext(),this);
gestureDetector.setIsLongpressEnabled(false);//解决长按屏幕后无法拖动的现象
然后,可以在onTouchEvent中直接托管View的触摸事件:
boolean consume = gestureDetector.onTouchEvent(event);
return consume;
下面给出完整的代码:
public class GestureDetectorView extends View implements GestureDetector.OnGestureListener {
private static final String TAG = "GestureDetectorView";
GestureDetector gestureDetector = null;
public GestureDetectorView(Context context) {
super(context);
}
public GestureDetectorView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public GestureDetectorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public GestureDetectorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if(gestureDetector == null){
gestureDetector = new GestureDetector(getContext(),this);
gestureDetector.setIsLongpressEnabled(false);
}
boolean isConsume = gestureDetector.onTouchEvent(event);//接管onTouchEvent方法
return isConsume;
}
@Override
public boolean onDown(@NonNull MotionEvent e) {
Log.d(TAG, "onDown: ");
return true;
}
@Override
public void onShowPress(@NonNull MotionEvent e) {
Log.d(TAG, "onShowPress: ");
}
@Override
public boolean onSingleTapUp(@NonNull MotionEvent e) {
Log.d(TAG, "onSingleTapUp: ");
return true;
}
@Override
public boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2, float distanceX, float distanceY) {
Log.d(TAG, "onScroll: ");
return true;
}
@Override
public void onLongPress(@NonNull MotionEvent e) {
Log.d(TAG, "onLongPress: ");
}
@Override
public boolean onFling(@NonNull MotionEvent e1, @NonNull MotionEvent e2, float velocityX, float velocityY) {
Log.d(TAG, "onFling: ");
return true;
}
}
其中提到的回调方法以及其意义总结在下表了:
方法名 | 描述 | 所属接口 |
---|---|---|
onDown | 手指轻轻触摸屏幕的一瞬间 | OnGestureListener |
onShowPress | 手指轻轻触摸屏幕,尚未松开或者拖动,由一个ACTION_DOWN触发 *需要和onDown区别,它强调的是没有松开或者拖动的状态 | OnGestureListener |
onSingleTapUp | 手指(轻轻触摸屏幕后)松开,伴随这一个ACTION_UP而触发,这是单击行为 | OnGestureListener |
onScroll | 手指按下屏幕并拖动,由一个ACTION_DOWN,多个ACTION_MOVE触发,这是拖动行为 | OnGestureListener |
onLongPress | 手指长按屏幕 | OnGestureListener |
onFling | 按下触摸屏,快速滑动后松开,由一个ACTION_DOWN,多个ACTION_MOVE和一个ACTION_UP触发,是快速滑动行为 | |
onDoubleTap | 双击,由两次连续的单击组成,不可能和onSingleTapConfirmed共存 | onDoubleTapListener |
onSingleTapConfirm | 严格的单击行为 *注意它和onSingleTapUp的区别,如果触发了onSingleTapConfirm,那么后面不可能再紧跟着另一个单机型为,即这只可能是单击,而不可能是双击中的一次单击 | onDoubleTapListener |
onDoubleTapEvent | 表示发生了双击行为,在双击的期间,ACTION_DOWN,ACTION_MOVE和ACTION_UP都会触发此回调 | onDoubleTapListener |
实现View的滑动
移动View的几种方式
1.scrollTo/scrollBy
scrollTo(x,y)表示移动到一个具体的坐标点,而scrollBy(dx,dy)则表示要移动的增量为dx和dy。其中,scrollBy最终也是要调用到scrollTo方法的:
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
需要注意的是,scrollTo和scrollBy方法移动的是整块画布之上的手机屏幕。什么叫整块画布呢?假设我们正用放大镜在看报纸,放大镜用来显示报纸上的内容。同样我们可以把放大镜看作是我们的手机屏幕,他们都用用来显示内容的。而报纸可以看做是屏幕下的画布,它们都是用来提供内容的。放大镜外的内容,也就是报纸的内容不会随着放大镜的移动而消失,他一直存在。
同样,我们的手机屏幕看不到的视图并不代表其不存在。可以先如图所示:
画布上有三个按钮控件,以手机屏幕左上角为原点,能被我们看到的控件只有位于60,60的按钮控件,其他两个按钮控件都是不可见的。假如我们调用了scrollBy(50,50)方法后将会显示什么呢?如果按照我们之前的思维的话,应该是button向X和Y的正方向轴均移动50个单位,之前位于(60,60)的button应该会位于(110,110)是吗?事实并非如此,由于scrollBy移动的是手机屏幕,所以移动完之后的情况应该是这样的:
由于整块手机屏幕被向X和Y轴的正方向移动了50个单位,所以正确的坐标应该是(10,10).所以说我们在用scrollBy或者scrollTo方法将View进行移动时,就需要将目标值加上负号:
((View)getParent()).scrollBy(-offsetX,-offsetY)
2.修改布局参数
第二种实现移动View的方法就是重新修改并设置布局参数,这是一种相对比较灵活并且比较推荐的方法。LayoutParams(布局参数)中存储了View的布局参数,我们可以修改并且重新设置布局参数达到移动View的效果。比如我们想把一个Button向右平移100px,只需要将LayoutParams里的marginLeft参数增加100px即可。
不过在获取布局参数的时候,我们一定要记得把其转化成对应父布局的布局参数,比如说,如果我的父布局是ConstraintLayout,那么我们在获取布局参数的时候就需要这样写:
ConstraintLayout.LayoutParams params =
(ConstraintLayout.LayoutParams)getLayoutParams();
接着我们修改布局参数并重新设置布局参数:
params.leftMargin += offsetX;
params.topMargin += offsetY;
setLayoutParams(params);
这样就可以将view向右移动offsetX值,向下移动offsetY值。
3.使用动画
动画又可以分为普通动画和属性动画。这两者的区别是,普通动画并不能改变View的位置参数,只是将View的影响实现了移动,这样讲可能有一点抽象,具体来说就是,如果对一个Button向右平移了300个像素,我们点击移动后的Button并不会触发点击事件,而点击Button的原始位置却会触发点击事件。
那我们先来看如何实现普通动画,我们先在res目录中新建anim文件夹并创建translate.xml文件来定义动画的效果:
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
android:fillAfter="true">
<translate
android:duration="1000"
android:fromXDelta="0"
android:toXDelta="300"
/>
</set>
translate标签中的duration指的是动画的持续时间,单位为ms,这里指定的动画时间就是1s;后面的fromXDelta就是X坐标的起始位,toXDelta就是X坐标的终点位。至于最上面的fillAfter,是设置动画完成后View时候保持在动画结束后的位置,若没有设置为true,那么动画完成后View将返回原位。
接着我们对View加载动画:
mView.setAnimation(AnimationUtils.loadAnimation(this,R.anim.translate));
接下来我们来看属性动画,其和普通动画的区别就是它可以改变View的位置参数,这里我们不详细讲属性动画,简单介绍一下属性动画,首先我们需要获取到一个属性动画对象:
ObjectAnimator am1 = ObjectAnimator.ofFloat(customView,"translationX",0.f,200.0f,0f);
ObjectAnimator.ofFloat()方法用于创建一个浮点数值的属性动画。
- customView:要执行动画的目标视图对象。
- “translationX”:指定要进行动画操作的属性名称,这里是translationX,表示视图在X轴上的平移。
- 0.f:动画的起始值,这里表示X轴平移的起始位置。
- 200.0f:动画的中间值,这里表示X轴平移的结束位置。
- 0f:动画的结束值,这里表示X轴平移的回到起始位置。
然后设置动画时间并启动动画:
am1.setDuration(1000).start();
这样就实现了平移过去再回来的效果。
4.layout方法
第四种方法就是直接使用layout方法,layout()方法是View类的一个方法,它接受四个整数参数:left、top、right和bottom,用于指定视图在父容器中的位置。这些参数表示视图的左上角和右下角在父容器坐标系中的坐标。
**不过并不太推荐这种方法,因为layout方法并不会改变View的布局参数。**当在其他地方设置布局参数时,就会出现视图位置跳变的情况。具体如果想在View中调用:
layout(getLeft()+offsetX,getTop()+offsetY,
getRight()+offsetX,getBottom()+offsetY);
这样就实现了View的移动,不过还是要强调一下,不推荐这种方法。
实现弹性滑动
接下来我们来实现一个有实际意义的功能:实现弹性滑动。这里我们使用弹性滑动对象Scroller实现,主要是介绍Scroller实现弹性滑动的典型用法,先给出典型的流程:
首先我们需要获得一个弹性滑动对象:
Scroller mScroller = new Scroller(context);//初始化Scroller
然后我们需要重写View的computeScroll函数,这是一个回调方法,会在绘制View的时候在draw方法中调用该方法。在这个方法中我们调用scrollTo方法并通过scroller对象不断获得当前View应该处于的位置,我的理解就是讲一段一段特别微小的动画组合起来实现弹性动画的效果:
public void smoothScrollTo(int destX,int destY){ //自己写的方法--用于给外部调用实现弹性滑动
int scrollX = getScrollX();
int delta = destX-scrollX;
mScroller.startScroll(scrollX,0,delta,0,2000);
invalidate();
}
@Override
public void computeScroll() {
super.computeScroll();
//用于计算滚动量,当返回值为true时,说明滚动尚未完成;否则说明滚动已经完成
if(mScroller.computeScrollOffset()){
((View)getParent()).scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
invalidate();//不断进行重新绘制
}
}
接下来我们就可以在Activity中调用该方法实现弹性滑动了:
mScrollerView.smoothScrollTo(-400,0);//向右平移400个像素
不过需要注意的是,这里移动的是整个手机屏幕(实际上相对来说就是移动了整块画布),所以屏幕上所有的内容都会产生平移。
实现View跟手移动
接下来我们实现拖动View,View跟手移动的效果。之前我们介绍了这么多移动View的方法,这里采取更加推荐的修改布局参数的方法进行移动。
首先在按下View时记录下View的起始坐标:
public boolean onTouchEvent(MotionEvent motionEvent){
//只要有触摸事件,该两行代码就会执行记录下坐标
int x = (int) motionEvent.getX();//
int y = (int) motionEvent.getY();//
//手指的移动是快于绘图重新绘制的
switch (motionEvent.getAction()){
case MotionEvent.ACTION_DOWN://事件按下
lastX = x;
lastY = y;
Log.d(TAG, "DOWN");
break;
...
}
lastX 和 lastY参数是View的成员变量,用于记录开始滑动时View的起始坐标,这里用相对坐标系即可了。
接下来,我们在移动事件里记录下手指移动到的坐标并且计算出偏移量,最后修改布局参数不断移动View的位置:
case MotionEvent.ACTION_MOVE:
//计算偏移量
int offsetX = x - lastX;
int offsetY = y - lastY;
//这方法有问题 -->改进后的方法 将源代码中的 getLeft + offsetX 替换
ConstraintLayout.LayoutParams layoutParams =
(ConstraintLayout.LayoutParams) getLayoutParams();//获取布局参数
layoutParams.leftMargin = layoutParams.leftMargin + offsetX;
layoutParams.topMargin = layoutParams.topMargin + offsetY;
setLayoutParams(layoutParams);
Log.d(TAG, "MOVE");
break;
核心方法就是这两个,本质就是不断记录偏移量然后进行重绘,下面给出完整的View代码:
public class MyFloat extends View {
private static final String TAG = "mView";
int lastX ;
int lastY ;
public MyFloat(@NonNull Context context) {
super(context);
}
public MyFloat(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public MyFloat(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
//使用布局参数LayoutParams
public boolean onTouchEvent(MotionEvent motionEvent){
//只要有触摸事件,该两行代码就会执行记录下坐标
int x = (int) motionEvent.getX();//
int y = (int) motionEvent.getY();//
//手指的移动是快于绘图重新绘制的
switch (motionEvent.getAction()){
case MotionEvent.ACTION_DOWN://事件按下
lastX = x;
lastY = y;
Log.d(TAG, "DOWN");
break;
case MotionEvent.ACTION_MOVE:
//计算偏移量
int offsetX = x - lastX;
int offsetY = y - lastY;
//这方法有问题 -->改进后的方法 将源代码中的 getLeft + offsetX 替换
ConstraintLayout.LayoutParams layoutParams =
(ConstraintLayout.LayoutParams) getLayoutParams();//获取布局参数
layoutParams.leftMargin = layoutParams.leftMargin + offsetX;
layoutParams.topMargin = layoutParams.topMargin + offsetY;
setLayoutParams(layoutParams);
Log.d(TAG, "MOVE");
break;
case MotionEvent.ACTION_UP://事件抬起
Log.d(TAG, "UP");
break;
case MotionEvent.ACTION_BUTTON_PRESS:
break;
}
return true;//表示该事件已被处理并且消费掉了,不再传递给其他的视图
}
}
这里介绍的是修改布局参数实现的,因为笔者觉得这种方法更具有广泛性且没有什么太大的缺点。使用其他的方式进行移动也可以实现,比如说layout方法,不过这可能会带来一些奇奇怪怪的问题。
- 最后,附上github上的完整代码☞:完整代码