Android触摸事件(二)-MoveAndScaleTouchHelper,触摸辅助工具类

目录

相关文章

AbsTouchEventHandle触摸事件类
此类为以上提及类的辅助工具类

概述

此类的主要作用是封装了一些触摸事件需要常用的操作,只需要实现简单的接口即可以使用.实现操作如下:

  • 界面拖动(基于单点触摸的移动事件)
  • 界面的缩放(基于两点触摸的移动事件)

此类只是一个辅助工具类,并不是必须的也不需要继承此类就可以使用.

此类基于AbsTouchEventHandle类回调的事件基础,需要AbsTouchEventHandle先处理基本的触摸事件,再通过此辅助类实现拖动的操作


关于更新

2016-06-20

由于抽象类AbsTouchEventHandle修改为非抽象类的helper辅助类+接口回调的形式,重新命名为TouchEventHelper(修改的原因是为了扩大其可用的范围),如原本抽象类在Activity中是不方便使用的,因为Activity可能需要继承自其它的Activity,修改为helper类型即可在activity对应方法中调用方法即可,更加方便.
TouchEventUtils重新命名为MoveAndScaleTouchHelper,仅处理移动与缩放的工具.功能上并没有变化.使用方式如旧.


基本数据

不管是拖动还是缩放,本质是基于坐标数据的,所以坐标数据的记录是必然的,因此需要记录的坐标数据如下:

//单点触摸拖动
//按下事件的坐标
private float mDownX = 0f;
private float mDownY = 0f;
//抬起事件的坐标
private float mUpX = 0f;
private float mUpY = 0f;

//多点触摸缩放
//多点触控缩放按下坐标
private float mScaleFirstDownX = 0f;
private float mScaleFirstDownY = 0f;
private float mScaleSecondDownX = 0f;
private float mScaleSecondDownY = 0f;
//多点触摸缩放抬起的坐标
private float mScaleFirstUpX = 0f;
private float mScaleFirstUpY = 0f;
private float mScaleSecondUpX = 0f;
private float mScaleSecondUpY = 0f;

关于拖动

原理

拖动的原理比较好理解,主要是通过跟随触摸点坐标的变化而达到拖动的效果

实现拖动的方式有多种,这里使用的方式为原数据与变动数据分开操作的方式.因此需要保存界面偏移量的变量.

//记录X轴方向的偏移量
float mDrawOffsetX;
//记录Y轴方向的偏移量
float mDrawOffsetY;

实现过程

关键变量定义

移动偏移量:MOVE 事件结束后新坐标相对于 DOWN 事件时坐标的偏移量
原始偏移量:界面在 DOWN 事件发生之前已经存在的偏移量
实际偏移量:以界面最初加载的状态为原始状态,任何时候对原始状态的偏移量都为界面当前的实际偏移量,原始偏移量即为上一次的实际偏移量(产生的移动偏移量将使此值变化)

每一次ACTION_MOVE事件会造成界面偏移,产生一次偏移量.在ACTION_MOVE事件结束后将本次产生的移动偏移量原始偏移量合并,生成新的界面偏移量,此偏移量即为界面实际的偏移量.
具体过程如下:

  • 单点按下时,记录当前触摸点坐标数据 downX,downY
  • 单点移动时,记录每一次移动后的新触摸点的坐标数据 moveX,moveY
  • 进入拖动界面偏移量计算工作
    • 计算移动后位置与按下时位置的偏移量 moveX-downX,moveY-downY
    • 计算新的界面实际偏移量(原始偏移量+移动偏移量)
    • 保存新的实际偏移量即可
  • 重绘界面

整个拖动过程改变的只是界面的偏移变量,界面原始的任何坐标数据不会被改变,因此绘制界面是实际的绘制方式应该是:

//设绘制元素的原始坐标为 X,Y; 实际偏移量为 offsetX,offsetY
//绘制该元素
draw(X+offsetX, Y+offsetY);

由此可确定,偏移量是存在正负值的,正负值表示相反方向的偏移;而 mBDrawOffsetXmDrawOffsetY原始值必定为0


事件处理回调

理论上界面的拖动是无任何限制的,但是在实际的操作中,存在一些实际的要求导致界面的拖动范围存在限制.界面的拖动是实际上是为了显示不在屏幕中的其它元素,当拖动超过一定范围时,屏幕中将不存在任何元素,偏移范围已经远远超过元素所在的坐标位置,这是没有意义的.

在界面拖动时,将提供对应的回调接口,用以确定界面是否可以拖动.当界面确认可以拖动时,才进行重绘工作,当界面不允许拖动时,只会保留最后一次ACTION_MOVE事件中可重绘的界面.

对外的回调接口如下:

/**
 * 移动事件处理接口
 */
public interface IMoveEvent {

    /**
     * 是否可以实现X轴的移动
     *
     * @param moveDistanceX 当次X轴的移动距离(可正可负)
     * @param newOffsetX    新的X轴偏移量(若允许移动的情况下,此值实际上即为上一次偏移量加上当次的移动距离)
     * @return
     */
    public abstract boolean isCanMovedOnX(float moveDistanceX, float newOffsetX);

    /**
     * 是否可以实现Y轴的移动
     *
     * @param moveDistacneY 当次Y轴的移动距离(可正可负)
     * @param newOffsetY    新的Y轴偏移量(若允许移动的情况下,此值实际上即为上一次偏移量加上当次的移动距离)
     * @return
     */
    public abstract boolean isCanMovedOnY(float moveDistacneY, float newOffsetY);

    /**
     * 移动事件
     *
     * @param suggestEventAction 建议处理的事件,值可能为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP}
     * @return
     */
    public abstract void onMove(int suggestEventAction);

    /**
     * 无法进行移动事件
     *
     * @param suggetEventAction 建议处理的事件,值可能为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP}
     */
    public abstract void onMoveFail(int suggetEventAction);
}

其中除了确认是否可拖动的回调接口,还包括了移动前的回调接口onMove(),无法移动时(可能由于条件不允许等)的回调接口onMoveFail()


偏移量计算

对于偏移量计算规则,上面已经提过了.这里需要补充的是另外几个要点.
首先,偏移量的计算在整个MOVE事件中是一直都在发生的,因为每隔一段时间ACTION_MOVE事件就会触发一次,每触发一次回调则会计算一次偏移量(但不一定会重绘);

每次偏移量的计算都是以 DOWN 事件按下时的坐标为基础的,这说明了每次计算得到的移动偏移量是相对ACTION_DOWN时坐标的偏移量.

其次,由于ACTION_MOVE事件的触发是不稳定的,即使按住坐标没有改变还是会被触发(猜测是每隔一段时间就会触发,而不是基于坐标的变化,原理没有细究确定).一些移动中可能会产生十几甚至几十次ACTION_MOVE事件,而且某些情况下偏移量会非常小(仅有1-2个像素也有可能),所以做了一些的排除措施.

当偏移量达到一定的数值时(计算偏移量的绝对值,因为偏移量可能是负值),才进行一次界面的重绘,以此减少重绘的次数

最后,在整个ACTION_MOVE事件时,每一次有效的偏移量改变时,才会记录到实际偏移量中,之后界面会被重新绘制.
根据以上的流程,我们需要记录的偏移量变量包括:

//任何时候绘制需要的偏移量
protected float mDrawOffsetY = 0f;
protected float mDrawOffsetX = 0f;
//移动过程中临时保存的移动前的偏移量
protected float mTempDrawOffsetX = 0f;
protected float mTempDrawOffsetY = 0f;

其中mDrawOffset变量是任何时候绘制界面时使用的偏移量(不管在ACTION_MOVE事件中还是ACTION_UP); 而mTempDrawOffset则是在ACTION_MOVE移动时保存的移动前的偏移量,用以计算某次移动事件之后的实际偏移量


实现

/**
 * 根据移动的距离计算是否重新绘制
 *
 * @param moveDistanceX    X轴方向的移动距离(可负值)
 * @param moveDistanceY    Y轴方向的移动距离(可负值)
 * @param invalidateAction 重绘的行为标志
 */
private boolean invalidateInSinglePoint(float moveDistanceX, float moveDistanceY, int invalidateAction) {
    if (mMoveEvent == null) {
        return false;
    }
    //此处做大于5的判断是为了避免在检测单击事件时
    //有可能会有很微小的变动,避免这种情况下依然会造成移动的效果
    if (Math.abs(moveDistanceX) > 5 || Math.abs(moveDistanceY) > 5 || invalidateAction == MotionEvent.ACTION_UP) {
        //新的偏移量
        float newDrawOffsetX = mTempDrawOffsetX + moveDistanceX;
        float newDrawOffsetY = mTempDrawOffsetY + moveDistanceY;

        //当前绘制的最左边边界坐标大于0(即边界已经显示在屏幕上时),且移动方向为向右移动
        if (!mMoveEvent.isCanMovedOnX(moveDistanceX, newDrawOffsetX)) {
            //保持原来的偏移量不变
            newDrawOffsetX = mDrawOffsetX;
        }
        //当前绘制的顶端坐标大于0且移动方向为向下移动
        if (!mMoveEvent.isCanMovedOnY(moveDistanceY, newDrawOffsetY)) {
            //保持原来的Y轴偏移量
            newDrawOffsetY = mDrawOffsetY;
        }

        //其它情况正常移动重绘
        //当距离确实有效地改变了再进行重绘制,否则原界面不变,减少重绘的次数
        if (newDrawOffsetX != mDrawOffsetX || newDrawOffsetY != mDrawOffsetY || invalidateAction == MotionEvent.ACTION_UP) {
            mDrawOffsetX = newDrawOffsetX;
            mDrawOffsetY = newDrawOffsetY;
            //抬起事件时,回调为
            if (invalidateAction == MotionEvent.ACTION_UP) {
                //将此次的新偏移量保存为临时数据后续拖动时可使用
                mTempDrawOffsetX = mDrawOffsetX;
                mTempDrawOffsetY = mDrawOffsetY;
            }
            mMoveEvent.onMove(invalidateAction);
            return true;
        } else {
            mMoveEvent.onMoveFail(invalidateAction);
        }
    }
    return false;
}

计算新偏移量之后,通过回调接口确定是否可以进行移动;再将产生变化的偏移量保存到绘制使用的变量中,通知移动事件触发,完成移动操作.
其中回调的onMove()事件基本上不需要任何处理,直接通知自定义View重绘即可view.postInvalidate()

绘制时view的onDraw()事件中,需要将偏移量添加到绘制的坐标上再绘制(原始元素不保存偏移量,偏移量是独立存在的)


关于缩放

原理

缩放基于两点触摸事件,两个不同的点坐标不断变化从而转化成界面的缩放.
需要确定的是变化的坐标与界面缩放之间的关系.两点坐标之间的变化可以是:

  • 两点之间的直线距离变化
  • 两点之间的直线距离平方(及其它类似衍生)等

我们需要确定的到底用什么计算方式与界面缩放合并比较.在这里使用的是两点之间的直线距离变化.因为这个变化与触摸点的坐标变化是一致的,变化速率是相同的.以这个这基准不会造成界面缩放的程度太快或者太慢.
界面缩放的原则:

以触摸点按下时两点之间的距离为原始缩放值,对应当前界面大小;
以触摸点(移动时)抬起时两点之间的距离/原始缩放值 = 缩放比例,界面的缩放基于此比例.


实现过程

缩放比例计算方法
//计算缩放的比例
//参数为:按下坐标,抬起坐标(均为两点坐标)
public static float getScaleRate(float firstDownX, float firstDownY, float secondDownX, float secondDownY,
                                 float firstUpX, float firstUpY, float secondUpX, float secondUpY) {
    //计算平方和
    double downDistance = Math.pow(Math.abs((firstDownX - secondDownX)), 2) + Math.pow(Math.abs(firstDownY - secondDownY), 2);
    double upDistance = Math.pow(Math.abs((firstUpX - secondUpX)), 2) + Math.pow(Math.abs(firstUpY - secondUpY), 2);
    //计算比例
    double newRate = Math.sqrt(upDistance) / Math.sqrt(downDistance);
    //计算与上一个比例的差
    //差值超过阀值则使用该比例,否则返回原比例
    if (newRate > 0.02 && newRate < 10) {
        //保存当前的缩放比为上一次的缩放比
        return (float) newRate;
    }
    return 1;
}

以上计算中使用到一个阀值,当计算出来的比例太过偏远,太小(< 0.02)或者太大(> 10)时,都不使用此缩放比.这是因为实际的放大中,除非屏幕足够大,否则不可以拖动到两点触摸为原始值的10倍大小;而小于0.02时,这个缩小比例足够小了,小得基本不太可能发生,同时缩放到这个程度可能界面元素也不可见了.
当然以上只是假设,满足一般的需求.也可以不使用此处排除一些极端的比例.
需要注意的是:

每一次缩放是都是当前触摸点的坐标与ACTION_POINTER_DOWN按下时坐标的直线距离.
而不是每一次缩放以上一次界面为基准的缩放(即down坐标不会被更新)

这是因为按下时坐标间的距离为界面的原始比例1,所以界面的缩放是基于原界面比例的,这样缩放时界面变化才均匀而且正常.
其次是这样情况相比于每一次缩放以上一次界面为基准的缩放方式而言,可以避免当每两个触摸点之间的移动距离很小时,实际计算过程也是当前触摸点坐标与按下时坐标距离,不会造成数值非常小而导致计算不正常或者不利于缩放(当缩放的值太大或者太小时都容易会现大误差)


事件处理回调

界面缩放在理论上与拖动一样是可以无限制的,但是实际上并不是这样的.因为界面可能存在各种各样的元素(文字图片等),从计算机的角度,文字渲染会有一个最大的上限,尝试过文字放到超过4096 PX的时候,就会消失.因为系统无法渲染如此大像素的文字.
除此之外,每一个缩放总存在一定的现实意义背景.而一般允许用户缩放但也不会提供无限制缩放功能,所以会设置上下限(缩放比范围为:0.XX - XX.0),这也是比较符合实际意义的.
所以提供了一个缩放事件的回调.可以通过回调确定是否允许缩放,缩放操作与缩放失败时的操作.

/**
 * 缩放事件处理接口
 */
public interface IScaleEvent {

    /**
     * 是否允许进行缩放
     *
     * @param newScaleRate 新的缩放比例值,请注意该值为当前值与缩放前的值的比例,即<font color="#ff9900"><b>在move期间,
     *                     此值都是相对于move事件之前的down的坐标计算出来的,并不是上一次move结果的比例</b></font>,建议
     *                     修改缩放值或存储缩放值在move事件中不要处理,在up事件中处理会比较好,防止每一次都重新存储数据,可能
     *                     造成数据的大量读写而失去准确性
     * @return
     */
    public abstract boolean isCanScale(float newScaleRate);

    /**
     * 设置缩放的比例(存储值),<font color="#ff9900"><b>当up事件中,且当前不允许缩放,此值将会返回最后一次在move中允许缩放的比例值,
     * 此方式保证了在处理up事件中,可以将最后一次缩放的比例显示出来,而不至于结束up事件不存储数据导致界面回到缩放前或者之前某个状态</b></font>
     *
     * @param newScaleRate     新的缩放比例
     * @param isNeedStoreValue 是否需要存储比例,此值仅为建议值;当move事件中,此值为false,当true事件中,此值为true;不管当前
     *                         up事件中是否允许缩放,此值都为true;
     */
    public abstract void setScaleRate(float newScaleRate, boolean isNeedStoreValue);

    /**
     * 缩放事件
     *
     * @param suggestEventAction 建议处理的事件,值可能为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP}
     */
    public abstract void onScale(int suggestEventAction);

    /**
     * 无法进行缩放事件,可能某些条件不满足,如不允许缩放等
     *
     * @param suggetEventAction 建议处理的事件,值可能为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP}
     */
    public abstract void onScaleFail(int suggetEventAction);
}

变量定义

由于缩放时是以ACTION_POINTER_DOWN为坐标为基准,任何时候的ACTION_MOVEACTION_POINTER_UP事件中的缩放操作都是该事件中的触摸点坐标与DOWN坐标中进行距离计算得到缩放比.

其中缩放比为1时界面不需要缩放;

同时,由于每次缩放时都是基于DOWN事件的坐标,所以每 一次缩放时的缩放比都是临时(永远不会知道会不会存在下一个缩放比,取决于用户是否保持缩放操作).
但致少我们需要确定的是:

ACTION_POINTER_UP事件中肯定需要处理并保存缩放比.因为这是一个缩放事件的最终事件

所以存在这样一个变量:

//是否需要保存当前的缩放比
//见回调接口
boolean isNeedStoreValue;

在缩放时,我们需要处理的一个额外的操作是:

//检测当前操作是否 ACTION_POINTER_UP
//若是说明需要保存缩放比
boolean isTrueSetValue = invalidateAction == MotionEvent.ACTION_POINTER_UP;
//若不是(不需要保存缩放比时)且缩放比为1,直接返回不进行缩放操作
if (newScaleRate == 1 && !isTrueSetValue) {
 return;
}

缩放流程

缩放过程是基于缩放比例的计算的.以下均假设缩放比例已计算完毕.
在多点触摸的事件中,ACTION_POINTER_DOWN事件明显只记录按下的坐标,不需要进行任何缩放操作.缩放操作主要在ACTION_POINTER_MOVEACTION_POINTER_UP事件中.

其中ACTION_POINTER_MOVE是主要缩放过程,而ACTION_POINTER_UP只是最记录最终的缩放数据及处理结尾工作.

缩放流程主要如下:

  • 计算缩放比例
  • 检测当前缩放的状态(MOVE事件还是UP事件)
  • 检测当前缩放比例有效性(若缩放比例为1,则不需要缩放,直接返回不缩放)
  • 根据缩放状态及缩放比例确定是否缩放
  • 若需要缩放,通过回调事件确认是否允许缩放
  • 缩放

以下为缩放操作:

//缩放操作,参数2为当前触摸事件标识
private void invalidateInMultiPoint(float newScaleRate, int invalidateAction) {
    //缩放回调事件不存在时,直接不进行任何操作
    if (mScaleEvent == null) {
        return;
    }
    //若缩放比为1且不为缩放的最终事件时,不进行重绘,防止反复多次地重绘..
    //如果是最后一次(up事件),必定重绘并记录缩放比
    boolean isCanScale = false;
    boolean isTrueSetValue = invalidateAction == MotionEvent.ACTION_POINTER_UP;
    if (newScaleRate == 1 && !isTrueSetValue) {
        return;
    }

        //回调缩放事件接口,是否允许缩放
    if (mScaleEvent.isCanScale(newScaleRate)) {
        //进行缩放,更新最后一次缩放比例为当前值
        mLastScaleRate = newScaleRate;
        isCanScale = true;
    }

    if (isTrueSetValue) {
        //若缩放比不合法且当前缩放为最后一次缩放(up事件),则将上一次的缩放比作为此次的缩放比,用于记录数据
        //此处不作此操作会导致在缩放的时候达到最大值后放手,再次缩放会在最开始的时候复用上一次缩放的结果(因为没有保存当前缩放值,有闪屏的效果...)
        newScaleRate = mLastScaleRate;
        //将最后一次的缩放比设为1(缩放事件已经终止,所以比例使用1)
        mLastScaleRate = 1;
        //最后一次必须缩放并保存值
        isCanScale = true;
    }
    if (!isCanScale) {
        //通知无法进行缩放
        mScaleEvent.onScaleFail(invalidateAction);
        return;
    }
    //更新缩放数据
    mScaleEvent.setScaleRate(newScaleRate, isTrueSetValue);
    //缩放回调
    mScaleEvent.onScale(invalidateAction);
}

以上缩放处理是先进行是否允许缩放的事件回调isCanScale(),然后再处理是否保存当前值.

当需要保存当前值时,不管是否允许缩放都会处理缩放事件,不过缩放比例会被处理到最后一次缩放比例.

如果不允许缩放直接回调缩放失败事件onScaleFail(),并直接返回.因为已经缩放失败了,也不再需要处理任何的事件.
若允许缩放,则进行缩放比例的设置setScaleRate(),再回调缩放事件onScale()
相比于之前的版本,此处更新的是缩放比例的设置是调整了位置.此前不管是否缩放成功都会进行缩放比例的调整.当然后来证实这是不正确的.


关于辅助功能

在源码中,拖动操作中设置了一个辅助性的功能(此功能有点鸡肋,可以忽略不影响).
由于拖动的位置分为拖动前/拖动后,且是分开为两部分变量保存的.所以可以适当地添加第三部分变量用于存放上一次拖放结果的坐标,即上一次的拖放位置.在必要的时候可以将当前的位置恢复到上一次的位置;
但由于保存上一次位置坐标只有一次,所以也只能恢复一次.(多次恢复是无效的)

//上一次移动后保存的偏移量
protected float mLastDrawOffsetX = 0f;
protected float mLastDrawOffsetY = 0f;

由前面拖动实现代码中可以发现,在ACTION_UP中将保存当前实际偏移量到临时的变量中,而临时变量中的即为上一次移动后保存的偏移量.

只需要在ACTION_UP事件处理中,将mTempDrawOffset(临时变量)保存到mLastDrawOffset(上一次移动后的偏移量)中,再将mDrawOffset(当前实际偏移量)保存到mTempDrawOffset(临时偏移量)中即可

这样实际上是用两组变量来存放当前的偏移量及上一次移动偏移量,最后一组变量则是任何时候的绘制使用的变量(包括拖动期间及拖动完成等状态)
所以该部分的实现代码将修改成以下形式:

//抬起事件时
if (invalidateAction == MotionEvent.ACTION_UP) {
    //保存上一次的偏移量(新增)
    mLastDrawOffsetX = mTempDrawOffsetX;
    mLastDrawOffsetY = mTempDrawOffsetY;
    //将此次的新偏移量保存为临时数据后续拖动时可使用
    mTempDrawOffsetX = mDrawOffsetX;
    mTempDrawOffsetY = mDrawOffsetY;
}

同时提供了恢复到上一次移动位置的方法:

//判断是否可以恢复到上一次移动位置
//若上一次位置与当前位置相同(即恢复过),返回false
public boolean isCanRollBack() {
    if (mDrawOffsetX == mLastDrawOffsetX && mDrawOffsetY == mLastDrawOffsetY) {
        return false;
    } else {
        return true;
    }
}

//回滚到上一次移动位置
public boolean rollbackToLastOffset() {
    boolean isRollbackSuccess = isCanRollBack();
    if (isRollbackSuccess) {
        //将当前的移动偏移值替换为上一次的偏移量
        this.mDrawOffsetX = mLastDrawOffsetX;
        this.mDrawOffsetY = mLastDrawOffsetY;
        this.mTempDrawOffsetX = mLastDrawOffsetX;
        this.mTempDrawOffsetY = mLastDrawOffsetY;
        //通过移动事件进行移动
        if (mMoveEvent != null) {
            mMoveEvent.onMove(Integer.MIN_VALUE);
        }
    }
    return isRollbackSuccess;
}

使用方法

继承AbsTouchEventHandle抽象类,创建此工具类的对象,实现此工具类的IScaleEventIMoveEvent接口,将接口对象设置到此工具类中,从AbsTouchEventHandle抽象方法中直接调用此工具类对应的方法即可.以下为示例代码:

public class Test extends AbsTouchEventHandle implements TouchUtils.IMoveEvent, TouchUtils.IScaleEvent{
    //创建工具
    private TouchUtils mTouch = null;

    public Test(){
        mTouch = new TouchUtils();
        mTouch.setMoveEvent(this);
        mTouch.setScaleEvent(this);
    }

    @Override
    public void onSingleTouchEventHandle(MotionEvent event, int extraMotionEvent) {
        //工具类默认处理的单点触摸事件
        mTouch.singleTouchEvent(event, extraMotionEvent);
    }

    @Override
    public void onMultiTouchEventHandle(MotionEvent event, int extraMotionEvent) {
        //工具类默认处理的多点(实际只处理了两点事件)触摸事件
        mTouch.multiTouchEvent(event, extraMotionEvent);
    }

    @Override
    public void onSingleClickByTime(MotionEvent event) {
        //基于时间的单击事件
        //按下与抬起时间不超过500ms
    }

    @Override
    public void onSingleClickByDistance(MotionEvent event) {
        //基于距离的单击事件
        //按下与抬起的距离不超过20像素(与时间无关,若按下不动几小时后再放开只要距离在范围内都可以触发)
    }

    @Override
    public void onDoubleClickByTime() {
        //基于时间的双击事件
        //单击事件基于clickByTime的两次单击
        //两次单击之间的时间不超过250ms
    }

    //实现 IMoveEvent 及 IScaleEvent 接口方法忽略
}

源码

/**
 * Created by CT on 16/3/24.
 * 触摸事件的辅助工具类,用于处理基本的拖动及缩放事件,提供简单的回调接口
 */
public class TouchUtils {

    private IScaleEvent mScaleEvent = null;
    private IMoveEvent mMoveEvent = null;
    //多点触控缩放按下坐标
    private float mScaleFirstDownX = 0f;
    private float mScaleFirstDownY = 0f;
    private float mScaleSecondDownX = 0f;
    private float mScaleSecondDownY = 0f;
    private float mScaleFirstUpX = 0f;
    private float mScaleFirstUpY = 0f;
    private float mScaleSecondUpX = 0f;
    private float mScaleSecondUpY = 0f;
    //上一次的缩放比例
    private float mLastScaleRate = 1f;

    //任何时候绘制需要的偏移量
    protected float mDrawOffsetY = 0f;
    protected float mDrawOffsetX = 0f;
    //上一次移动后保存的偏移量
    protected float mLastDrawOffsetX = 0f;
    protected float mLastDrawOffsetY = 0f;
    //移动过程中临时保存的移动前的偏移量
    protected float mTempDrawOffsetX = 0f;
    protected float mTempDrawOffsetY = 0f;
    //按下事件的坐标
    private float mDownX = 0f;
    private float mDownY = 0f;
    //抬起事件的坐标
    private float mUpX = 0f;
    private float mUpY = 0f;
    //是否打印消息
    private boolean mIsShowLog = true;

    public TouchUtils() {
    }

    public TouchUtils(IScaleEvent scaleEvent, IMoveEvent moveEvent) {
        this.mScaleEvent = scaleEvent;
        this.mMoveEvent = moveEvent;
    }

    /**
     * 获取上一次移动后的X轴偏移量,此值只会保存移动的上一次偏移量,若回滚过一次偏移量,此值与当前偏移量值相同
     *
     * @return
     */
    public float getLastOffsetX() {
        return this.mLastDrawOffsetX;
    }

    /**
     * 获取上一次移动后的Y轴偏移量,此值只会保存移动的上一次偏移量,若回滚过一次偏移量,此值与当前偏移量值相同
     *
     * @return
     */
    public float getLastOffset() {
        return this.mLastDrawOffsetY;
    }

    /**
     * 是否可回滚到上一次移动的偏移量
     *
     * @return
     */
    public boolean isCanRollBack() {
        if (mDrawOffsetX == mLastDrawOffsetX && mDrawOffsetY == mLastDrawOffsetY) {
            return false;
        } else {
            return true;
        }
    }

    /**
     * 回滚到上一次移动的偏移量,若回滚成功返回true,否则返回False
     *
     * @return
     */
    public boolean rollbackToLastOffset() {
        boolean isRollbackSuccess = isCanRollBack();
        if (isRollbackSuccess) {
            //将当前的移动偏移值替换为上一次的偏移量
            this.mDrawOffsetX = mLastDrawOffsetX;
            this.mDrawOffsetY = mLastDrawOffsetY;
            this.mTempDrawOffsetX = mLastDrawOffsetX;
            this.mTempDrawOffsetY = mLastDrawOffsetY;
            //通过移动事件进行移动
            if (mMoveEvent != null) {
                mMoveEvent.onMove(Integer.MIN_VALUE);
            }
        }
        return isRollbackSuccess;
    }

    /**
     * 获取X轴偏移量
     *
     * @return
     */
    public float getDrawOffsetX() {
        return this.mDrawOffsetX;
    }

    /**
     * 获取Y轴偏移量
     *
     * @return
     */
    public float getDrawOffsetY() {
        return this.mDrawOffsetY;
    }

    /**
     * 通过此方法可以设置初始值
     *
     * @param offsetX
     */
    public void setOffsetX(float offsetX) {
        this.mDrawOffsetX = offsetX;
        //需要一并更新暂存的偏移量
        this.mTempDrawOffsetX = offsetX;
    }

    /**
     * 通过此方法可以设置初始值
     *
     * @param offsetY
     */
    public void setOffsetY(float offsetY) {
        this.mDrawOffsetY = offsetY;
        //需要一并更新暂存的偏移量
        this.mTempDrawOffsetY = offsetY;
    }

    /**
     * 设置缩放处理事件
     *
     * @param event
     */
    public void setScaleEvent(IScaleEvent event) {
        this.mScaleEvent = event;
    }

    /**
     * 设置移动处理事件
     *
     * @param event
     */
    public void setMoveEvent(IMoveEvent event) {
        this.mMoveEvent = event;
    }

    /**
     * 根据坐标值计算需要缩放的比例,<font color="#ff9900"><b>返回值为移动距离与按下距离的商,move/down</b></font>
     *
     * @param firstDownX  多点触摸按下的pointer_1_x
     * @param firstDownY  多点触摸按下的pointer_1_y
     * @param secondDownX 多点触摸按下的pointer_2_x
     * @param secondDownY 多点触摸按下的pointer_2_y
     * @param firstUpX    多点触摸抬起或移动的pointer_1_x
     * @param firstUpY    多点触摸抬起或移动的pointer_1_y
     * @param secondUpX   多点触摸抬起或移动的pointer_2_x
     * @param secondUpY   多点触摸抬起或移动的pointer_2_y
     * @return
     */
    public static float getScaleRate(float firstDownX, float firstDownY, float secondDownX, float secondDownY,
                                     float firstUpX, float firstUpY, float secondUpX, float secondUpY) {
        //计算平方和
        double downDistance = Math.pow(Math.abs((firstDownX - secondDownX)), 2) + Math.pow(Math.abs(firstDownY - secondDownY), 2);
        double upDistance = Math.pow(Math.abs((firstUpX - secondUpX)), 2) + Math.pow(Math.abs(firstUpY - secondUpY), 2);
        //计算比例
        double newRate = Math.sqrt(upDistance) / Math.sqrt(downDistance);
        //计算与上一个比例的差
        //差值超过阀值则使用该比例,否则返回原比例
        if (newRate > 0.02 && newRate < 10) {
            //保存当前的缩放比为上一次的缩放比
            return (float) newRate;
        }
        return 1;
    }

    /**
     * 单点触摸事件处理
     *
     * @param event            单点触摸事件
     * @param extraMotionEvent 建议处理的额外事件,一般值为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP},{@link MotionEvent#ACTION_CANCEL}
     *                         <p>存在此参数是因为可能用户进行单点触摸并移动之后,会再进行多点触摸(此时并没有松开触摸),在这种情况下是无法分辨需要处理的是单点触摸事件还是多点触摸事件.
     *                         <font color="#ff9900"><b>此时会传递此参数值为单点触摸的{@link MotionEvent#ACTION_UP},建议按抬起事件处理并结束事件</b></font></p>
     */
    public void singleTouchEvent(MotionEvent event, int extraMotionEvent) {
        //单点触控
        int action = event.getAction();
        //用于记录此处事件中新界面与旧界面之间的相对移动距离
        float moveDistanceX = 0f;
        float moveDistanceY = 0f;
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mDownX = event.getX();
                mDownY = event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                //特别处理额外的事件
                //此处处理的事件是在单击事件并移动中用户进行了多点触摸
                //此时尝试结束进行移动界面,并将当前移动的结果固定下来作为新的界面(效果与直接抬起结束触摸相同)
                //并不作任何操作(因为在单击后再进行多点触摸无法分辨需要进行处理的事件是什么)
                if (extraMotionEvent == MotionEvent.ACTION_UP) {
                    showMsg("move 处理为 up 事件");
                    //已经移动过且建议处理为up事件时
                    //处理为up事件
                    event.setAction(MotionEvent.ACTION_UP);
                    singleTouchEvent(event, Integer.MIN_VALUE);
                    return;
                }

                showMsg("move 拖动重绘界面");
                mUpX = event.getX();
                mUpY = event.getY();
                moveDistanceX = mUpX - mDownX;
                moveDistanceY = mUpY - mDownY;
                //此次移动加数据量达到足够的距离触发移动事件
                invalidateInSinglePoint(moveDistanceX, moveDistanceY, MotionEvent.ACTION_MOVE);
                mUpX = 0f;
                mUpY = 0f;
                break;
            case MotionEvent.ACTION_UP:

                mUpX = event.getX();
                mUpY = event.getY();
                moveDistanceX = mUpX - mDownX;
                moveDistanceY = mUpY - mDownY;

                invalidateInSinglePoint(moveDistanceX, moveDistanceY, MotionEvent.ACTION_UP);
                //移动操作完把数据还原初始状态,以防出现不必要的错误
                mDownX = 0f;
                mDownY = 0f;
                mUpX = 0f;
                mUpY = 0f;
                break;
        }
    }


    /**
     * 多点触摸事件处理(两点触摸,暂没有做其它任何多点触摸)
     *
     * @param event            多点触摸事件
     * @param extraMotionEvent 建议处理的额外事件,一般值为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP},{@link MotionEvent#ACTION_CANCEL}
     */
    public void multiTouchEvent(MotionEvent event, int extraMotionEvent) {
        //使用try是为了防止获取系统的触摸点坐标失败
        //该部分可能为系统的问题
        try {
            int action = event.getAction();
            float newScaleRate = 0f;
            switch (action & MotionEvent.ACTION_MASK) {

                case MotionEvent.ACTION_POINTER_DOWN:
                    mScaleFirstDownX = event.getX(0);
                    mScaleFirstDownY = event.getY(0);
                    mScaleSecondDownX = event.getX(1);
                    mScaleSecondDownY = event.getY(1);

                    break;
                case MotionEvent.ACTION_MOVE:
                    mScaleFirstUpX = event.getX(0);
                    mScaleFirstUpY = event.getY(0);
                    mScaleSecondUpX = event.getX(1);
                    mScaleSecondUpY = event.getY(1);

                    newScaleRate = TouchUtils.getScaleRate(mScaleFirstDownX, mScaleFirstDownY, mScaleSecondDownX, mScaleSecondDownY,
                            mScaleFirstUpX, mScaleFirstUpY, mScaleSecondUpX, mScaleSecondUpY);
                    invalidateInMultiPoint(newScaleRate, MotionEvent.ACTION_MOVE);
                    break;
                case MotionEvent.ACTION_POINTER_UP:
                    mScaleFirstUpX = event.getX(0);
                    mScaleFirstUpY = event.getY(0);
                    mScaleSecondUpX = event.getX(1);
                    mScaleSecondUpY = event.getY(1);

                    newScaleRate = TouchUtils.getScaleRate(mScaleFirstDownX, mScaleFirstDownY, mScaleSecondDownX, mScaleSecondDownY,
                            mScaleFirstUpX, mScaleFirstUpY, mScaleSecondUpX, mScaleSecondUpY);
                    invalidateInMultiPoint(newScaleRate, MotionEvent.ACTION_POINTER_UP);

                    mScaleFirstDownX = 0;
                    mScaleFirstDownY = 0;
                    mScaleSecondDownX = 0;
                    mScaleSecondDownY = 0;
                    mScaleFirstUpX = 0;
                    mScaleFirstUpY = 0;
                    mScaleSecondUpX = 0;
                    mScaleSecondUpY = 0;
                    break;
            }
        } catch (IllegalArgumentException e) {
        }
    }


    /**
     * 多点触摸的重绘,是否重绘由实际缩放的比例决定
     *
     * @param newScaleRate     新的缩放比例,该比例可能为1(通常情况下比例为1不缩放,没有意义)
     * @param invalidateAction 重绘的动作标志
     */
    private void invalidateInMultiPoint(float newScaleRate, int invalidateAction) {
        if (mScaleEvent == null) {
            return;
        }
        //若缩放比为1且不为缩放的最终事件时,不进行重绘,防止反复多次地重绘..
        //如果是最后一次(up事件),必定重绘并记录缩放比
        boolean isCanScale = false;
        boolean isTrueSetValue = invalidateAction == MotionEvent.ACTION_POINTER_UP;
        if (newScaleRate == 1 && !isTrueSetValue) {
            return;
        }

        //回调缩放事件接口,是否允许缩放
        if (mScaleEvent.isCanScale(newScaleRate)) {
            //进行缩放,更新最后一次缩放比例为当前值
            mLastScaleRate = newScaleRate;
            isCanScale = true;
        }

        if (isTrueSetValue) {
            //若缩放比不合法且当前缩放为最后一次缩放(up事件),则将上一次的缩放比作为此次的缩放比,用于记录数据
            //此处不作此操作会导致在缩放的时候达到最大值后放手,再次缩放会在最开始的时候复用上一次缩放的结果(因为没有保存当前缩放值,有闪屏的效果...)
            newScaleRate = mLastScaleRate;
            //将最后一次的缩放比设为1(缩放事件已经终止,所以比例使用1)
            mLastScaleRate = 1;
            //最后一次必须缩放并保存值
            isCanScale = true;
        }
        if (!isCanScale) {
            //通知无法进行缩放
            mScaleEvent.onScaleFail(invalidateAction);
            return;
        }
        //更新缩放数据
        mScaleEvent.setScaleRate(newScaleRate, isTrueSetValue);
        //缩放回调
        mScaleEvent.onScale(invalidateAction);
    }


    /**
     * 根据移动的距离计算是否重新绘制
     *
     * @param moveDistanceX    X轴方向的移动距离(可负值)
     * @param moveDistanceY    Y轴方向的移动距离(可负值)
     * @param invalidateAction 重绘的行为标志
     */
    private boolean invalidateInSinglePoint(float moveDistanceX, float moveDistanceY, int invalidateAction) {
        if (mMoveEvent == null) {
            return false;
        }
        //此处做大于5的判断是为了避免在检测单击事件时
        //有可能会有很微小的变动,避免这种情况下依然会造成移动的效果
        if (Math.abs(moveDistanceX) > 5 || Math.abs(moveDistanceY) > 5 || invalidateAction == MotionEvent.ACTION_UP) {
            //新的偏移量
            float newDrawOffsetX = mTempDrawOffsetX + moveDistanceX;
            float newDrawOffsetY = mTempDrawOffsetY + moveDistanceY;

            //当前绘制的最左边边界坐标大于0(即边界已经显示在屏幕上时),且移动方向为向右移动
            if (!mMoveEvent.isCanMovedOnX(moveDistanceX, newDrawOffsetX)) {
                //保持原来的偏移量不变
                newDrawOffsetX = mDrawOffsetX;
            }
            //当前绘制的顶端坐标大于0且移动方向为向下移动
            if (!mMoveEvent.isCanMovedOnY(moveDistanceY, newDrawOffsetY)) {
                //保持原来的Y轴偏移量
                newDrawOffsetY = mDrawOffsetY;
            }

            //其它情况正常移动重绘
            //当距离确实有效地改变了再进行重绘制,否则原界面不变,减少重绘的次数
            if (newDrawOffsetX != mDrawOffsetX || newDrawOffsetY != mDrawOffsetY || invalidateAction == MotionEvent.ACTION_UP) {
                mDrawOffsetX = newDrawOffsetX;
                mDrawOffsetY = newDrawOffsetY;
                //抬起事件时,回调为
                if (invalidateAction == MotionEvent.ACTION_UP) {
                    //保存上一次的偏移量
                    mLastDrawOffsetX = mTempDrawOffsetX;
                    mLastDrawOffsetY = mTempDrawOffsetY;
                    //将此次的新偏移量保存为临时数据后续拖动时可使用
                    mTempDrawOffsetX = mDrawOffsetX;
                    mTempDrawOffsetY = mDrawOffsetY;
                }
                mMoveEvent.onMove(invalidateAction);
                return true;
            } else {
                mMoveEvent.onMoveFail(invalidateAction);
            }
        }
        return false;
    }

    /**
     * 设置是否打印消息
     *
     * @param isShowLog
     */
    public void setIsShowLog(boolean isShowLog) {
        mIsShowLog = isShowLog;
    }

    /**
     * 打印消息
     *
     * @param msg
     */
    public void showMsg(String msg) {
        if (mIsShowLog) {
            Log.i("touchUtils", msg + "");
        }
    }

    /**
     * 缩放事件处理接口
     */
    public interface IScaleEvent {

        /**
         * 是否允许进行缩放
         *
         * @param newScaleRate 新的缩放比例值,请注意该值为当前值与缩放前的值的比例,即<font color="#ff9900"><b>在move期间,
         *                     此值都是相对于move事件之前的down的坐标计算出来的,并不是上一次move结果的比例</b></font>,建议
         *                     修改缩放值或存储缩放值在move事件中不要处理,在up事件中处理会比较好,防止每一次都重新存储数据,可能
         *                     造成数据的大量读写而失去准确性
         * @return
         */
        public abstract boolean isCanScale(float newScaleRate);

        /**
         * 设置缩放的比例(存储值),<font color="#ff9900"><b>当up事件中,且当前不允许缩放,此值将会返回最后一次在move中允许缩放的比例值,
         * 此方式保证了在处理up事件中,可以将最后一次缩放的比例显示出来,而不至于结束up事件不存储数据导致界面回到缩放前或者之前某个状态</b></font>
         *
         * @param newScaleRate     新的缩放比例
         * @param isNeedStoreValue 是否需要存储比例,此值仅为建议值;当move事件中,此值为false,当true事件中,此值为true;不管当前
         *                         up事件中是否允许缩放,此值都为true;
         */
        public abstract void setScaleRate(float newScaleRate, boolean isNeedStoreValue);

        /**
         * 缩放事件
         *
         * @param suggestEventAction 建议处理的事件,值可能为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP}
         */
        public abstract void onScale(int suggestEventAction);

        /**
         * 无法进行缩放事件,可能某些条件不满足,如不允许缩放等
         *
         * @param suggetEventAction 建议处理的事件,值可能为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP}
         */
        public abstract void onScaleFail(int suggetEventAction);
    }


    /**
     * 移动事件处理接口
     */
    public interface IMoveEvent {

        /**
         * 是否可以实现X轴的移动
         *
         * @param moveDistanceX 当次X轴的移动距离(可正可负)
         * @param newOffsetX    新的X轴偏移量(若允许移动的情况下,此值实际上即为上一次偏移量加上当次的移动距离)
         * @return
         */
        public abstract boolean isCanMovedOnX(float moveDistanceX, float newOffsetX);

        /**
         * 是否可以实现Y轴的移动
         *
         * @param moveDistacneY 当次Y轴的移动距离(可正可负)
         * @param newOffsetY    新的Y轴偏移量(若允许移动的情况下,此值实际上即为上一次偏移量加上当次的移动距离)
         * @return
         */
        public abstract boolean isCanMovedOnY(float moveDistacneY, float newOffsetY);

        /**
         * 移动事件
         *
         * @param suggestEventAction 建议处理的事件,值可能为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP}
         * @return
         */
        public abstract void onMove(int suggestEventAction);

        /**
         * 无法进行移动事件
         *
         * @param suggetEventAction 建议处理的事件,值可能为{@link MotionEvent#ACTION_MOVE},{@link MotionEvent#ACTION_UP}
         */
        public abstract void onMoveFail(int suggetEventAction);
    }
}

回到目录

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值