Drag and scale

Try the Compose way
Jetpack Compose is the recommended UI toolkit for Android. Learn how to use touch and input in Compose.

This document describes how to use touch gestures to drag and scale on-screen objects, using onTouchEvent() to intercept touch events.

Drag an object

A common operation for a touch gesture is to use it to drag an object across the screen.

In a drag or scroll operation, the app has to keep track of the original pointer, even if additional fingers touch the screen. For example, imagine that while dragging the image, the user places a second finger on the touch screen and lifts the first finger. If your app is only tracking individual pointers, it regards the second pointer as the default and moves the image to that location.

To prevent this from happening, your app needs to distinguish between the original pointer and any subsequent pointers. To do this, it tracks the ACTION_POINTER_DOWN and ACTION_POINTER_UP events as described in Handle multi-touch gestures. ACTION_POINTER_DOWN and ACTION_POINTER_UP are passed to the onTouchEvent() callback whenever a secondary pointer goes down or up.

In the ACTION_POINTER_UP case, you can extract this index and ensure that the active pointer ID isn't referring to a pointer that is no longer touching the screen. If it is, you can select a different pointer to be active and save its current X and Y position. Use this saved position in the ACTION_MOVE case to calculate the distance to move the on-screen object. This way, the app always calculates the distance to move using data from the correct pointer.

The following code snippet lets a user drag an object on the screen. It records the initial position of the active pointer, calculates the distance the pointer travels, and moves the object to the new position. It also correctly manages the possibility of additional pointers.

The snippet uses the getActionMasked() method. Always use this method to retrieve the action of a MotionEvent .

Kotlin

// The "active pointer" is the one moving the object.
privatevarmActivePointerId=INVALID_POINTER_ID
overridefunonTouchEvent(ev:MotionEvent):Boolean{
// Let the ScaleGestureDetector inspect all events.
mScaleDetector.onTouchEvent(ev)
valaction=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.
valnewPointerIndex=if(pointerIndex==0)1else0
mLastTouchX=MotionEventCompat.getX(ev,newPointerIndex)
mLastTouchY=MotionEventCompat.getY(ev,newPointerIndex)
mActivePointerId=MotionEventCompat.getPointerId(ev,newPointerIndex)
}
}
}
}
returntrue
}

Java

// The "active pointer" is the one moving the object.
privateintmActivePointerId=INVALID_POINTER_ID;
@Override
publicbooleanonTouchEvent(MotionEventev){
// Let the ScaleGestureDetector inspect all events.
mScaleDetector.onTouchEvent(ev);
finalintaction=MotionEventCompat.getActionMasked(ev);
switch(action){
caseMotionEvent.ACTION_DOWN:{
finalintpointerIndex=MotionEventCompat.getActionIndex(ev);
finalfloatx=MotionEventCompat.getX(ev,pointerIndex);
finalfloaty=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;
}
caseMotionEvent.ACTION_MOVE:{
// Find the index of the active pointer and fetch its position.
finalintpointerIndex=
MotionEventCompat.findPointerIndex(ev,mActivePointerId);
finalfloatx=MotionEventCompat.getX(ev,pointerIndex);
finalfloaty=MotionEventCompat.getY(ev,pointerIndex);
// Calculate the distance moved.
finalfloatdx=x-mLastTouchX;
finalfloatdy=y-mLastTouchY;
mPosX+=dx;
mPosY+=dy;
invalidate();
// Remember this touch position for the next move event.
mLastTouchX=x;
mLastTouchY=y;
break;
}
caseMotionEvent.ACTION_UP:{
mActivePointerId=INVALID_POINTER_ID;
break;
}
caseMotionEvent.ACTION_CANCEL:{
mActivePointerId=INVALID_POINTER_ID;
break;
}
caseMotionEvent.ACTION_POINTER_UP:{
finalintpointerIndex=MotionEventCompat.getActionIndex(ev);
finalintpointerId=MotionEventCompat.getPointerId(ev,pointerIndex);
if(pointerId==mActivePointerId){
// This is the active pointer going up. Choose a new
// active pointer and adjust it accordingly.
finalintnewPointerIndex=pointerIndex==0?1:0;
mLastTouchX=MotionEventCompat.getX(ev,newPointerIndex);
mLastTouchY=MotionEventCompat.getY(ev,newPointerIndex);
mActivePointerId=MotionEventCompat.getPointerId(ev,newPointerIndex);
}
break;
}
}
returntrue;
}

Drag to pan

The previous section shows an example of dragging an object on the screen. Another common scenario is panning, which is when a user's dragging motion causes scrolling in both the X- and Y-axes. The preceding snippet directly intercepts the MotionEvent actions to implement dragging. The snippet in this section takes advantage of the platform's built-in support for common gestures by overriding onScroll() in GestureDetector.SimpleOnGestureListener .

To provide more context, onScroll() is called when a user drags a finger to pan the content. onScroll() is only called when a finger is down. As soon as the finger is lifted from the screen, either the gesture either ends or a fling gesture starts, if the finger is moving with some speed just before it is lifted. For more information about scrolling versus flinging, see Animate a scroll gesture.

The following is the code snippet for onScroll():

Kotlin

// The current viewport. This rectangle represents the visible
// chart domain and range.
privatevalmCurrentViewport=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.
privatevalmContentRect:Rect? =null
privatevalmGestureListener=object:GestureDetector.SimpleOnGestureListener(){
...
overridefunonScroll(
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.
valviewportOffsetX=distanceX*mCurrentViewport.width()/width()
valviewportOffsetY=-distanceY*mCurrentViewport.height()/height()
// Updates the viewport and refreshes the display.
setViewportBottomLeft(
mCurrentViewport.left+viewportOffsetX,
mCurrentViewport.bottom+viewportOffsetY
)
}
returntrue
}
}

Java

// The current viewport. This rectangle represents the visible
// chart domain and range.
privateRectFmCurrentViewport=
newRectF(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.
privateRectmContentRect;
privatefinalGestureDetector.SimpleOnGestureListenermGestureListener
=newGestureDetector.SimpleOnGestureListener(){
...
@Override
publicbooleanonScroll(MotionEvente1,MotionEvente2,
floatdistanceX,floatdistanceY){
// 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.
floatviewportOffsetX=distanceX*mCurrentViewport.width()
/mContentRect.width();
floatviewportOffsetY=-distanceY*mCurrentViewport.height()
/mContentRect.height();
...
// Updates the viewport, refreshes the display.
setViewportBottomLeft(
mCurrentViewport.left+viewportOffsetX,
mCurrentViewport.bottom+viewportOffsetY);
...
returntrue;
}

The implementation of onScroll() scrolls the viewport in response to the touch gesture:

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.
 */
privatefunsetViewportBottomLeft(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.
 */
valcurWidth:Float=mCurrentViewport.width()
valcurHeight:Float=mCurrentViewport.height()
valnewX:Float=Math.max(AXIS_X_MIN,Math.min(x,AXIS_X_MAX-curWidth))
valnewY: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.
 */
privatevoidsetViewportBottomLeft(floatx,floaty){
/*
 * 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.
 */
floatcurWidth=mCurrentViewport.width();
floatcurHeight=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);
}

Use touch to perform scaling

As discussed in Detect common gestures, use GestureDetector to detect common gestures used by Android, such as scrolling, flinging, and touch and hold. For scaling, Android provides ScaleGestureDetector . You can use GestureDetector and ScaleGestureDetector together when you want a view to recognize additional gestures.

To report detected gesture events, gesture detectors use listener objects passed to their constructors. ScaleGestureDetector uses ScaleGestureDetector.OnScaleGestureListener . Android provides ScaleGestureDetector.SimpleOnScaleGestureListener as a helper class that you can extend if you don't need all of the reported events.

Basic scaling example

The following snippet illustrates the basic elements involved in scaling.

Kotlin

privatevarmScaleFactor=1f
privatevalscaleListener=object:ScaleGestureDetector.SimpleOnScaleGestureListener(){
overridefunonScale(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()
returntrue
}
}
privatevalmScaleDetector=ScaleGestureDetector(context,scaleListener)
overridefunonTouchEvent(ev:MotionEvent):Boolean{
// Let the ScaleGestureDetector inspect all events.
mScaleDetector.onTouchEvent(ev)
returntrue
}
overridefunonDraw(canvas:Canvas?){
super.onDraw(canvas)
canvas?.apply{
save()
scale(mScaleFactor,mScaleFactor)
// onDraw() code goes here.
restore()
}
}

Java

privateScaleGestureDetectormScaleDetector;
privatefloatmScaleFactor=1.f;
publicMyCustomView(ContextmContext){
...
// View code goes here.
...
mScaleDetector=newScaleGestureDetector(context,newScaleListener());
}
@Override
publicbooleanonTouchEvent(MotionEventev){
// Let the ScaleGestureDetector inspect all events.
mScaleDetector.onTouchEvent(ev);
returntrue;
}
@Override
publicvoidonDraw(Canvascanvas){
super.onDraw(canvas);
canvas.save();
canvas.scale(mScaleFactor,mScaleFactor);
...
// onDraw() code goes here.
...
canvas.restore();
}
privateclass ScaleListener
extendsScaleGestureDetector.SimpleOnScaleGestureListener{
@Override
publicbooleanonScale(ScaleGestureDetectordetector){
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();
returntrue;
}
}

More complex scaling example

The following is a more complex example from the InteractiveChart sample shown in Animate a scroll gesture. The InteractiveChart sample supports scrolling, panning, and scaling with multiple fingers, using the ScaleGestureDetector span (getCurrentSpanX and getCurrentSpanY ) and "focus" (getFocusX and getFocusY ) features.

Kotlin

privatevalmCurrentViewport=RectF(AXIS_X_MIN,AXIS_Y_MIN,AXIS_X_MAX,AXIS_Y_MAX)
privatevalmContentRect:Rect? =null
...
overridefunonTouchEvent(event:MotionEvent):Boolean{
returnmScaleGestureDetector.onTouchEvent(event)
||mGestureDetector.onTouchEvent(event)
||super.onTouchEvent(event)
}
/**
 * The scale listener, used for handling multi-finger scale gestures.
 */
privatevalmScaleGestureListener=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.
 */
privatevalviewportFocus=PointF()
privatevarlastSpanX:Float=0f
privatevarlastSpanY:Float=0f
// Detects new pointers are going down.
overridefunonScaleBegin(scaleGestureDetector:ScaleGestureDetector):Boolean{
lastSpanX=scaleGestureDetector.currentSpanX
lastSpanY=scaleGestureDetector.currentSpanY
returntrue
}
overridefunonScale(scaleGestureDetector:ScaleGestureDetector):Boolean{
valspanX:Float=scaleGestureDetector.currentSpanX
valspanY:Float=scaleGestureDetector.currentSpanY
valnewWidth:Float=lastSpanX/spanX*mCurrentViewport.width()
valnewHeight:Float=lastSpanY/spanY*mCurrentViewport.height()
valfocusX:Float=scaleGestureDetector.focusX
valfocusY: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
returntrue
}
}

Java

privateRectFmCurrentViewport=
newRectF(AXIS_X_MIN,AXIS_Y_MIN,AXIS_X_MAX,AXIS_Y_MAX);
privateRectmContentRect;
privateScaleGestureDetectormScaleGestureDetector;
...
@Override
publicbooleanonTouchEvent(MotionEventevent){
booleanretVal=mScaleGestureDetector.onTouchEvent(event);
retVal=mGestureDetector.onTouchEvent(event)||retVal;
returnretVal||super.onTouchEvent(event);
}
/**
 * The scale listener, used for handling multi-finger scale gestures.
 */
privatefinalScaleGestureDetector.OnScaleGestureListenermScaleGestureListener
=newScaleGestureDetector.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.
 */
privatePointFviewportFocus=newPointF();
privatefloatlastSpanX;
privatefloatlastSpanY;
// Detects new pointers are going down.
@Override
publicbooleanonScaleBegin(ScaleGestureDetectorscaleGestureDetector){
lastSpanX=ScaleGestureDetectorCompat.
getCurrentSpanX(scaleGestureDetector);
lastSpanY=ScaleGestureDetectorCompat.
getCurrentSpanY(scaleGestureDetector);
returntrue;
}
@Override
publicbooleanonScale(ScaleGestureDetectorscaleGestureDetector){
floatspanX=ScaleGestureDetectorCompat.
getCurrentSpanX(scaleGestureDetector);
floatspanY=ScaleGestureDetectorCompat.
getCurrentSpanY(scaleGestureDetector);
floatnewWidth=lastSpanX/spanX*mCurrentViewport.width();
floatnewHeight=lastSpanY/spanY*mCurrentViewport.height();
floatfocusX=scaleGestureDetector.getFocusX();
floatfocusY=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;
returntrue;
}
};

Additional resources

See the following references for more information about input events, sensors, and making custom views interactive.

Content and code samples on this page are subject to the licenses described in the Content License. Java and OpenJDK are trademarks or registered trademarks of Oracle and/or its affiliates.

Last updated 2026年05月28日 UTC.