声明:本文内容根据《Android开发艺术探索》的思路,基于 API 26 进行总结
一、Android View基础知识
背景:
- 常用的系统控件很多时候不能满足需求,因此需要根据具体需求自定义新的控件。
- 一般都是通过继承某View来重写核心方法,重新设置属性来完成控件的自定义。
- Android手机属于移动设备,特点是通过屏幕进行一系列操作,比如滑动切换。由于不同层级的Veiw都可以响应滑动,所以就带来了滑动冲突的问题,详细了解View的事件分发机制就可以根据其特性来解决这个问题。
定义:
- View是 Android 中所有控件的基类,常用的各种控件包括RelativeLayout等都是View的子类。
- ViewGroup 可翻译为控件组,也继承了View。特点是包含一个或多个View、ViewGroup的子View同样可以是ViewGroup。
1.1 View 位置参数:
1.1.1 View 基本参数
- View的位置由它的四个顶点来决定:top、left、right、bottom。
- top:上边距离父容器(ViewGroup)距离。
public final int getTop(){ return mTop; }
left:左边距离父容器距离。
public final int getLeft(){ return mLeft; }
right:右边距离父容器距离。
public final int getRight(){ return mRight; }
bottom:下边距离父容器距离。
public final int getBottom(){ return mBottom; }
View的宽度:width = right - left;
View的高度:height = bottom - top。
- Android3.0开始增加额外参数:
x 和 y :View左上角坐标;
translationX 和 translationY:View左上角相对于父容器的偏移量(默认为 0)。
同样提供 get/set 方法,注意平移过程中 top 和 left 并不会改变,发生变化的是 x、y、translationX、translationY。
x = left + translationX
y = right + translationY
1.1.2 MotionEvent 和 TouchSlop
- MotionEvent (移动事件)
- ACTION_DOWN: 手指放下,接触屏幕
- ACTION_MOVE: 手指在屏幕移动
- ACTION_UP: 手指离开屏幕的瞬间
- 点击后离开会经历:ACTION_DOWN --> ACTION_UP
- 点击后滑动再离开:ACTION_DOWN --> ACTION_MOVE --> ACTION_MOVE ... --> ACTION_UP
通过 MotionEvent 对象可以得到点击事件发生的坐标 x 和 y 。
getX/getY: 指相对于当前 View 左上角的 x/y 坐标。
getRawX/getRawY: 相对于屏幕左上角的 x/y 坐标。
- TouchSlop (最小滑动)
TouchSlop 定义系统能够识别的最小滑动距离。
是一个常量,如果手指滑动小于这个距离,系统则不认为是在滑动。不同设备上可能值不相同。
获取ViewConfiguration.get(getContext()).getScaledTouchSlop()
源码目录:frameworks/base/core/res/res/values/config.xml
1.1.3 VelocityTracker 、GestureDetector 和 Scroller
- VelocityTracker (速度追踪)
速度追踪,用于追踪手指在滑动中的速度,包括水平和垂直的速度。步骤如下:
(1) 在 View 的 onTouchEvent 方法中追踪当前点击事件的速度:
VelocityTracker velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);
(2) 获取当前事件的速度后,获取在一定时间内,手指划过的像素
velocityTracker.computeCurrentVelocity(1000);
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();
获取速度前必须先计算速度:先调用 computeCurrentVelocity() 方法。
一定时间划过的像素数:上面参数 1000 ,通过 getXVelocity 获得的就是1000 ms 内手指划过的 x 像素值。
速度 = (终点位置 - 起点位置)/ 时间段
(3) 释放并回收内存
velocityTracker.clear();
velocityTracker.recycle();
- GestureDetector (手势检测)
用于检测用户单击、滑动、长按、双击等。使用过程:
(1) 创建 GestureDetector 对象并实现 OnGestureListener 接口,实现 OnDoubleTapListener 监听双击行为:
GestureDetector mGestureDetector = new GestureDetector(this);
// 解决长按屏幕后无法拖动
mGestureDetector.setIsLongpressEnabled(false);
(2) 在需要监听 View 的 onTouchEvent 方法中添加:
boolean consume = mGestureDetector.onTouchEvent(event);
return super.onTouchEvent(event);
(3) 根据需要有选择地实现 OnGestureListener 和 OnDoubleTapListener 中的方法。
方法名 | 描述 | 所述接口 |
---|---|---|
onDown(MotionEvent e) | 手指触摸屏幕瞬间,由一个ACTION_DOWN触发 | OnGestureListener |
onShowPress(MotionEvent e) | 手指轻触屏幕,尚未松开或拖动 | OnGestureListener |
onSingleTapUp(MotionEvent e) | 手指轻触屏幕后松开,伴随一个ACTION_UP触发,单击行为 | OnGestureListener |
onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) | 手指按下屏幕并移动,一个 ACTION_DOWN,多个ACTION_MOVE 触发,是拖动行为 | OnGestureListener |
onLongPress(MotionEvent e) | 长按行为 | OnGestureListener |
onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) | 用户触摸屏幕、快速滑动后松开,由一个 ACTION_DOWN、多个 ACTION_MOVE 和一个 ACTION_UP 触发。 | OnGestureListener |
onDoubleTap(MotionEvent e) | 双击,由两次连续的单击组成,不可能和 onSingleTapConfirmed 共存 | OnDoubleTapListener |
onSingleTapConfirmed(MotionEvent e) | 严格的单击行为,如果在一定时间内再次点击,则不会触发此方法 | OnDoubleTapListener |
onDoubleTapEvent(MotionEvent e) | 表示发生了双击行为,在此期间, ACTION_DOWN、ACTION_MOVE 和 ACTION_UP 都会触发此回调 | OnDoubleTapListener |
在实际开发中,可以不使用 GestureDetector ,完全可以在 View 的 onTouchEvent 方法中实现所需监听。如果只是监听滑动相关的,可在 onTouchEvent 实现,如果监听双击的话,用 GestureDetector。
- Scroller(弹性滑动对象)
用于实现 View 的弹性滑动。使用 View 的 scrollTo/scrollBy 方法来滑动时,过程是瞬间完成的,使用 Scroller 和 View 的 computeScroll 方法配合来完成弹性滑动。
Scroller scroller = new Scroller(getContext());
private void smoothScrollTo(int destX, int destY){
int scrollX = getScrollX();
int delta = destX - scrollX;
// 1000ms内慢慢滑动至 destX
scroller.startScroll(scrollX, 0, delta, 0, 1000);
invalidate();
}
@Override
public void computeScroll() {
if(scroller.computeScrollOffset()){
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
二、View 滑动
常见的三种方式实现 View 的滑动:
- 通过 View 本身提供的 scrollTo/scrollBy 方法。
- 通过动画给 View 施加平移效果。
- 改变 View 的 LayoutParams 使得 View 重新布局。
2.1 使用 scrollTo 和 scrollBy
/**
* 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.(使无效,作废)
* @param x the x position to scroll to
* @param y the y position to scroll to
*/
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();
}
}
}
/**
* Move the scrolled position of your view. This will cause a call to
* {@link #onScrollChanged(int, int, int, int)} and the view will be
* invalidated.
* @param x the amount of pixels to scroll by horizontally
* @param y the amount of pixels to scroll by vertically
*/
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
注意:
- scrollTo 和 scrollBy 只能改变 View 内容的位置,而不能改变 View 在布局中的位置
- mScrollX 表示 View 左边缘和 View 内容左边缘水平的距离,左边为正,右边为负,单位为像素
mScrollY 表示 View 上边缘和 View 内容上边缘垂直的距离,上边为正,下边为负,单位像素 - 也就是说,假设 View 的内容向左滑动100px, mScrollX 为 100px。View 的内容向下滑动 50px ,mScrollY 为 -50px。
2.2 使用动画
通过操作 View 的 translationX 和 translationY 属性,可以使用 View 动画(包括帧动画(Frame Animation)和补间动画(Tweened Animation))或属性动画(3.0以下需要兼容动画库 nineoldandroids)。
View 动画向 100ms 右下角平移 100 像素。
属性动画 100ms 向右平移 100 像素。
ObjectAnimator.ofFloat(new MyView(this),"translationX", 0, 100).setDuration(100).start();
设置 android:fillAfter 属性为false,View 移动后会瞬间回去,true 会保存移动状态。
使用 View 动画不会真正地改变 View 的位置参数,包括宽/高。所以 View 使用 View动画,其内的控件如 Button 位置不会改变,可事先在目标位置设置 Button,待移动完成隐藏原来 Button。
Android 3.0以上使用属性动画则没有这个问题。
2.3 改变布局参数
通过改变某 View 的 LayoutParams。
比如想使一个 Button 向右平移 100px,只需要设置其 LayoutParams 的 marginLeft 参数增加 100px即可。
还可在 Button 左边放置一个空 View,Button 需要移动时设置空 View 的宽度,在 LinearLayout 的水平方向布局里 Button 就会被挤压到右边一定的宽度。
ViewGroup.MarginLayoutParams marginLayoutParams = (ViewGroup.MarginLayoutParams) mButton.getLayoutParams();
// 宽度增加100px
marginLayoutParams.width += 100;
// 左间距增加100px
marginLayoutParams.leftMargin += 100;
// mButton应用修改
mButton.requestLayout();
//或者 mButton.setLayoutParams(marginLayoutParams);
三种移动动画特点:
- scrollTo/scrollBy: 操作简单,适合 View 内容的滑动;
- 动画:操作简单,用于没有交互或复杂动画效果的实现;
- 改变布局参数:操作稍微复杂,适用于有交互的 View。
小Demo:自定义 View 实现跟随手指在屏幕上移动
public ScreenMoveView(Context context) {
super(context);
}
// 必须实现这个构造函数
public ScreenMoveView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getRawX();
int y = (int) event.getRawY();
switch (event.getAction()){
// 手指落下
case MotionEvent.ACTION_DOWN:
break;
// 手指移动
case MotionEvent.ACTION_MOVE:
int deltaX = x - mLastX;
int deltaY = y - mLastY;
// ViewHelper 是 nineoldandroids 提供的动画兼容库,可在github下载
int translationX = (int) (ViewHelper.getTranslationX(this) + deltaX);
int translationY = (int) (ViewHelper.getTranslationY(this) + deltaY);
ViewHelper.setTranslationX(this,translationX);
ViewHelper.setTranslationY(this,translationY);
break;
// 手指抬起
case MotionEvent.ACTION_UP:
break;
default:
break;
}
mLastX = x;
mLastY = y;
// true拦截父类传递
return true;
}
三、弹性滑动
3.1 使用 Scroller 实现弹性滑动
// step1:实例化 Scroller 对象
Scroller mScroller = new Scroller(mContext);
private void smoothScrollTo(int destX, int destY){
// getScrollX获取View在屏幕上从初始点偏移的值
int scrollX = getScrollX();
int deltaX = destX - scrollX;
// step2:开始滑动 1000ms 平滑滑向destX
mScroller.startScroll(scrollX,0,deltaX,0,1000);
invalidate(); // --> 重绘 View 调用 draw 方法
}
// step3:draw方法调用该方法
@Override
public void computeScroll() {
// step4:判断是否滑动完毕
if(mScroller.computeScrollOffset()){
scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
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;
}
该函数仅仅保存了传递的几个参数,startX 和 startY 表示滑动的起点,dx 和 dy 表示的是要滑动的距离,duration 表示滑动的时间。这里的滑动是 View 内容的滑动而并非 View 本身位置的改变。
仅仅调用 startScroll 是无法让 View 进行滑动的,实际让 View 实现弹性滑动的是 invalidate(),该方法会导致 View 重绘,在 View 的 draw 方法又会调用 computeScroll 方法。computeScroll 方法是 View 的一个空实现,需要自己去实现。
原理:View 重绘 --> draw 方法调用 computeScroll --> computeScroll 向Scroller 获取当前的 scrollX 和 scrollY --> 通过 scrollTo 方法实现滑动 --> 调用 postInvalidate 方法二次重绘 --> 依然调用 computeScroll 方法 --> 继续获取 scrollX 和 scrollY 并通过 scrollTo 方法滑动到新位置直到结束。
step4: mScroller.computeScrollOffset() 源码:
public boolean computeScrollOffset() {
if (mFinished) {
return false;
}
// 滑动动画过去的时间
int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
// 滑动的时间小于设定的总滑动时间
if (timePassed < mDuration) {
switch (mMode) {
case SCROLL_MODE:
// 根据时间的流逝的百分比来算出 scrollX 和 scrollY 改变的百分比
final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
// 再根据百分比来计算出当前的值
mCurrX = mStartX + Math.round(x * mDeltaX);
mCurrY = mStartY + Math.round(x * mDeltaY);
break;
...
return true;
}
最后返回 true 表明动画还没结束需要继续滑动,false 则表明滑动完成。
Scroller 滑动原理:Scroller 配合 View 的 computeScroll 方法完成弹性滑动,该方法不断让 View 重绘,每次重绘根据时间间隔来计算出 View 当前滑动的位置并使用 scrollTo 方法完成 View 的滑动。
3.2 通过动画
final int startX = 0;
final int deltaX = 100;
ValueAnimator animator = ValueAnimator.ofInt(0,1).setDuration(1000);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float fraction = animation.getAnimatedFraction();
mButton.scrollTo(startX + (int)(deltaX * fraction),0);
}
});
animator.start();
上述代码仅仅是完成一个 1000ms 的动画,同时设定动画刷新监听,再按照比例通过 scrollTo 方法来完成某个 View 的动画。由于 scrollTo 针对的是 View 的内容而非本身,所以这里只能变动 View 内容并非本身。
3.3 使用延时策略
// msg.what
private static final int MESSAGE_SCROLL_TO = 1;
// 总共更新次数
private static final int FRAME_COUNT = 30;
// 每一次移动间隔
private static final int DELAYED_TIME = 33;
// 记录移动数量,要小于总数
private int mCount = 0;
private Handler mHandler = new Handler(){
@Override
public void handleMessage(Message msg) {
switch (msg.what){
case MESSAGE_SCROLL_TO:
mCount++;
if(mCount <= FRAME_COUNT){
// 计算当前移动比例
float fraction = mCount/(float)FRAME_COUNT;
int scrollx = (int) (fraction * 100);
mButton.scrollTo(scrollx,0);
// 再次发送消息移动
mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME);
}
break;
}
}
};
使用 Handler 或 View 的 postDelayed 方法来循环发送动画消息,来完成 View 的缓慢移动效果。