26

In normal view, we can have onTouchEvent

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {}
            MotionEvent.ACTION_MOVE -> {}
            MotionEvent.ACTION_UP -> {}
            else -> return false
        }
        invalidate()
        return true
    }

In Jetpack Compose, I can only find we have the tapGestureFilter in the modifier, which only takes the action from the ACTION_UP only.

Modifier
    .tapGestureFilter { Log.d("Track", "Tap ${it.x} | ${it.y}") }
    .doubleTapGestureFilter { Log.d("Track", "DoubleTap ${it.x} | ${it.y}") }

Is there an equivalent onTouchEvent for Jetpack Compose?

Thracian
  • 43,021
  • 16
  • 133
  • 222
Elye
  • 53,639
  • 54
  • 212
  • 474
  • Please let me know sth. Is it a bad practice to use the above function `onTouchEvent` in Jetpack compose project? I've tested it and it seems to function properly! – Mohamad Ghaith Alzin Aug 30 '22 at 08:17

4 Answers4

38

We have a separate package for that, which is pretty useful. There are two main extension functions that would be suitable for you:

  • pointerInput - docs
  • pointerInteropFilter - docs

If you want to handle and process the event I recommend using pointerInteropFilter which is the analogue of View.onTouchEvent. It's used along with modifier:

Column(modifier = Modifier.pointerInteropFilter {
    when (it.action) {
        MotionEvent.ACTION_DOWN -> {}
        MotionEvent.ACTION_MOVE -> {}
        MotionEvent.ACTION_UP -> {}
        else ->  false
    }
     true
})

That will be Compose adjusted code to your specified View.onTouchEvent sample.

P.S. Don't forget about @ExperimentalPointerInput annotation.

codingjeremy
  • 5,215
  • 1
  • 36
  • 39
Yurii Tsap
  • 3,554
  • 3
  • 24
  • 33
  • 1
    Nice, sweet! Testing `pointerInteropFilter`, works like a charm. – Elye Oct 30 '20 at 08:21
  • I didn't use `@ExperimentalPointerInput` though.. hmm, is that still needed? – Elye Oct 30 '20 at 08:22
  • @Elye Well, I'm using `androidx.compose.ui:ui:1.0.0-alpha05` and `pointerInput` requires ExperimentalPointerInput annotation. Maybe they will change some details, not sure. Glad to hear that it worked for you! – Yurii Tsap Oct 30 '20 at 09:34
  • There is no `@ExperimentalPointerInput` in beta9, use `@ExperimentalComposeUiApi` instead – Jenkyn Jun 27 '21 at 06:02
  • At Compose 1.1.0 is marked as `@OptIn(ExperimentalComposeUiApi::class)` – Jose Flavio Quispe Irrazábal Feb 23 '22 at 04:47
  • 2
    This solution is not great because you can't trigger any gesture event of child views, So the trigger of child views depends on what you return after "when" statement (true or false), if you return false, children buttons trigger but you can't trigger this view's ACTION_MOVE and ACTION_UP afterwards. I'm search for solution where I can trigger both. – oto Jul 19 '22 at 17:57
24

pointerInteropFilter is not described as preferred way to use if you are not using touch api with interoperation with existing View code.

A special PointerInputModifier that provides access to the underlying MotionEvents originally dispatched to Compose. Prefer pointerInput and use this only for interoperation with existing code that consumes MotionEvents. While the main intent of this Modifier is to allow arbitrary code to access the original MotionEvent dispatched to Compose, for completeness, analogs are provided to allow arbitrary code to interact with the system as if it were an Android View.

You can use pointerInput , awaitTouchDown for MotionEvent.ACTION_DOWN, and awaitPointerEvent for MotionEvent.ACTION_MOVE and MotionEvent.ACTION_UP

val pointerModifier = Modifier
    .pointerInput(Unit) {
        forEachGesture {

            awaitPointerEventScope {
                
                awaitFirstDown()
               // ACTION_DOWN here
               
                do {
                    
                    //This PointerEvent contains details including
                    // event, id, position and more
                    val event: PointerEvent = awaitPointerEvent()
                    // ACTION_MOVE loop

                    // Consuming event prevents other gestures or scroll to intercept
                    event.changes.forEach { pointerInputChange: PointerInputChange ->
                        pointerInputChange.consumePositionChange()
                    }
                } while (event.changes.any { it.pressed })

                // ACTION_UP is here
            }
        }
}

enter image description here

Some key notes about gestures

  1. pointerInput propagation is when you have more than one goes from bottom one to top one.
  2. If children and parent are listening for input changes it propagates from inner children to outer then parent. Unlike touch events from parent to children ( This is true for PointerEventPass.Main, PointerEventPass.Final or PointerEventPass.Initial changes propagation direction)
  3. If you don't consume events other events like scroll drag can interfere or consume events, most of the events check if it's consumed before propagating to them

detectDragGestures source code for instance

val down = awaitFirstDown(requireUnconsumed = false)
    var drag: PointerInputChange?
    var overSlop = Offset.Zero
    do {
        drag = awaitPointerSlopOrCancellation(
            down.id,
            down.type
        ) { change, over ->
            change.consumePositionChange()
            overSlop = over
        }
    } while (drag != null && !drag.positionChangeConsumed())
  1. So when you need to prevent other events to intercept

    call pointerInputChange.consumeDown() after awaitFirstDown, call pointerInputChange.consumePositionChange() after awaitPointerEvent

    and awaitFirstDown() has requireUnconsumed parameter which is true by default. If you set it to false even if a pointerInput consumes down before your gesture you still get it. This is also how events like drag use it to get first down no matter what.

  2. Every available event you see detectDragGestures, detectTapGestures even awaitFirstDown use awaitPointerEvent for implementation, so using awaitFirstDown, awaitPointerEvent and consuming changes you can configure your own gestures.

For instance, this is a function i customized from original detectTransformGestures only to be invoked with specific number of pointers down.

suspend fun PointerInputScope.detectMultiplePointerTransformGestures(
    panZoomLock: Boolean = false,
    numberOfPointersRequired: Int = 2,
    onGesture: (centroid: Offset, pan: Offset, zoom: Float, rotation: Float) -> Unit,

    ) {
    forEachGesture {
        awaitPointerEventScope {
            var rotation = 0f
            var zoom = 1f
            var pan = Offset.Zero
            var pastTouchSlop = false
            val touchSlop = viewConfiguration.touchSlop
            var lockedToPanZoom = false

            awaitFirstDown(requireUnconsumed = false)

            do {
                val event = awaitPointerEvent()

                val downPointerCount = event.changes.size

                // If any position change is consumed from another pointer or pointer
                // count that is pressed is not equal to pointerCount cancel this gesture
                val canceled = event.changes.any { it.positionChangeConsumed() } || (
                        downPointerCount != numberOfPointersRequired)

                if (!canceled) {
                    val zoomChange = event.calculateZoom()
                    val rotationChange = event.calculateRotation()
                    val panChange = event.calculatePan()

                    if (!pastTouchSlop) {
                        zoom *= zoomChange
                        rotation += rotationChange
                        pan += panChange

                        val centroidSize = event.calculateCentroidSize(useCurrent = false)
                        val zoomMotion = abs(1 - zoom) * centroidSize
                        val rotationMotion =
                            abs(rotation * PI.toFloat() * centroidSize / 180f)
                        val panMotion = pan.getDistance()

                        if (zoomMotion > touchSlop ||
                            rotationMotion > touchSlop ||
                            panMotion > touchSlop
                        ) {
                            pastTouchSlop = true
                            lockedToPanZoom = panZoomLock && rotationMotion < touchSlop
                        }
                    }

                    if (pastTouchSlop) {
                        val centroid = event.calculateCentroid(useCurrent = false)
                        val effectiveRotation = if (lockedToPanZoom) 0f else rotationChange
                        if (effectiveRotation != 0f ||
                            zoomChange != 1f ||
                            panChange != Offset.Zero
                        ) {
                            onGesture(centroid, panChange, zoomChange, effectiveRotation)
                        }
                        event.changes.forEach {
                            if (it.positionChanged()) {
                                it.consumeAllChanges()
                            }
                        }
                    }
                }
            } while (!canceled && event.changes.any { it.pressed })
        }
    }
}

Edit

As of 1.2.0-beta01, partial consumes like PointerInputChange.consemePositionChange(), PointerInputChange.consumeDownChange(), and one for consuming all changes PointerInputChange.consumeAllChanges() are deprecated

PointerInputChange.consume()

is the only one to be used preventing other gestures/event.

Also i have a tutorial here that covers gestures in detail

Thracian
  • 43,021
  • 16
  • 133
  • 222
  • Is there also a way to allow zooming (pinch) out of a layout that contains a clickable areas? i can drag this layout around, but when wanting to pinch, it just presses the buttons. – nyx69 Apr 07 '23 at 03:33
  • 1
    You can chain multiple `Modifier.pointerInput`s the last one being for detecting transform gestures. And other option is to use Final PointerEventPass instead of Main. Would you mind asking this as a separate question with some code or some visuals? – Thracian Apr 07 '23 at 03:56
  • Did that, hope it's more clear here: https://stackoverflow.com/questions/75955466 – nyx69 Apr 07 '23 at 04:30
3

Maybe a bit late, but since compose is constantly updating, this is how I do it as of today:

Modifier
    .pointerInput(Unit) {
        detectTapGestures {...}
     }
    .pointerInput(Unit) {
        detectDragGestures { change, dragAmount ->  ...}
    })

We also have detectHorizontalDragGestures and detectVerticalDragGestures among others to help us.

ps: 1.0.0-beta03

GuilhE
  • 11,591
  • 16
  • 75
  • 116
0

After did some research, looks like can use dragGestureFilter, mixed with tapGestureFilter

Modifier
    .dragGestureFilter(object: DragObserver {
        override fun onDrag(dragDistance: Offset): Offset {
            Log.d("Track", "onActionMove ${dragDistance.x} | ${dragDistance.y}")
            return super.onDrag(dragDistance)
        }
        override fun onStart(downPosition: Offset) {
            Log.d("Track", "onActionDown ${downPosition.x} | ${downPosition.y}")
            super.onStart(downPosition)
        }
        override fun onStop(velocity: Offset) {
            Log.d("Track", "onStop ${velocity.x} | ${velocity.y}")
            super.onStop(velocity)
        }
    }, { true })
    .tapGestureFilter {
        Log.d("NGVL", "onActionUp ${it.x} | ${it.y}")
    }

The reason still use tagGestureFilter, is because the onStop doesn't provide the position, but just the velocity, hence the tapGestureFilter does help provide the last position (if needed)

Elye
  • 53,639
  • 54
  • 212
  • 474