Android Compose, Anchor the ExposedDropdownMenu to the TextField cursor

197 views Asked by At

I am creating an sample screen in compose, where i need to show the dropdown with suggesstions while the user is typing without losing the TextField focus.

Desired Output:

When i have used ExposedDropdownMenuBox, dropdown is showing on top of TextField.

@Composable
fun AutoCompleteTextView(
    modifier: Modifier = Modifier,
    textFieldValue: TextFieldValue = TextFieldValue(),
    onTextChanged: ((TextFieldValue) -> Unit) = ::onTextChnaged,
    textStyle: TextStyle = TextStyle.Default,
    singleLine: Boolean = false
) {
    val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5")

    var isDropdownVisible by remember { mutableStateOf(false) }
    var expanded by remember { mutableStateOf(false) }
    var selectedOptionText by remember { mutableStateOf("") }

    ExposedDropdownMenuBox(
        modifier = Modifier,
        expanded = expanded,
        onExpandedChange = { expanded = it }) {
        TextField(
            value = textFieldValue,
            onValueChange = { value ->
                onTextChanged(value)
                value.selection.start
                isDropdownVisible = value.text.isNotEmpty() && value.text.first() == '@'
            },
            label = { Text("Enter your text here.") },
            trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
            textStyle = textStyle,
            singleLine = singleLine,
            modifier = modifier.menuAnchor(),
        )

        // filter options based on text field value
        val filteringOptions = options.filter { it.contains(selectedOptionText, ignoreCase = true) }
        if (filteringOptions.isNotEmpty()) {
            ExposedDropdownMenu(
                expanded = expanded,
                onDismissRequest = { expanded = false },
            ) {
                filteringOptions.forEach { selectionOption ->
                    DropdownMenuItem(
                        text = { Text(selectionOption) },
                        onClick = {
                            selectedOptionText = selectionOption
                            expanded = false
                        },
                        contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding,
                    )
                }
            }
        }
    }

    BackHandler(enabled = isDropdownVisible) {
        isDropdownVisible = false
    }


}

fun onTextChnaged(textFieldValue: TextFieldValue): Unit {

}

Output image:

1

There are 1 answers

0
sajjad soltani On

In Android Compose, to anchor the ExposedDropdownMenu to the TextField cursor and show suggestions as a user types, you need to adjust the position of your ExposedDropdownMenu so that it appears near the text cursor (caret), and update the filtering logic to respond to text changes. Here's an updated version of your AutoCompleteTextView Composable that includes the positioning of the DropdownMenu based on the TextField cursor.

@Composable
fun AutoCompleteTextView(
    modifier: Modifier = Modifier,
    textFieldValue: TextFieldValue = TextFieldValue(),
    onTextChanged: (TextFieldValue) -> Unit,
    textStyle: TextStyle = TextStyle.Default,
    singleLine: Boolean = true
) {
    var isDropdownVisible by remember { mutableStateOf(false) }
    var textFieldSize by remember { mutableStateOf(Size.Zero) }
    var cursorPosition by remember { mutableStateOf(Offset.Zero) }
    val coroutineScope = rememberCoroutineScope()

    val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5")
    var expanded by remember { mutableStateOf(false) }
    var selectedOptionText by remember { mutableStateOf("") }

    ExposedDropdownMenuBox(
        modifier = modifier
            .onGloballyPositioned { coordinates ->
                textFieldSize = coordinates.size.toSize()
            },
        expanded = isDropdownVisible,
        onExpandedChange = {
            expanded = it
            isDropdownVisible = it
        }
    ) {
        TextField(
            value = textFieldValue,
            onValueChange = { value ->
                onTextChanged(value)
                isDropdownVisible = value.text.isNotEmpty()
            },
            singleLine = singleLine,
            textStyle = textStyle,
            label = { Text("Enter your text") },
            modifier = modifier
                .onGloballyPositioned { layoutCoordinates ->
                    // Calculate the current cursor position
                    val textLayoutResult = textFieldValue.layoutResult?.value
                    if (textLayoutResult != null) {
                        val cursorRect = textLayoutResult.getCursorRect(textFieldValue.selection.start)
                        cursorPosition = Offset(
                            x = cursorRect.left,
                            y = textFieldSize.height
                        )
                        // Optional: Scroll TextField so the cursor is always visible
                        // coroutineScope.launch { /* Scroll logic */ }
                    }
                },
            trailingIcon = {
                ExposedDropdownMenuDefaults.TrailingIcon(
                    expanded = isDropdownVisible
                )
            },
        )

        // Calculate dropdown position based on the cursor
        DropdownMenu(
            expanded = isDropdownVisible,
            onDismissRequest = { isDropdownVisible = false },
            offset = DpOffset(cursorPosition.x.dp, cursorPosition.y.dp)
        ) {
            options.filter { it.contains(selectedOptionText, ignoreCase = true) }.forEach { option ->
                DropdownMenuItem(
                    onClick = {
                        selectedOptionText = option
                        isDropdownVisible = false
                    }
                ) {
                    Text(text = option)
                }
            }
        }
    }

    // Handle back press
    BackHandler(enabled = isDropdownVisible) {
        isDropdownVisible = false
    }
}

Key changes and considerations:

  1. A modifier has been applied to TextField to calculate the global position of the text field and get the size, which is later used to determine the dropdown position.

  2. The onGloballyPositioned modifier is used to calculate the cursor position within the TextField.

  3. The dropdown menu is positioned using offset which takes cursorPosition to position the dropdown menu relative to the cursor within the TextField.

  4. You'll want to make sure that the cursor position is updated every time the text changes in such a way that it might affect the cursor's position on the screen. This includes changes to the TextFieldValue.

This is a basic implementation and could be further refined based on specific UI and UX requirements. For example, handling screen rotations, complex UI layouts, handling keyboard appearance, etc., would require additional work.