android-jetpack-composeandroid-jetpack-compose-gesture

How can I keep my view centered on the user's finger during drag


I have a cursor icon that I'd like to keep centered on the user's finger as they drag it, but due to touch slop and a variety of other things, it doesn't stay centered by simply updating the view's offset via the drag amount in onDrag.

I thought I could compute the global X Y of the touch point and just set it to that but it's oddly difficult to get that value in Compose without doing discouraged things like using the pointerInterop modifier.

     onDrag = { change, dragAmount ->
                    change.consume()

                    params.x += dragAmount.x.toInt()
                    params.y += dragAmount.y.toInt()
                    wm.updateViewLayout(composeView, params)
                   
                }

UPDATE 1

There's gotta be some obvious math I'm just not seeing. I'm able to sync the view's centerpoint with the user's finger on DragStart by shifting the view by the offset like here:

onDragStart = {
    params.x = params.x + it.x.toInt() - (composeView.width / 2)
    params.y = params.y + it.y.toInt() - (composeView.height / 2)
    wm.updateViewLayout(composeView, params)
},
onDrag = { change, dragAmount ->
    change.consume()
    //cant do that here though so doing the normal way below
    // params.x = params.x + change.position.x.toInt() - (composeView.width / 2)
    // params.y = params.y + change.position.y.toInt() - (composeView.height / 2)
    params.x += dragAmount.x.toInt()
    params.y += dragAmount.y.toInt()
   
    wm.updateViewLayout(composeView, params)
}

If I comment out the dragamount incremental way and use the adjusted way, it goes erratic when you drag it, jumping around crazy.

I tried some other techniques like using awaitEachGesture (seen below) with drag to get rid of the touch slop detection but that doesn't get me any closer.
The real problem seems to occur when the user drags VERY slow. If you drag very slow, for some reason your finger is allowed to creep away from the view. It's like the view doesn't respond to very tiny movements.
All I want to do is keep the view centered on the touch point. It seems like it should just be some formula with existing available params..

 .pointerInput(Unit) {
                awaitEachGesture {
                    val down = awaitFirstDown()

                    drag(down.id) { change ->
                        val posChange = change.positionChange()
                        val pos = change.position
                        Log.i("await gesture drag", "change: ${pos}")
                        Log.i("await gesture drag", "poschange: ${posChange}")
                        change.consume()
                        params.x += posChange.x.toInt()
                        params.y += posChange.y.toInt()
                       // params.x = params.x + pos.x.toInt() - (composeView.width / 2)
                       // params.y = params.y + pos.y.toInt() - (composeView.height / 2)
                        wm.updateViewLayout(composeView, params)
                    }

                }
            }

I also created this GIF to show the issues. First you can see the touch point has some drift even if I go fast. It goes to the edge of the circle. Then worse, if I go really slow, it drifts a lot.

Update 2

I just realized it might be because the drag amount has to be rounded to an int for the layout params x and y. I'll investigate that tomorrow.

imgur GIF of issue

Update 3

My theory about rounding was correct. When you drag very slowly, the change position/drag amount float .toInt() was rounding down to zero and causing no movement. Changing it to roundToInt also doesnt work because then slow movements slowly crawl past your finger as it rounds up. The solution for me was to use a float accumulator so that the small drag amounts aren't swallowed. I'm guessing this is because I'm moving around a parent ComposeView rather than from the touched element's own .offset modifier? I'm not really sure what the difference is because I know you don't need an accumulator for the usual drag technique, maybe theres a hidden accumulator under the hood?

            .pointerInput(Unit) {

                val center = size.center

                awaitEachGesture {
                    val down = awaitFirstDown()

                    params.x += (down.position.x - center.x).roundToInt()
                    params.y += (down.position.y - center.y).roundToInt() 
                    wm.updateViewLayout(composeView, params)

                    drag(down.id) { change ->
                        val posChange = change.positionChange()

                        change.consume()

                        accumulatedX += posChange.x
                        accumulatedY += posChange.y

                        val deltaX = accumulatedX.toInt()
                        val deltaY = accumulatedY.toInt()

                        accumulatedX -= deltaX
                        accumulatedY -= deltaY

                        params.x += deltaX
                        params.y += deltaY
                        wm.updateViewLayout(composeView, params)
                    }

                }
            }

Solution

  • If it's possible to add touch gesture on parent you can check if touched position is in your composeView and check drag as

    Result

    enter image description here

    @Preview
    @Composable
    fun DragFromCenterTest() {
    
        var offset by remember {
            mutableStateOf(Offset.Zero)
        }
    
        var childSize by remember {
            mutableStateOf(IntSize.Zero)
        }
    
        var isTouched by remember {
            mutableStateOf(false)
        }
        Box(
            modifier = Modifier.fillMaxSize()
    
                .pointerInput(Unit) {
    
                    awaitEachGesture {
                        val down = awaitFirstDown()
    
                        val position = down.position
                        isTouched = position.minus(offset)
                            .getDistanceSquared() < childSize.width * childSize.width
                        do {
    
                            //This PointerEvent contains details including
                            // event, id, position and more
                            val event: PointerEvent = awaitPointerEvent()
    
                            event.changes.firstOrNull()?.let { pointerInputChange ->
                                if (isTouched) {
    
                                    val position = pointerInputChange.position
                                    
                                    offset =
                                        Offset(
                                            position.x - childSize.width / 2,
                                            position.y - childSize.height / 2
                                        )
                                }
                            }
    
    
                        } while (event.changes.any { it.pressed })
                    }
    
                    // Alternative 2 with drag
    //                detectDragGestures(
    //                    onDragStart = { position ->
    //                         isTouched = position.minus(offset)
    //                            .getDistanceSquared() < childSize.width * childSize.width
    //
    //                        if (isTouched) {
    //                            offset =
    //                                Offset(
    //                                    position.x - childSize.width / 2,
    //                                    position.y - childSize.height / 2
    //                                )
    //                        }
    //                    },
    //                    onDrag = { change, dragAmount ->
    //
    //                        val position = change.position
    //
    //                        if (isTouched) {
    //                            offset =
    //                                Offset(
    //                                    position.x - childSize.width / 2,
    //                                    position.y - childSize.height / 2
    //                                )
    //                        }
    //                    }
    //                )
                }
        ) {
    
            Draggable(
                modifier = Modifier
                    .onSizeChanged {
                        childSize = it
                    }
                    .offset {
                        IntOffset(offset.x.toInt(), offset.y.toInt())
                    }
                    .drawWithContent {
                        drawContent()
                        drawCircle(
                            color = Color.Red,
                            radius = 10.dp.toPx()
                        )
                    }
            )
        }
    }
    
    @Composable
    fun Draggable(
        modifier: Modifier,
    ) {
        Box(modifier.size(100.dp).background(Color.Blue, CircleShape))
    }
    

    If you wish to add gesture to child you need to calculate distance of first touch to center of your Composable which is as

    @Preview
    @Composable
    fun DragFromCenterTest2() {
    
        var offset by remember {
            mutableStateOf(Offset.Zero)
        }
    
        Box(
            modifier = Modifier.fillMaxSize()
        ) {
    
            Draggable(
                modifier = Modifier
    
                    .offset {
                        IntOffset(offset.x.toInt(), offset.y.toInt())
                    }
    
                    .pointerInput(Unit) {
    
                        val size = size
                        val center = size.center
                        awaitEachGesture {
    
                            val down = awaitFirstDown()
    
                            val firstDown = down.position
    
                            val distanceToCenter =
                                Offset(firstDown.x - center.x, firstDown.y - center.y)
                            // Move current position to first down position to center it at first
                            // touch position
                            offset += distanceToCenter
    
                            do {
    
                                val event: PointerEvent = awaitPointerEvent()
    
                                event.changes.firstOrNull()?.let { pointerInputChange ->
    
                                    val position = pointerInputChange.positionChange()
    
                                    offset += position
    
                                }
    
                            } while (event.changes.any { it.pressed })
                        }
                    }
                    .drawWithContent {
                        drawContent()
                        drawCircle(
                            color = Color.Red,
                            radius = 10.dp.toPx()
                        )
                    }
            )
        }
    }