کشیدن و تغییر مقیاس

امتحان کردن روش «نوشتن»
‫Jetpack Compose جعبه‌ابزار واسط کاربر توصیه‌شده برای Android است. با نحوه استفاده از لمس و ورودی در «نوشتن» آشنا شوید.

این سند نحوه استفاده از اشاره‌های لمسی برای کشیدن و مقیاس‌بندی کردن اشیا روی صفحه را بااستفاده از onTouchEvent() برای رهگیری رویدادهای لمسی شرح می‌دهد.

کشیدن شیء

یک عملکرد رایج برای اشاره لمسی استفاده از آن برای کشیدن یک شیء در سراسر صفحه است.

در عملیات کشیدن یا پیمایش، برنامه باید نشانگر اصلی را ردیابی کند، حتی اگر انگشتان دیگری صفحه را لمس کنند. برای مثال، تصور کنید که کاربر درحین کشیدن تصویر، انگشت دومش را روی صفحه‌نمایش لمسی قرار می‌دهد و انگشت اولش را برمی‌دارد. اگر برنامه شما فقط اشاره‌گرهای فردی را ردیابی می‌کند، اشاره‌گر دوم را به‌عنوان پیش‌فرض درنظر می‌گیرد و تصویر را به آن مکان منتقل می‌کند.

برای جلوگیری از این اتفاق، برنامه شما باید بین اشاره‌گر اصلی و اشاره‌گرهای بعدی تمایز قائل شود. برای انجام این کار، رویدادهای ACTION_POINTER_DOWN و ACTION_POINTER_UP را همان‌طور که در مدیریت کردن اشاره‌های چندلمسی توضیح داده شده است ردیابی می‌کند. هرگاه اشاره‌گر ثانویه‌ای پایین یا بالا می‌رود، ACTION_POINTER_DOWN و ACTION_POINTER_UP به تابع برگشتی onTouchEvent() ارسال می‌شوند.

در حالت ACTION_POINTER_UP، می‌توانید این شاخص را استخراج کنید و مطمئن شوید که شناسه اشاره‌گر فعال به اشاره‌گری که دیگر صفحه را لمس نمی‌کند ارجاع نمی‌دهد. اگر فعال باشد، می‌توانید اشاره‌گر دیگری را انتخاب کنید تا فعال شود و موقعیت X و Y کنونی آن را ذخیره کنید. از این موقعیت ذخیره‌شده در ACTION_MOVE قاب برای محاسبه فاصله حرکت شیء روی صفحه استفاده کنید. به این ترتیب، برنامه همیشه فاصله حرکت را بااستفاده از داده‌های اشاره‌گر صحیح محاسبه می‌کند.

تکه کد زیر به کاربر امکان می‌دهد شیئی را روی صفحه بکشد. موقعیت اولیه اشاره‌گر فعال را ثبت می‌کند، فاصله‌ای را که اشاره‌گر طی می‌کند محاسبه می‌کند، و شیء را به موقعیت جدید منتقل می‌کند. همچنین امکان وجود اشاره‌گرهای اضافی را به‌درستی مدیریت می‌کند.

گزیده از روش getActionMasked() استفاده می‌کند. همیشه از این روش برای بازیابی کنش MotionEvent استفاده می‌شود.

کاتلین

// 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
}

جاوا

// 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() در زیر آمده است:

کاتلین

// 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
    }
}

جاوا

// 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() ناحیه نمایش را در پاسخ به اشاره لمسی پیمایش می‌کند:

کاتلین

/**
 * 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)
}

جاوا

/**
 * 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 را ارائه می‌دهد. وقتی می‌خواهید نما اشاره‌های بیشتری را تشخیص دهد، می‌توانید از GestureDetector و ScaleGestureDetector با هم استفاده کنید.

برای گزارش رویدادهای اشاره تشخیص‌داده‌شده، آشکارسازهای اشاره از اشیای شنونده‌ای که به سازنده‌هایشان منتقل شده است استفاده می‌کنند. ‫ScaleGestureDetector از ScaleGestureDetector.OnScaleGestureListener استفاده می‌کند. ‫Android ScaleGestureDetector.SimpleOnScaleGestureListener را به‌عنوان کلاس کمکی ارائه می‌دهد که اگر به همه رویدادهای گزارش‌شده نیاز ندارید می‌توانید آن را گسترش دهید.

نمونه مقیاس‌بندی پایه

تکه کد زیر عناصر اصلی درگیر در مقیاس‌بندی را نشان می‌دهد.

کاتلین

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

جاوا

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 (getCurrentSpanX و getCurrentSpanY) و ویژگی‌های «کانون توجه» (getFocusX و getFocusY) پشتیبانی می‌کند.

کاتلین

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
    }
}

جاوا

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

منابع بیشتر

برای کسب اطلاعات بیشتر درباره رویدادهای ورودی، حسگرها، و تعاملی کردن نماهای سفارشی، به مرجع‌های زیر مراجعه کنید.