自定义控件面试题

View 的事件体系

2019-11-14  本文已影响0人  simplehych

1 View 基础知识

1.1 View 的位置参数
1.2 MotionEvent
1.3 TouchSlop
1.4 VelocityTracker
1.5 GestureDetector
1.6 Scroller

1.0 什么是View

View 是 Android 中所有控件的基类

1.1 View 的位置参数

View的位置坐标和父容器的关系

容易得出 View 的宽高和坐标的关系:

width = right - left
height = bottom - top

如何得到 View 的这四个参数呢?

在 View 的源码中它们对应四个成员变量:
mLeft
mRight
mTop
mBottom

获取方式为:
getLeft()
getRight()
getTop()
getBottom()

从 Android3.0 开始,View增加了额外的几个参数:
x
y
translationX
translationY
其中 xy 是 View 左上角相对于父容器的坐标,而translationXtranslationY 是 View 左上角相对于父容器的偏移量。

这几个参数都是相对于父容器的坐标,并且 translationX 和 translationY 的默认值是 0,和 View 的四个基本位置参数一样,View 也为它们提供了 get/set 方法。

这几个参数的换算关系如下所示:

x = mLeft + translationX;
y = mTop + translationY;
// sdk版本不同,成员变量可能不存在,但set.get方法存在
getX() = mLeft + getTranslationX();
getY() = mTop + getTranslationY();

View 在平移的过程中,mLeftmTop 表示的是原始左上角的位置信息,其值不会发生改变,此时发生改变的是 x、y、translationX 和 translationY 这四个参数。

1.2 MotionEvent

在手指接触屏幕后所产生的一系列事件中

典型的事件类型有:

典型的事件序列有:

通过 MotionEvent 对象我们可以得到点击事件发生的 xy 坐标。

两组方法:
getX() / getY():返回的是相对于当前 View 左上角的 x 和 y 坐标。
getRaw() / getRawY():返回的是相对于手机屏幕左上角的 x 和 y 坐标。

1.3 TouchSlop

TouchSlop 是系统所能识别出的被认为是滑动的最小距离

换句话说,当手指在屏幕上滑动时,如果两次滑动之间的距离小于这个常量,那么系统就不任务你是在进行滑动操作。

原因很简单:滑动的距离太短,系统不认为它是滑动的。

这是一个常量,和设备有关,不同的设备在这个值可能是不同的。

获取方式:ViewConfiguration.get(context).getScaledTouchSlop()

意义:当我们处理滑动时,可以利用这个常量做一些过滤。比如两次滑动事件的滑动距离小于这个值,我们就可以认为未达到滑动距离的临界值,因此就可以认为它们不是滑动,这样做可以有更好的用户体验。

在 ViewConfiguration 中默认值为 8 dp。

private static final int TOUCH_SLOP = 8;

同时 在 /frameworks/base/core/res/res/values/config.xml 中定义为8dp。

<!-- Base "touch slop" value used by ViewConfiguration as a
     movement threshold where scrolling should begin. -->
<dimen name="config_viewConfigurationTouchSlop">8dp</dimen>

1.4 VelocityTracker

速度追踪:用于追踪手指在滑动过程中的速度,包括水平和竖直方向的速度。

使用方式:

public boolean onTouchEvent(MotionEvent e) {
    // copy MotionEvent
    MotionEvent vtev = MotionEvent.obtain(e);
    // 1. 追踪当前事件的速度
    VelocityTracker mVelocityTracker = VelocityTracker.obatin();
    mVelocityTracker.addMovement(vtev);
    // 2. 获取速度前,先计算速度
    mVelocityTracker.computeCurrentVelocity(1000);
    // 3. 获取速度
    float xvel = mVelocityTracker.getXVelocity();
    float yvel = mVelocityTracker.getYVelocity();
    // 4. 清空
    mVelocityTracker.clear();
    vtev.recycle();
}
  1. 在View的 onTouchEvent 方法中追踪当前单击事件的速度
  2. 计算速度,这里指一段时间内手指所滑过的像素数。方法参数单位是ms。比如将时间间隔设为 1000ms时,在 1s 内,手指在水平方向从左向右滑过100像素,那么水平速度就是 100像素/s。另注意速度可为负数,从右向左滑动时,水平方向速度即为负值。

1.4 GestureDetector

手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。


protected void onFinishInflate() {
    // 1. 在适当的位置初始化 或者  onAttachedToWindow 等
    mGestureDetector = new GestureDetector(mContext, new SimpleOnGestureListener() {
        @Override
        public boolean onSingleTapUp(MotionEvent e) {
            return false;
        }
    });
    //解决长按屏幕无法托送的现象
    mGestureDetector.setIsLongpressEnabled(false);
}

public boolean onTouchEvent(MotionEvent event) {
    // Give everything to the gesture detector
    // 2. 接管目标 View 的 onTouchEvent 方法
    boolean retValue = mGestureDetector.onTouchEvent(event);
    return retValue; // or return true;
}
  1. 创建GestureDetector对象并实现 onGestureListener 接口,根据需要还可以实现 onDoubleTapListener 从而能够监听双击行为。
  2. 接管目标View的onTouchEvent方法。

SimpleOnGestureListener 同时实现了 OnGestureListener,OnDoubleTapListener,OnContextClickListener 三个接口,可提供使用。

1.5 Scroller

弹性滑动对象,用于实现 View 的弹性滑动。

当使用 View 的 scrollTo / scrollBy 方法来进行滑动时,其过程是瞬间完全的,没有过渡效果的滑动用户体验不好。

而Scroller实现有过渡效果,是在一定的时间间隔内完成的,不是瞬间完成的。

Scroller 本身无法让View弹性滑动,它需要和View的computeScroll方法配合使用才能完成这个功能。

固定代码使用方法:

mScroller = new Scroller(mContext);

// 缓慢滑动到指定位置
private void smoothScrollTo(int dstX, int dstY) {
    int scrollX = getScrollX();
    int delta = dstX - scrollX;
    
    mScroller.startScroll(scrollX, 0, delta, 0, 1000);
    invalidate();
}

@Override
public void computeScroll() {
    super.computeScroll();
    if (mScroller.computeScrollOffset()) {
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidate();
    }
}

2 View 的滑动

  1. View 本身提供的 scrollTo / scrollBy
  2. 动画施加平移效果
  3. 改变LayoutParams重新布局

2.1 scrollTo / scrollBy

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();
        }
    }
}

public void scrollBy(int x, int y) {
    scrollTo(mScrollX + x, mScrollY + y);
}

scrollTo:基于所传递参数的绝对滑动。
scrollBy:基于当前位置的相对滑动。

scrollBy 实际上调用了 scrollTo 方法,内部通过 mScrollXmScrollY 来改变规则,这两个属性可以通过 getScrollX()getScrollY 方法分别得到。

说明:在滑动过程中,mScrollX 的值总是等于 View 左边缘View 内容左边缘在水平方向的距离;mScrollY同理。

scrollTo 和 scrollBy 只能改变 View内容的位置,而不能改变 View 在布局中的位置。

2.2 动画

通过平移动画移动View,主要是操作 View 的 translationX 和 translationY 属性。

既可以采用 View动画,也可以采用属性动画

<translate
    android:duration="100"
    android:fromXDelta="0"
    android:fromYDelta="0"
    android:toXDelta="100"
    android:toYDelta="100"
/>

Animation animation = AnimationUtils.loadAnimation(this, R.anim.trans);
targetView.startAnimation(animation);
ObjectAnimator.ofFloat(targetView, "translationX", 0, 100).setDuration(100).start();

View动画是对View的映像做操作,并不能真正改变View的位置参数,包括宽和高。如果希望动画后的状态得以保留还必须将fillAfter属性设置为true,否则View会瞬间恢复到初始状态。这导致button点击事件只能在原先位置,而动画后的状态位置无效。

2.3 LayoutParams

改变布局参数,即改变 LayoutParams。

MarginLayoutParams params = button.getLayoutParams();
params.width += 100;
params.leftMargin += 100;
button.requestLayout(); // 或 button.setLayoutParams(params);

2.4 比较

3 弹性滑动

3.1 Scroller

工作原理:
当我们构造一个Scroller对象,并且调用它的startScroll方法时,Scroller内部其实什么也没做,它只是保存了我们传递的几个参数。

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内容的滑动而非View本身位置的改变。

可以看到,仅仅调用startScroll方法是无法让View滑动的,因为它内部并没做滑动相关的事。

滑动依靠 startScroll 方法下面的 invalidate 方法。导致 View 重绘,View 的draw方法又会去调用 computeScroll 方法,computeScroll 方法在 View 中是一个空实现。自己实现 computeScroll 方法才能实现弹性滑动。而computeScroll又会去向Scroller 获取当前的 scrollX 和 scrollY;然后通过 scrollTo方法实现滑动;接着又调用 postInvalidate 方法进行第二次重绘。如此反复。

查看 computeScrollOffset 方法

public boolean computeScrollOffset() {
    if (mFinished) {
        return false;
    }

    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;
        ...
        }
    }
    else {
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

这个方法会根据时间流逝的百分比来计算当前的scrollX 和 scrollY 的值。和动画中的差值器类似。

总结:Scroller的设计思想,整个过程它对 View 没有丝毫的引用,甚至它内部连计时器都没有。

Scroller 本身并不能实现 View 的滑动,需要配合 View 的 computeScroll 方法才能完成弹性滑动的效果,它不断的让View重绘,而每一次重绘都有一个时间间隔,通过时间间隔Scroller就可以得出View当前的滑动位置,然后同坐scrollTo方法来完成View的滑动。
就这样,View的每一次重绘都会导致View进行小幅度的滑动,而多次的小幅度滑动就组成了弹性滑动。

3.2 动画

动画本身就是一种渐近的过程,因此通过它来实现的滑动天然就有弹性效果。

ObjectAnimator.ofFloat(textView, "translationX", 0, 100)
            .setDuration(100)
            .start();

上述代码,可以让一个View本身在1000ms内向左移动100像素。

而scrollTo只能移动 View内容

可以利用动画的特性模仿Scroller效果:

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 anim) {
        float fraction = anim.getAnimatedFraction();
        textView.scrollTo(startX + (int) (deltaX * fraction), 0);
    }
});
animator.start();
移动View本身 移动View内容 scrollTo

3.3 使用延时策略

核心思想:通过发送一系列延时消息从而达到一种渐近式的效果。

具体执行:

  1. Handler
  2. View的postDelayed方法
  3. 线程的sleep方法

以 Handler 为例:

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);
                    textView.scrollTo(scrollX, 0);
                    mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO, DELAYED_TIME);
                }
            }
            default:
                break;
        }
    }
};

4 View 事件分发机制

4.1 点击事件的传递规则
4.2 事件分发的源码解析

4.1 点击事件的传递规则

分析的对象:MotionEvent

所谓点击事件分发,其实就是对MotionEvent事件的分发过程,即当一个MotionEvent产生了以后,系统需要把这个事件传递给一个具体的View,而这个传递的过程就是分发过程

分发过程由三个重要的方法共同完成:

  1. dispatchTouchEvent

事件分发的起始方法。如果事件能够传递给当前View,那么此方法一定会被调用。

返回结果当前 View 的 onTouchEvent方法下级View的dispatchTouchEvent方法 的影响,表示是否消耗当前事件。

  1. onInterceptTouchEvent

在 dispatchTouchEvent 方法内部调用,用来判断是否拦截某个事件,如果当前View拦截了某个事件,那么在同一个事件序列中,此方法不会被再次调用,返回结果表示是否拦截当前事件。

  1. onTouchEvent

在 dispatchTouchEvent 方法内部调用,用来处理点击事件,返回结果表示是否消耗当前事件,如果不消耗,则在同一个事件序列中,当前View无法再次接收到事件。

上述三个方法的关系的伪代码:

public boolean dispatchTouchEvent(MotionEvent ev){
    boolean consume = false;
    if (onInterceptTouchEvent(ev)) {
        consume = onTouchEvent(ev);
    }else {
        consume = child.dispatchTouchEvent(ev);
    }
    return consume;
}

另:OnTouchListener#onTouch 的返回值。

当一个View需要处理事件时,如果它设置了OnTouchListener,那么OnTouchListener中的onTouch方法会被调用。如果onTouch返回值false,表示没有消费,则这个View的onTouchEvent方法会被调用;否则不会。

结论:给View设置的OnTouchListener,其优先级比onTouchEvent要高。

另:OnClickListener#oClick没有返回值。

在onTouchEvent方法中,如果View设置了OnClickListener,那么onClick会被调用。

结论:平时常用的onClickListener优先级最低,即处于事件传递的尾端。

传递过程顺序:Activity -> Window -> View

假如:一个View的onTouchEvent返回false,那么它的父容器的onTouchEvent就会被调用,依次类推。最终Activity的onTouchEvent方法将会被调用。

相关结论:

4.2 事件分发的源码解析

4.2.1 Activity对点击事件的分发过程

Activity#dispatchTouchEvent -> PhoneWidow#superDispatchTouchEvent -> DecorView#superDispatchTouchEvent -> ViewGroup#dispatchTouchEvent

4.2.2 顶级View对点击事件的分发过程

ViewGroup
public boolean dispatchTouchEvent(MotionEvent ev){
    boolean handled = false;

    // Handle an initial down
    if (actionMasked == MotionEvent.ACTION_DOWN) {
        mGroupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }

    // Check for interception
    boolean intercepted;
    if (actionMasked == MotionEvent.ACTION_DOWN || mFirstTouchTarget != null) {
        boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
        if (!disallowIntercept) {
            intercepted = onInterceptTouchEvent(ev);
        }else {
            intercepted = false;
        }
        
    }else {
        intercepted = false;
    }

    for (int i = childrenCount - 1; i >= 0; i--) {
        if (child.dispatchTouchEvent(ev) {
            // mFirstTouchTarget 是一个单链表结构
            mFirstTouchTarget = TouchTarget.obtain(child);
            break;
        }
    }
    if (mFirstTouchTarget==null) {
        handled = View.dispatchTouchEvent(ev);
    }

    return handled;
}



View
public boolean dispatchTouchEvent(MotionEvent event) {
    boolean result = false;
    if (mOnTouchListener != null && mOnTouchListener.onTouch(view, event)) {
        result = true;
    }
    if (!result && onTouch(event)) {
        result = true;
    }
    return true;
}

View 
public boolean onTouchEvent(MotionEvent event) {
    boolean clickable = (viewFlags == CLICKABLE) || (viewFlags == LONG_CLICKABLE);
    
    if (viewFlags == DISABLED) {
        // A disabled view that is clickable still consumes the touch event
        // it just doesn't resond to them
        return clickable;
    }    

    // 类似onTouchListener
    if (mTouchDelegate != null) {
        if (mTouchDelegate.onTouchEvent(event)) {
            return true;
        }
    }

    if (clickable) {
        switch(event.getAction()){
            case MotionEvent.ACTION_UP:
                ...
                preformClick();
                ...
                break;
        }
        return true;
    }
    return false;
}

5 View 滑动冲突

5.1 常见的滑动冲突场景:

  1. 外部滑动方向和内部滑动方向不一致
  2. 外部滑动方向和内部滑动方向一致
  3. 上述两种情况嵌套

本质上来说,这三种滑动冲突场景的复杂度是相同的,区别仅仅是滑动策略的不同。

5.2 滑动冲突处理规则

  1. 滑动的角度、距离差、速度差
  2. 业务处理规则

5.3 滑动冲突的解决方式

5.3.1 外部拦截法

父容器拦截处理;
重写父容器的 onInterceptTouchEvent 方法,做相应的拦截即可;

伪代码如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercepted = false;

    int x = (int) event.getX();
    int y = (int) event.getY();

    switch(event.getAction()){
        case MotionEvent.ACTION_DOWN:
            intercepted = false;
            break;
        case MotionEvent.ACTION_MOVE:
            if (???) {// true or false
                intercepted = true;
            }else {
                intercepted = false;
            }
            break;
        case MotionEvent.ACTION_UP:
            intercepted = false;
            break;
        default:
            break;
    }

    mLastXIntercept = x;
    mLastYIntercept = y;
    return intercepted;
}

只需要在条件 ??? 处进行修改即可,其他均不需要修改并且不能修改。

  1. 在ACITION_DOWN事件中,必须返回false,不能拦截ACTION_DOWN事件。一旦父容器拦截了ACTION_DOWN,导致mFirstTouchTarget==null,那么后续的ACTION_MOVE和ACTION_UP事件都会直接交由父容器处理,不能传递给子元素。

  2. 在ACTION_MOVE事件中,这个事件可以根据需求来决定是否拦截,如果父容器需要拦截就返回true,否则返回false;

  3. 在ACTION_UP事件中,必须返回false,因为ACTION_UP事件本身没有太多意义。考虑一种情况,假设事件交由子元素处理,如果父容器在ACTION_MOVE时返回了true,就会导致子元素无法接收到ACTION_UP事件,这个时候子元素中的 onClick事件就无法触发,父容器比较特殊,一旦它开始拦截任何一个事件,那么后续的事件都会交给它处理,即便在ACTION_UP时返回了false。比如Boss拦截了你一个任务,最近都不会交给你任何任务。

5.3.2 内部拦截法

父容器不拦截任何事件,所有的事件传递给子元素,如果子元素需要此事就直接消费掉,否则交由父容器处理。

这种方法和Android中的事件分发机制不一致,需要配合 requestDisallowInterceptTouchEvent方法才能正常工作。

重写子元素的 dispatchTouchEvent方法
重写父容器的onInterceptTouchEvent方法

伪代码如下:

// 子元素
public boolean dispatchTouchEvent(MotionEvent event) {
    int x = (int) event.getX();
    int y = (int) event.getY();

    switch(event.getAction()){
        case MotionEvent.ACTION_DOWN:
            parent.requestDisableInterceptTouchEvent(true);
            break;
        case MotionEvent.ACTION_MOVE:
            int deltaX = x - mLastX;
            int delatY = y - mLastY;
            if (???) {
                parent.requestDisableInterceptTouchEvent(false);
            }
            break;
        case MotionEvent.ACTION_UP:
            break;
        default:
            break;
    }

    mLastX = x;
    mLastY = y;

    return super.dispatchTouchEvent(event);
}

上述代码是内部拦截法的典型代码,当面对不同的滑动策略时只需要修改里面的???条件即可,其他不需要改动且不能改动。

除了子元素需要做处理以外,父元素也要默认拦截除了ACTION_DOWN以外的其他事件,这样子元素调用 parent.requestDisableInterceptTouchEvent(false)方法时,父元素才能继续拦截所需的事件。

为什么父容器不能拦截ACTION_DOWN事件呢?那是因为ACTION_DOWN事件并不受FLAG_DISALLOW_INTERCEPT这个标记位控制,所以一旦父容器拦截ACTION_DOWN事件,那么所有的事件都无法传递到子元素中去,这样内部拦截就无法起作用了。

父容器的修改如下:

public boolean onInterceptTouchEvent(MotionEvent event) {
    int action = event.getAction();
    if (action == MotionEvent.ACTION_DOWN) {
        return false;
    }
    return true;
}

参考资料

感谢《Android开发艺术探索》文章作者,第3章 View的事件体系

上一篇 下一篇

猜你喜欢

热点阅读