拖动和缩放

尝试Compose方式
Jetpack Compose是Android推荐的UI工具包。了解如何在Compose中使用触摸和输入。

本文档介绍了如何使用触摸手势来拖动和缩放屏幕上的对象,使用 onTouchEvent() 来拦截触摸事件。

拖动一个对象

触摸手势的常见操作是使用它在屏幕上拖动一个对象。

在拖动或滚动操作中,应用必须跟踪原始指针,即使其他手指接触屏幕也是如此。例如,想象一下,在拖动图像时,用户将第二根手指放在触摸屏上并抬起第一根手指。如果你的应用只跟踪单个指针,它会将第二个指针视为默认指针并将图像移动到该位置。

为了防止这种情况发生,你的应用需要区分原始指针和任何后续指针。为此,它跟踪 ACTION_POINTER_DOWNACTION_POINTER_UP 事件,如 处理多点触控手势 中所述。 ACTION_POINTER_DOWNACTION_POINTER_UP 在辅助指针按下或抬起时传递给 onTouchEvent() 回调。

ACTION_POINTER_UP 情况下,你可以提取此索引并确保活动指针ID未引用不再接触屏幕的指针。如果是,你可以选择另一个指针作为活动指针并保存其当前的X和Y位置。在 ACTION_MOVE 情况下使用此保存的位置来计算移动屏幕上对象的距离。这样,应用始终使用来自正确指针的数据来计算移动距离。

以下代码片段允许用户在屏幕上拖动一个对象。它记录活动指针的初始位置,计算指针移动的距离,并将对象移动到新位置。它还正确地管理了其他指针的可能性。

此代码片段使用 getActionMasked() 方法。始终使用此方法来检索 MotionEvent 的操作。

Kotlin

// The "active pointer" is the one moving the object.
private var mActivePointerId = INVALID_POINTER_ID

override fun onTouchEvent(ev: MotionEvent): Boolean {
    // Let the ScaleGestureDetector inspect all events.
    mScaleDetector.onTouchEvent(ev)

    val action = MotionEventCompat.getActionMasked(ev)

    when (action) {
        MotionEvent.ACTION_DOWN -> {
            MotionEventCompat.getActionIndex(ev).also { pointerIndex ->
                // Remember where you start for dragging.
                mLastTouchX = MotionEventCompat.getX(ev, pointerIndex)
                mLastTouchY = MotionEventCompat.getY(ev, pointerIndex)
            }

            // Save the ID of this pointer for dragging.
            mActivePointerId = MotionEventCompat.getPointerId(ev, 0)
        }

        MotionEvent.ACTION_MOVE -> {
            // Find the index of the active pointer and fetch its position.
            val (x: Float, y: Float) =
                    MotionEventCompat.findPointerIndex(ev, mActivePointerId).let { pointerIndex ->
                        // Calculate the distance moved.
                        MotionEventCompat.getX(ev, pointerIndex) to
                                MotionEventCompat.getY(ev, pointerIndex)
                    }

            mPosX += x - mLastTouchX
            mPosY += y - mLastTouchY

            invalidate()

            // Remember this touch position for the next move event.
            mLastTouchX = x
            mLastTouchY = y
        }
        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
            mActivePointerId = INVALID_POINTER_ID
        }
        MotionEvent.ACTION_POINTER_UP -> {

            MotionEventCompat.getActionIndex(ev).also { pointerIndex ->
                MotionEventCompat.getPointerId(ev, pointerIndex)
                        .takeIf { it == mActivePointerId }
                        ?.run {
                            // This is the active pointer going up. Choose a new
                            // active pointer and adjust it accordingly.
                            val newPointerIndex = if (pointerIndex == 0) 1 else 0
                            mLastTouchX = MotionEventCompat.getX(ev, newPointerIndex)
                            mLastTouchY = MotionEventCompat.getY(ev, newPointerIndex)
                            mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex)
                        }
            }
        }
    }
    return true
}

Java

// The "active pointer" is the one moving the object.
private int mActivePointerId = INVALID_POINTER_ID;

@Override
public boolean onTouchEvent(MotionEvent ev) {
    // Let the ScaleGestureDetector inspect all events.
    mScaleDetector.onTouchEvent(ev);

    final int action = MotionEventCompat.getActionMasked(ev);

    switch (action) {
    case MotionEvent.ACTION_DOWN: {
        final int pointerIndex = MotionEventCompat.getActionIndex(ev);
        final float x = MotionEventCompat.getX(ev, pointerIndex);
        final float y = MotionEventCompat.getY(ev, pointerIndex);

        // Remember the starting position of the pointer.
        mLastTouchX = x;
        mLastTouchY = y;
        // Save the ID of this pointer for dragging.
        mActivePointerId = MotionEventCompat.getPointerId(ev, 0);
        break;
    }

    case MotionEvent.ACTION_MOVE: {
        // Find the index of the active pointer and fetch its position.
        final int pointerIndex =
                MotionEventCompat.findPointerIndex(ev, mActivePointerId);

        final float x = MotionEventCompat.getX(ev, pointerIndex);
        final float y = MotionEventCompat.getY(ev, pointerIndex);

        // Calculate the distance moved.
        final float dx = x - mLastTouchX;
        final float dy = y - mLastTouchY;

        mPosX += dx;
        mPosY += dy;

        invalidate();

        // Remember this touch position for the next move event.
        mLastTouchX = x;
        mLastTouchY = y;

        break;
    }

    case MotionEvent.ACTION_UP: {
        mActivePointerId = INVALID_POINTER_ID;
        break;
    }

    case MotionEvent.ACTION_CANCEL: {
        mActivePointerId = INVALID_POINTER_ID;
        break;
    }

    case MotionEvent.ACTION_POINTER_UP: {

        final int pointerIndex = MotionEventCompat.getActionIndex(ev);
        final int pointerId = MotionEventCompat.getPointerId(ev, pointerIndex);

        if (pointerId == mActivePointerId) {
            // This is the active pointer going up. Choose a new
            // active pointer and adjust it accordingly.
            final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
            mLastTouchX = MotionEventCompat.getX(ev, newPointerIndex);
            mLastTouchY = MotionEventCompat.getY(ev, newPointerIndex);
            mActivePointerId = MotionEventCompat.getPointerId(ev, newPointerIndex);
        }
        break;
    }
    }
    return true;
}

拖动以平移

上一节展示了在屏幕上拖动对象的示例。另一个常见场景是平移,即用户拖动手势会导致X轴和Y轴上的滚动。前面的代码片段直接拦截 MotionEvent 操作来实现拖动。本节中的代码片段通过重写 onScroll()GestureDetector.SimpleOnGestureListener 中,利用了平台对常见手势的内置支持。

为了提供更多上下文信息,onScroll() 在用户拖动手指平移内容时被调用。 onScroll() 仅在手指按下时被调用。一旦手指从屏幕上抬起,手势就会结束,或者如果手指在抬起前以一定的速度移动,就会开始一个轻扫手势。有关滚动与轻扫的更多信息,请参阅 动画滚动手势

以下是 onScroll() 的代码片段

Kotlin

// The current viewport. This rectangle represents the visible
// chart domain and range.
private val mCurrentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX)

// The current destination rectangle, in pixel coordinates, into which the
// chart data must be drawn.
private val mContentRect: Rect? = null

private val mGestureListener = object : GestureDetector.SimpleOnGestureListener() {
    ...
    override fun onScroll(
            e1: MotionEvent,
            e2: MotionEvent,
            distanceX: Float,
            distanceY: Float
    ): Boolean {
        // Scrolling uses math based on the viewport, as opposed to math using
        // pixels.

        mContentRect?.apply {
            // Pixel offset is the offset in screen pixels, while viewport offset is the
            // offset within the current viewport.
            val viewportOffsetX = distanceX * mCurrentViewport.width() / width()
            val viewportOffsetY = -distanceY * mCurrentViewport.height() / height()


            // Updates the viewport and refreshes the display.
            setViewportBottomLeft(
                    mCurrentViewport.left + viewportOffsetX,
                    mCurrentViewport.bottom + viewportOffsetY
            )
        }

        return true
    }
}

Java

// The current viewport. This rectangle represents the visible
// chart domain and range.
private RectF mCurrentViewport =
        new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX);

// The current destination rectangle, in pixel coordinates, into which the
// chart data must be drawn.
private Rect mContentRect;

private final GestureDetector.SimpleOnGestureListener mGestureListener
            = new GestureDetector.SimpleOnGestureListener() {
...

@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2,
            float distanceX, float distanceY) {
    // Scrolling uses math based on the viewport, as opposed to math using
    // pixels.

    // Pixel offset is the offset in screen pixels, while viewport offset is the
    // offset within the current viewport.
    float viewportOffsetX = distanceX * mCurrentViewport.width()
            / mContentRect.width();
    float viewportOffsetY = -distanceY * mCurrentViewport.height()
            / mContentRect.height();
    ...
    // Updates the viewport, refreshes the display.
    setViewportBottomLeft(
            mCurrentViewport.left + viewportOffsetX,
            mCurrentViewport.bottom + viewportOffsetY);
    ...
    return true;
}

onScroll() 的实现根据触摸手势滚动视窗

Kotlin

/**
 * Sets the current viewport, defined by mCurrentViewport, to the given
 * X and Y positions. The Y value represents the topmost pixel position,
 * and thus the bottom of the mCurrentViewport rectangle.
 */
private fun setViewportBottomLeft(x: Float, y: Float) {
    /*
     * Constrains within the scroll range. The scroll range is the viewport
     * extremes, such as AXIS_X_MAX, minus the viewport size. For example, if
     * the extremes are 0 and 10 and the viewport size is 2, the scroll range
     * is 0 to 8.
     */

    val curWidth: Float = mCurrentViewport.width()
    val curHeight: Float = mCurrentViewport.height()
    val newX: Float = Math.max(AXIS_X_MIN, Math.min(x, AXIS_X_MAX - curWidth))
    val newY: Float = Math.max(AXIS_Y_MIN + curHeight, Math.min(y, AXIS_Y_MAX))

    mCurrentViewport.set(newX, newY - curHeight, newX + curWidth, newY)

    // Invalidates the View to update the display.
    ViewCompat.postInvalidateOnAnimation(this)
}

Java

/**
 * Sets the current viewport (defined by mCurrentViewport) to the given
 * X and Y positions. Note that the Y value represents the topmost pixel
 * position, and thus the bottom of the mCurrentViewport rectangle.
 */
private void setViewportBottomLeft(float x, float y) {
    /*
     * Constrains within the scroll range. The scroll range is the viewport
     * extremes, such as AXIS_X_MAX, minus the viewport size. For example, if
     * the extremes are 0 and 10 and the viewport size is 2, the scroll range
     * is 0 to 8.
     */

    float curWidth = mCurrentViewport.width();
    float curHeight = mCurrentViewport.height();
    x = Math.max(AXIS_X_MIN, Math.min(x, AXIS_X_MAX - curWidth));
    y = Math.max(AXIS_Y_MIN + curHeight, Math.min(y, AXIS_Y_MAX));

    mCurrentViewport.set(x, y - curHeight, x + curWidth, y);

    // Invalidates the View to update the display.
    ViewCompat.postInvalidateOnAnimation(this);
}

使用触摸进行缩放

检测常见手势 中所述,使用 GestureDetector 来检测 Android 使用的常见手势,例如滚动、轻扫和触摸并按住。对于缩放,Android 提供了 ScaleGestureDetector。当你想让一个视图识别其他手势时,你可以将 GestureDetectorScaleGestureDetector 结合使用。

为了报告检测到的手势事件,手势检测器使用传递给其构造函数的监听器对象。 ScaleGestureDetector 使用 ScaleGestureDetector.OnScaleGestureListener。Android 提供了 ScaleGestureDetector.SimpleOnScaleGestureListener 作为辅助类,如果你不需要所有报告的事件,可以扩展它。

基本缩放示例

以下代码片段说明了缩放所涉及的基本元素。

Kotlin

private var mScaleFactor = 1f

private val scaleListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {

    override fun onScale(detector: ScaleGestureDetector): Boolean {
        mScaleFactor *= detector.scaleFactor

        // Don't let the object get too small or too large.
        mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f))

        invalidate()
        return true
    }
}

private val mScaleDetector = ScaleGestureDetector(context, scaleListener)

override fun onTouchEvent(ev: MotionEvent): Boolean {
    // Let the ScaleGestureDetector inspect all events.
    mScaleDetector.onTouchEvent(ev)
    return true
}

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)

    canvas?.apply {
        save()
        scale(mScaleFactor, mScaleFactor)
        // onDraw() code goes here.
        restore()
    }
}

Java

private ScaleGestureDetector mScaleDetector;
private float mScaleFactor = 1.f;

public MyCustomView(Context mContext){
    ...
    // View code goes here.
    ...
    mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
}

@Override
public boolean onTouchEvent(MotionEvent ev) {
    // Let the ScaleGestureDetector inspect all events.
    mScaleDetector.onTouchEvent(ev);
    return true;
}

@Override
public void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    canvas.save();
    canvas.scale(mScaleFactor, mScaleFactor);
    ...
    // onDraw() code goes here.
    ...
    canvas.restore();
}

private class ScaleListener
        extends ScaleGestureDetector.SimpleOnScaleGestureListener {
    @Override
    public boolean onScale(ScaleGestureDetector detector) {
        mScaleFactor *= detector.getScaleFactor();

        // Don't let the object get too small or too large.
        mScaleFactor = Math.max(0.1f, Math.min(mScaleFactor, 5.0f));

        invalidate();
        return true;
    }
}

更复杂的缩放示例

以下是 InteractiveChart 示例的更复杂示例,如 动画滚动手势 中所示。 InteractiveChart 示例支持使用多个手指滚动、平移和缩放,使用 ScaleGestureDetector 的跨度 (getCurrentSpanXgetCurrentSpanY) 以及“焦点” (getFocusXgetFocusY) 功能。

Kotlin

private val mCurrentViewport = RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX)
private val mContentRect: Rect? = null
...
override fun onTouchEvent(event: MotionEvent): Boolean {
    return mScaleGestureDetector.onTouchEvent(event)
            || mGestureDetector.onTouchEvent(event)
            || super.onTouchEvent(event)
}

/**
 * The scale listener, used for handling multi-finger scale gestures.
 */
private val mScaleGestureListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() {

    /**
     * This is the active focal point in terms of the viewport. It can be a
     * local variable, but keep it here to minimize per-frame allocations.
     */
    private val viewportFocus = PointF()
    private var lastSpanX: Float = 0f
    private var lastSpanY: Float = 0f

    // Detects new pointers are going down.
    override fun onScaleBegin(scaleGestureDetector: ScaleGestureDetector): Boolean {
        lastSpanX = scaleGestureDetector.currentSpanX
        lastSpanY = scaleGestureDetector.currentSpanY
        return true
    }

    override fun onScale(scaleGestureDetector: ScaleGestureDetector): Boolean {
        val spanX: Float = scaleGestureDetector.currentSpanX
        val spanY: Float = scaleGestureDetector.currentSpanY

        val newWidth: Float = lastSpanX / spanX * mCurrentViewport.width()
        val newHeight: Float = lastSpanY / spanY * mCurrentViewport.height()

        val focusX: Float = scaleGestureDetector.focusX
        val focusY: Float = scaleGestureDetector.focusY
        // Ensures the chart point is within the chart region.
        // See the sample for the implementation of hitTest().
        hitTest(focusX, focusY, viewportFocus)

        mContentRect?.apply {
            mCurrentViewport.set(
                    viewportFocus.x - newWidth * (focusX - left) / width(),
                    viewportFocus.y - newHeight * (bottom - focusY) / height(),
                    0f,
                    0f
            )
        }
        mCurrentViewport.right = mCurrentViewport.left + newWidth
        mCurrentViewport.bottom = mCurrentViewport.top + newHeight
        // Invalidates the View to update the display.
        ViewCompat.postInvalidateOnAnimation(this@InteractiveLineGraphView)

        lastSpanX = spanX
        lastSpanY = spanY
        return true
    }
}

Java

private RectF mCurrentViewport =
        new RectF(AXIS_X_MIN, AXIS_Y_MIN, AXIS_X_MAX, AXIS_Y_MAX);
private Rect mContentRect;
private ScaleGestureDetector mScaleGestureDetector;
...
@Override
public boolean onTouchEvent(MotionEvent event) {
    boolean retVal = mScaleGestureDetector.onTouchEvent(event);
    retVal = mGestureDetector.onTouchEvent(event) || retVal;
    return retVal || super.onTouchEvent(event);
}

/**
 * The scale listener, used for handling multi-finger scale gestures.
 */
private final ScaleGestureDetector.OnScaleGestureListener mScaleGestureListener
        = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
    /**
     * This is the active focal point in terms of the viewport. It can be a
     * local variable, but keep it here to minimize per-frame allocations.
     */
    private PointF viewportFocus = new PointF();
    private float lastSpanX;
    private float lastSpanY;

    // Detects new pointers are going down.
    @Override
    public boolean onScaleBegin(ScaleGestureDetector scaleGestureDetector) {
        lastSpanX = ScaleGestureDetectorCompat.
                getCurrentSpanX(scaleGestureDetector);
        lastSpanY = ScaleGestureDetectorCompat.
                getCurrentSpanY(scaleGestureDetector);
        return true;
    }

    @Override
    public boolean onScale(ScaleGestureDetector scaleGestureDetector) {

        float spanX = ScaleGestureDetectorCompat.
                getCurrentSpanX(scaleGestureDetector);
        float spanY = ScaleGestureDetectorCompat.
                getCurrentSpanY(scaleGestureDetector);

        float newWidth = lastSpanX / spanX * mCurrentViewport.width();
        float newHeight = lastSpanY / spanY * mCurrentViewport.height();

        float focusX = scaleGestureDetector.getFocusX();
        float focusY = scaleGestureDetector.getFocusY();
        // Ensures the chart point is within the chart region.
        // See the sample for the implementation of hitTest().
        hitTest(scaleGestureDetector.getFocusX(),
                scaleGestureDetector.getFocusY(),
                viewportFocus);

        mCurrentViewport.set(
                viewportFocus.x
                        - newWidth * (focusX - mContentRect.left)
                        / mContentRect.width(),
                viewportFocus.y
                        - newHeight * (mContentRect.bottom - focusY)
                        / mContentRect.height(),
                0,
                0);
        mCurrentViewport.right = mCurrentViewport.left + newWidth;
        mCurrentViewport.bottom = mCurrentViewport.top + newHeight;
        ...
        // Invalidates the View to update the display.
        ViewCompat.postInvalidateOnAnimation(InteractiveLineGraphView.this);

        lastSpanX = spanX;
        lastSpanY = spanY;
        return true;
    }
};

其他资源

有关输入事件、传感器和使自定义视图交互的更多信息,请参阅以下参考资料。