Android View 事件体系笔记(一):View 基础属性与滑动

Android View(一).png

声明:本文内容根据《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

View参数.png
1.1.2 MotionEvent 和 TouchSlop
  1. 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 坐标

  1. TouchSlop (最小滑动)
    TouchSlop 定义系统能够识别的最小滑动距离
    是一个常量,如果手指滑动小于这个距离,系统则不认为是在滑动。不同设备上可能值不相同。
    获取 ViewConfiguration.get(getContext()).getScaledTouchSlop()
    源码目录:frameworks/base/core/res/res/values/config.xml
1.1.3 VelocityTracker 、GestureDetector 和 Scroller
  1. 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();
  1. 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) 根据需要有选择地实现 OnGestureListenerOnDoubleTapListener 中的方法。

方法名 描述 所述接口
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。

  1. 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 像素。

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
    android:fillAfter="true"
    android:zAdjustment="normal">
    <!-- android:fillAfter="true":移动完毕后保存状态 -->
    <!-- duration:时常 -->
    <!-- interpolator:动画效果:linear_interpolator 表示常量速率变化 -->
    <translate
        android:duration = "100"
        android:fromXDelta="0"
        android:fromYDelta="0"
        android:interpolator="@android:anim/linear_interpolator"
        android:toXDelta="100"
        android:toYDelta="100"/>
</set>

属性动画 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 的缓慢移动效果。

推荐阅读更多精彩内容