kotlinandroid-jetpack-composeandroid-jetpackandroid-jetpack-compose-gesture

Jetpack Compose - Propagating Clicks to Lower UI Layers


I'm working on creating an "onboarding" experience for my app, which is essentially a quick guide on how to use the application. To achieve this, I want the user to click where the app instructs them to. Specifically, I'd like to create a user interface with a circular element where the user is allowed to click, and a gray transparent background where clicking is not permitted (please refer to the attached image for clarity)

enter image description here

My current challenge lies in figuring out how to propagate this click event to the lower UI layers.

Here's the relevant code snippet:

        Canvas(
        modifier = Modifier
            .fillMaxSize()
            .zIndex(3f)
            .pointerInput(Unit) {
                detectTapGestures(onTap = { offset ->
                    if (!Rect(position, volume).contains(offset)) {
                        Log.d("app-debugger9","Cancel the click!")
                    }
                    else
                    {
                        Log.d("app-debugger9","Propagate click to below UI layers!")
                    }
                })
            }
    ) {
        val circlePath = Path().apply {
            addOval(Rect(position, volume))
        }
        clipPath(circlePath, clipOp = ClipOp.Difference) {
            drawRect(SolidColor(Color.Black.copy(alpha = alphaValue)))
        }
    }

Log.d("app-debugger9", "Propagate click to the lower UI layers!") - This Log.d statement indicates the moment when I intend to propagate the click event to the lower UI layers. My question is: how can I achieve this?

Currently, no matter where I click, inside or outside the circle, I can't propagate that click to the lower UI layer.

What I expect to have is that if I click outside the circle, the click is ignored. However, if I click inside the circle, the click is propagated to the lower UI layer, which leads to the opening of a level (because this circle is positioned above the level open button).

Note: 1) It's possible that this can be accomplished without using 'pointerInput(Unit)', but I'm not sure how to do it, so I opted to use 'pointerInput(Unit)' for now. 2) When I refer to 'above' or 'lower' UI layers, I am using .zIndex(). If the index is larger, it means 'above,' and if the index is smaller, it means 'lower'.


Solution

  • With Modifier.clickable or PointerEventScope.detectTapGestures you can't propagate gestures to descendant or parent because they call PointeEventChange.consume(), you can check out this answer about gestures and gesture propagation in Jetpack Compose. But in your case it looks like you are trying to propagate event to which is not possible, and a bad design in my opinion.

    For anyone that wants to pass a gesture to parent not sibling you can easily write a custom up and down detect gesture using awaitEachGesture.

    @Preview
    @Composable
    private fun GesturePrpoagation() {
    
        val context = LocalContext.current
    
        Box(modifier = Modifier
            .fillMaxSize()
            .border(2.dp, Color.Red)
            .pointerInput(Unit) {
    //            detectTapGestures {
    //                Toast.makeText(context, "Parent tapped", Toast.LENGTH_SHORT).show()
    //            }
                awaitEachGesture {
                    val down: PointerInputChange = awaitFirstDown()
                    val up: PointerInputChange? = waitForUpOrCancellation()
                    Toast.makeText(context, "Parent tapped", Toast.LENGTH_SHORT).show()
                }
            }
        ) {
    
            Box(modifier = Modifier
                .background(Color.Red)
                .fillMaxSize()
                .zIndex(1f)
                .pointerInput(Unit) {
    //                detectTapGestures {
    //                    Toast.makeText(context, "Box1 tapped", Toast.LENGTH_SHORT).show()
    //                }
                    awaitEachGesture {
                        val down: PointerInputChange = awaitFirstDown()
                        val up: PointerInputChange? = waitForUpOrCancellation()
                        Toast.makeText(context, "Box1 tapped", Toast.LENGTH_SHORT).show()
                    }
                }
            )
    
            Box(modifier = Modifier
                .background(Color.Black.copy(alpha = .5f))
                .fillMaxSize()
                .zIndex(2f)
                .pointerInput(Unit) {
    //                detectTapGestures {
    //                    Toast.makeText(context, "Box2 tapped", Toast.LENGTH_SHORT).show()
    //                }
                    awaitEachGesture {
                        val down: PointerInputChange = awaitFirstDown()
                        val up: PointerInputChange? = waitForUpOrCancellation()
                        Toast.makeText(context, "Box2 tapped", Toast.LENGTH_SHORT).show()
                    }
                }
            )
        }
    }
    

    Depending on zIndex of Box1 and Box2 you will see that event will triggered for the one with bigger zIndex, if both zero which is zero by default, will be triggered for second Box then propagated to parent. You can selectively change this by consuming any of these PointerInputChange.

    In OPs case there is no need for second Box. Black transparent layer with circle clip can be drawn using Modifier.drawWithContent and based on touch position you trigger click event in this layer or any Component.

    You can see that while tap gestures are consumed, unless you consume gestures are propagated.

    @Preview
    @Composable
    private fun TouchLayerSample() {
    
        var isTouched by remember {
            mutableStateOf(false)
        }
    
        val context = LocalContext.current
    
        Column(
            modifier = Modifier.fillMaxSize()
                .pointerInput(Unit) {
                    val size = this.size
                    val center = size.center
                    detectTapGestures { offset: Offset ->
    
                        val circleCenter = Offset(
                            x = center.x.toFloat(),
                            y = size.height * .3f
                        )
    
                        // This is for measuring distance from center of circle to
                        // touch position to invoke only invoke when touch is inside circle
                        isTouched = isTouched(
                            center = circleCenter,
                            touchPosition = offset,
                            radius = 200f
                        )
    
                        if (isTouched) {
                            Toast.makeText(context, "Circle is touched", Toast.LENGTH_SHORT).show()
                        }
    
                    }
                }
                .drawWithCache {
    
                    val center = this.size.center
    
                    val circlePath = Path().apply {
                        addOval(
                            Rect(
                                center = Offset(
                                    x = center.x,
                                    y = size.height * .3f
                                ),
                                radius = 200f
                            )
                        )
                    }
                    onDrawWithContent {
                        drawContent()
                        clipPath(circlePath, clipOp = ClipOp.Difference) {
                            drawRect(SolidColor(Color.Black.copy(alpha = .8f)))
                        }
    
                    }
                }
        ) {
    
            // This can be any content that is behind transparent black layer.
    
            // If you have a clickable component here you can use isTouched with custom gesture in previous example to not consume it inside detectTapGesture, so it can be propagated to component. In that case you need to also change PointerEventPass to Initial to propagate from ancestor to descendant.
    
            Image(
                modifier = Modifier.fillMaxSize(),
                painter = painterResource(R.drawable.landscape1),
                contentDescription = null,
                contentScale = ContentScale.FillBounds
            )
        }
    }
    
    private fun isTouched(center: Offset, touchPosition: Offset, radius: Float): Boolean {
        return center.minus(touchPosition).getDistanceSquared() < radius * radius
    }
    

    enter image description here