kotlinandroid-jetpack-composedialogpopupoffset

How do you detect if the user has clicked outside of a composable using Jetpack Compose?


Using Jetpack Compose, I'm trying to create a custom popup without using the built in popup composable because the latter doesn't allow for animated entry. I've got it working mostly. The popup appears above everything on the screen in the location of my choosing. The only problem is that I can't make the popup disappear when clicking outside of it's bounds.

I tried to develop this feature using the onGloballyPositioned modifier on the custom popup and capturing it's size and position into variables. I then put a pointer input modifier around the entire screen, creating a conditional within it that detects if the position of the pointer event was within the position of the popup. It didn't work.

Here is the code for my failed implementation.

// Variables Initialized Outside Of An Activity, ViewModel, or Composable
var isPopUpOpen by mutableStateOf(false)
var size by mutableStateOf(Offset.Zero)
var position by mutableStateOf(Offset.Zero)

// Popup Composable
@Composable
fun CustomPopUp() {

   AnimatedVisibility(
       visible = isPopUpOpen,
       enter = fadeIn(),
       exit = fadeOut(),
   ) {

            Box(
                modifier = Modifier
                    .offset(x = 90.dp, y = 150.dp)
                    .onGloballyPositioned {
                        size = Offset(it.size.width.toFloat(), it.size.height.toFloat())
                        position = Offset(it.positionInRoot().x, it.positionInRoot().y)
                    }
                    .background(Color.Blue)
                    .size(200.dp)
                       
       )
    }
}

// Main UI; Located In Main Activity

    CustomPopUp()

      Box(
         modifier = Modifier
             // Click Outside Logic
             .pointerInput(isPopUpOpen) {
                 while (isPopUpOpen) {
                     awaitPointerEventScope {
                         if (awaitFirstDown().position != position) {
                             isPopUpOpen = false
                                } 
                            }
                        }
                    }
                    .background(if (isPopUpOpen) Color.Black.copy(alpha = 0.2f) else Color.Transparent)
                    .fillMaxSize(),
            ) {
                Column(
                    modifier = Modifier.fillMaxSize(),
                    horizontalAlignment = Alignment.CenterHorizontally,
                    verticalArrangement = Arrangement.Center
                ) {
                    // Button That Displays Popup
                    Button(onClick = { isPopUpOpen = !isPopUpOpen }) { }

                }
            }
 

Solution

  • The mistake is you are comparing first touch position with top left position of your custom PopUp.

    enter image description here

    You should get Rect from onGloballyPositioned and check if touch is not inside Rect of your Composable while popUp is open.

    var rect by mutableStateOf(Rect.Zero)
    
    @Preview
    @Composable
    fun PopUpTouchTest() {
        CustomPopUp()
    
        Box(
            modifier = Modifier
                // Click Outside Logic
                .pointerInput(Unit) {
                    awaitEachGesture {
                        val down = awaitFirstDown()
                        val position = down.position
                        if (rect.contains(position).not() && isPopUpOpen) {
                            isPopUpOpen = false
                        }
    
                        waitForUpOrCancellation()
                    }
                }
                .background(if (isPopUpOpen) Color.Black.copy(alpha = 0.2f) else Color.Transparent)
                .fillMaxSize(),
        ) {
            Column(
                modifier = Modifier.fillMaxSize(),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                // Button That Displays Popup
                Button(onClick = { isPopUpOpen = !isPopUpOpen }) {
                    Text("Click")
                }
    
            }
        }
    }
    
    @Composable
    fun CustomPopUp() {
        AnimatedVisibility(
            visible = isPopUpOpen,
            enter = fadeIn(),
            exit = fadeOut(),
        ) {
    
            Box(
                modifier = Modifier
                    .offset(x = 90.dp, y = 150.dp)
                    .onGloballyPositioned {
                      rect = it.boundsInRoot()
                    }
                    .background(Color.Blue)
                    .size(200.dp)
    
            )
        }
    }
    

    Also, default PopUp allows fadeIn/FadeOut or other animations as well.

    Here is a sample with AnimatedVisibility to open/close a popUp with fadeIn/fadeOut or any other available animations for AnimatedVisibility.

    @Composable
    fun AnimatedVisibilityTransitionSample() {
        val visibleState: MutableTransitionState<Boolean> = remember { MutableTransitionState(false) }
        val transition: Transition<Boolean> = rememberTransition(visibleState)
    
        Column(
            modifier = Modifier.fillMaxSize()
        ) {
            Button(
                onClick = {
                    visibleState.targetState = visibleState.targetState.not()
                }
            ) {
                Text("Update Target State")
            }
    
            Text(
                "State currentState: ${visibleState.currentState}\n" +
                        "targetState: ${visibleState.targetState}\n" +
                        "isIdle:  ${visibleState.isIdle}",
                fontSize = 16.sp
            )
    
            if (transition.targetState || transition.currentState) {
                Popup(
                    properties = PopupProperties(focusable = true),
                    offset = IntOffset(200, 400),
                    onDismissRequest = {
                        visibleState.targetState = false
                    }
                ) {
                    transition.AnimatedVisibility(
                        visible = { targetSelected -> targetSelected },
                        enter = fadeIn(
                            animationSpec = tween(600)
                        ),
                        exit = fadeOut(
                            animationSpec = tween(600)
                        )
                    ) {
                        Box(modifier = Modifier.background(Color.Red).padding(16.dp)) {
                            Text("Popup Content...")
                        }
                    }
                }
            }
        }
    }