android-jetpack-composetooltipandroid-jetpack-compose-material3

Jetpack Compose Tooltip in DropdownMenu not following anchor


I'm writing a button that similar to the FAB that locates at the bottom right, it's supposed to work like:

Click the Button -> Popup a DropdownMenu with an "info" icon -> Click the icon to show a Tooltip beside it.

bug

But the tooltip is not aligned to the "info" icon, why is that?

The code:

class MainActivity : ComponentActivity() {
    @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Scaffold {
                // Full fill the entire screen
                Box(modifier = Modifier.fillMaxSize()) {

                    // This Box is for aligning the content at the bottom end,
                    //  and DropdownMenu requires a Box container.
                    Box(
                        modifier = Modifier.align(Alignment.BottomEnd)
                    ) {
                        var dropdownExpanded by remember { mutableStateOf(false) }

                        Button(
                            onClick = { dropdownExpanded = !dropdownExpanded },
                        ) {
                            Text("dropdown")
                        }

                        DropdownMenu(
                            expanded = dropdownExpanded,
                            onDismissRequest = {},
                        ) {
                            TooltipImage() // the only menu item
                        }
                    }
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TooltipImage() {
    val tooltipState = rememberTooltipState(isPersistent = true)
    val scope = rememberCoroutineScope()

    TooltipBox(
        positionProvider = TooltipDefaults.rememberRichTooltipPositionProvider(),
        tooltip = {
            RichTooltip { Text("tooltip content") }
        },
        state = tooltipState
    ) {
        Image(
            painter = painterResource(id = android.R.drawable.ic_menu_info_details),
            contentDescription = "",
            modifier = Modifier
                .clickable {
                    scope.launch { tooltipState.show() }
                }
        )
    }
}

Edited:

The answer is correct, however, when I add a TextField on that Dropdown and when it's focused, the input caret is also mis-aligned at the top. The TextField doesn't seem to support the position provicer, I ended up using popup dialog instead of dropdown menu.


Solution

  • The reason this happens is when you open a DropDownMenu which is a PopUp you create a new window with anchor positioned in (0,22) on my emulator, you can check how a DropDown and Tooltip is positioned in this answer.

    And if you debug TooltipDefaults.rememberRichTooltipPositionProvider() you can see anchorBounds are not where they supposed to be on screen.

    What you can do get position of DropDownMenu on screen with

    Modifier
        .onGloballyPositioned {
            offset = it.positionOnScreen()
        }
    

    write your own PopupPositionProvider with this offset for TooltipBox as

    @Composable
    fun rememberPositionProvider(
        spacingBetweenTooltipAndAnchor: Dp = SpacingBetweenTooltipAndAnchor,
        userOffset: IntOffset = IntOffset.Zero
    ): PopupPositionProvider {
        val tooltipAnchorSpacing = with(LocalDensity.current) {
            spacingBetweenTooltipAndAnchor.roundToPx()
        }
        return remember(tooltipAnchorSpacing, userOffset) {
            object : PopupPositionProvider {
                override fun calculatePosition(
                    anchorBounds: IntRect,
                    windowSize: IntSize,
                    layoutDirection: LayoutDirection,
                    popupContentSize: IntSize
                ): IntOffset {
    
                    val newBounds = anchorBounds.translate(userOffset)
    
                    var x = newBounds.right
                    // Try to shift it to the left of the anchor
                    // if the tooltip would collide with the right side of the screen
                    if (x + popupContentSize.width > windowSize.width) {
                        x = newBounds.left - popupContentSize.width
                        // Center if it'll also collide with the left side of the screen
                        if (x < 0)
                            x = newBounds.left +
                                    (newBounds.width - popupContentSize.width) / 2
                    }
    
                   // 🔥 This is a line i added you might check for right side
                   // overflowing as well
                    x += popupContentSize.width / 2 + anchorBounds.width / 2
    
                    // Tooltip prefers to be above the anchor,
                    // but if this causes the tooltip to overlap with the anchor
                    // then we place it below the anchor
                    var y = newBounds.top - popupContentSize.height - tooltipAnchorSpacing
                    if (y < 0)
                        y = newBounds.bottom + tooltipAnchorSpacing
                    return IntOffset(x, y)
                }
            }
        }
    }
    
    internal val SpacingBetweenTooltipAndAnchor = 4.dp
    

    And apply it as

    @Preview
    @Composable
    fun TooltipTest() {
        Scaffold {
            // Full fill the entire screen
            Box(modifier = Modifier.fillMaxSize()) {
    
                // This Box is for aligning the content at the bottom end,
                //  and DropdownMenu requires a Box container.
                Box(
                    modifier = Modifier.align(Alignment.BottomEnd)
                ) {
                    var dropdownExpanded by remember { mutableStateOf(false) }
    
                    Button(
                        onClick = { dropdownExpanded = !dropdownExpanded },
                    ) {
                        Text("dropdown")
                    }
    
                    var offset by remember {
                        mutableStateOf(Offset.Zero)
                    }
    
                    DropdownMenu(
                        modifier = Modifier
                            .onGloballyPositioned {
                                offset = it.positionOnScreen()
                            },
                        expanded = dropdownExpanded,
                        onDismissRequest = {},
                    ) {
                        TooltipImage(
                            offset = offset.round()
                        ) // the only menu item
                    }
                }
    
            }
        }
    }
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun TooltipImage(
        offset: IntOffset
    ) {
        val tooltipState = rememberTooltipState(isPersistent = true)
        val scope = rememberCoroutineScope()
    
        TooltipBox(
            positionProvider = rememberPositionProvider(userOffset = offset),
            tooltip = {
                RichTooltip { Text("tooltip content") }
            },
            state = tooltipState
        ) {
            Image(
                painter = painterResource(id = android.R.drawable.ic_menu_info_details),
                contentDescription = "",
                modifier = Modifier
                    .clickable {
                        scope.launch { tooltipState.show() }
                    }
            )
        }
    }
    

    Result

    enter image description here