machfour
machfour

Reputation: 2710

Does Jetpack Compose offer a Material AutoComplete TextView replacement?

In the process of migrating my app to Jetpack compose, I've come to a part of my app where a TextField needs autocompletion functionality.

However, as of version 1.0.0-alpha05, I couldn't find any functionality to achieve this using the Compose API. The closest thing I've found is the DropdownMenu and DropdownMenuItem composeables, but it seems like it would be a lot of manual plumbing required to create an autocomplete menu out of these.

The obvious thing to do is just wait for future updates to Jetpack Compose, of course. But I'm wondering, has anyone who encountered a this issue in their migrations found a solution?

Upvotes: 14

Views: 12374

Answers (8)

hasan.z
hasan.z

Reputation: 339

The code that worked for me is slightly different. You also need to use menuAnchor. It’s a good idea to create a separate component for this, so it can be easily reused throughout the project.

@Composable
fun AutoCompleteTextField(
    modifier: Modifier = Modifier,
    options: PersistentList<String>,
    value: String,
    onValueChange: (String) -> Unit,
    //This is the callback that is triggered when an item is selected from the dropdown
    onItemSelected: (String) -> Unit,
    label: String,
) {
    val filteredOptions = remember(value) {
        options.fastFilter { it.contains(value, ignoreCase = true) }
    }

    val focusRequester = LocalFocusManager.current
    var expanded by remember { mutableStateOf(false) }

    ExposedDropdownMenuBox(
        modifier = modifier,
        expanded = expanded,
        onExpandedChange = { expanded = it }
    ) {
        TextField(
            modifier = Modifier
                // The `menuAnchor` modifier must be passed to the text field to handle
                // expanding/ collapsing the menu on click.
                .menuAnchor(MenuAnchorType.PrimaryEditable)
                //Set this modifier to dismiss the menu when the text field is not in focus,
                //e.g. moving to another text field by using keyboard actions.
                .onFocusChanged { focusState ->
                    if (!focusState.isFocused) {
                        expanded = false
                    }
                },
            value = value,
            onValueChange = onValueChange,
            trailingIcon = {
                ExposedDropdownMenuDefaults.TrailingIcon(
                    expanded = expanded,
                )
            },
            keyboardOptions = KeyboardOptions(
                keyboardType = KeyboardType.Text,
                imeAction = ImeAction.Next
            ),
            singleLine = true,
            label = { Text(text = label) },
            maxLines = 1,
        )

        ExposedDropdownMenu(
            shape = defaultCardShape,
            expanded = expanded,
            onDismissRequest = { expanded = false }) {
            filteredOptions.fastForEach { option ->
                DropdownMenuItem(
                    modifier = Modifier.fillMaxWidth(),
                    text = { Text(text = option) },
                    onClick = {
                        onValueChange(option)
                        onItemSelected(option)
                        expanded = false
                        //Clear focus from this component and move focus to the next one by selecting
                        // an option from the dropdown.
                        focusRequester.moveFocus(FocusDirection.Next)
                    },
                )
            }
        }
    }
}

Here is how to use:

var city by remember { mutableStateOf("") }
val cities = persistentListOf(
    "city 1",
    "city 2",
    "city 3",
    "city 4",
    "city 5",
)

AutoCompleteTextField(
    modifier = modifier,
    options = cities,
    label = "City",
    value = city,
    onValueChange = { city = it },
    onItemSelected = { item ->
        println("selected item: $item")
    }
)

Upvotes: 0

chitgoks
chitgoks

Reputation: 310

In case anyone is interested, here is my version. I added debounce because a big list can lag the text field and i dont want to do a search every time i type something.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T : Listable> AutoCompleteTextField(
    modifier: Modifier = Modifier,
    dropdownColor: Color = MaterialTheme.colorScheme.surface,
    fieldLabel: String,
    fieldError: String? = null,
    keyboardOptions: KeyboardOptions = KeyboardOptions(
        imeAction = ImeAction.Next,
        capitalization = KeyboardCapitalization.Words
    ),
    onSuggestionSelected: (selectedSuggestion: T) -> Unit,
    suggestions: List<T>,
    value: String
) {
    val context = LocalContext.current
    var text by remember { mutableStateOf(value) }
    var isDropdownExpanded by remember { mutableStateOf(false) }
    var filteredSuggestions by remember { mutableStateOf(emptyList<T>()) }
    var debounceJob by remember { mutableStateOf<Job?>(null) }

    LaunchedEffect(text) {
        debounceJob?.cancel()
        debounceJob = launch {
            delay(500)
            filteredSuggestions = suggestions.filter {
                text.isNotEmpty() && it.name.contains(text, ignoreCase = true)
            }
        }
    }

    ExposedDropdownMenuBox(
        modifier = modifier,
        expanded = isDropdownExpanded,
        onExpandedChange = { expanded ->
            isDropdownExpanded = expanded
        }
    ) {
        OutlinedTextField(
            isError = !fieldError.isNullOrEmpty(),
            keyboardOptions = keyboardOptions,
            label = { Text(fieldLabel) },
            maxLines = 1,
            modifier = Modifier
                .fillMaxWidth()
                .onFocusChanged {
                    if (!it.isFocused) {
                        isDropdownExpanded = false
                    }
                }
                .menuAnchor(type = MenuAnchorType.PrimaryEditable, enabled = true),
            onValueChange = {
                text = it
                isDropdownExpanded = it.isNotEmpty()
            },
            readOnly = false,
            supportingText = {
                if (!fieldError.isNullOrEmpty()) {
                    Text(
                        text = fieldError,
                        color = Color.Red,
                        style = TextStyle(fontSize = 12.sp)
                    )
                }
            },
            trailingIcon = {
                if (!fieldError.isNullOrEmpty()) {
                    Icon(Icons.Filled.Error, contentDescription = context.getString(R.string.error), tint = Color.Red)
                }
            },
            value = text
        )

        if (filteredSuggestions.isNotEmpty()) {
            DropdownMenu(
                modifier = Modifier.exposedDropdownSize(),
                containerColor = dropdownColor,
                expanded = isDropdownExpanded,
                onDismissRequest = {
                    isDropdownExpanded = false
                },
                properties = PopupProperties(focusable = false)
            ) {
                filteredSuggestions.forEach { suggestion ->
                    DropdownMenuItem(
                        text = { Text(suggestion.name) },
                        onClick = {
                            onSuggestionSelected(suggestion)
                            text = suggestion.name
                            isDropdownExpanded = false
                        },
                        contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding
                    )
                }
            }
        }
    }
}

interface Listable {
    val id: String
    val name: String
}

Upvotes: 3

Andrei Bichir
Andrei Bichir

Reputation: 46

So, after a lot of pretty words and wondering why the compose Gods made it so hard, I managed to create an autocomplete field that does the following:

  • Filter items as you type (even after the menu pops up)
  • Does not open and closes just by clicking on it, but only filters by the text inserted
  • Does not closes the Keyboard
  • Does not pop back up when clicking an already completed Field, suggesting one option(the text already there)

Below is my code, with some particularities to my app, but with comments on the important aspects. Can be modified as you wish from a filtering perspective, and design.

Note: I am using "androidx.compose.material3:material3:1.3.0-beta04" when writing this answer. Check the most recent version available when you find this comment.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AutocompleteTextField(
    value: TextFieldValue,
    onValueChanged: (TextFieldValue) -> Unit,
    hint: String,
    suggestionsList: Set<String>,
    modifier: Modifier = Modifier,
    keyboardOptions: KeyboardOptions = KeyboardOptions(
        imeAction = ImeAction.Next,
        capitalization = KeyboardCapitalization.Words
    ),
    onDeleteItemClick: (String) -> Unit,
    maxNumberOfCharacters: Int = 35,
    dropdownColor: Color = MaterialTheme.colorScheme.surface
) {
    //Get results filtered as input changes
    val filteredSuggestions = remember(value) {
        if (value.text.isNotEmpty()) {
            suggestionsList.filter {
                it.startsWith(prefix = value.text, ignoreCase = true)
            }.toSet()
        } else {
            emptyList()
        }
    }

    //Decide if the Dropdown is visible based on the filtered results and inserted text
    var expanded by remember(
        filteredSuggestions
    ) {
        mutableStateOf(
            filteredSuggestions.isNotEmpty()
                    && value.text.isNotBlank() // Don't show menu when just selecting text
                    && !filteredSuggestions.contains(value.text) // Don't show as a suggestion the full text already there
        )
    }


    ExposedDropdownMenuBox(
        modifier = modifier,
        expanded = expanded,
        onExpandedChange = {
        //The expansion is triggered by the suggestion list and text changes, not by click
    }
) {
    MyCustomTextField(
        modifier = Modifier
            .fillMaxWidth()
            .onFocusChanged {
                if (!it.isFocused) {
                    //Closes the dropdown when user moves to another field
                    expanded = false
                }
            }
            //Makes it possible to keep writing once the popup shows up.
            //Requires material3 version at least "1.3.0-beta04"
            //Without this line you get a FocusRequester missing error
            .menuAnchor(type = MenuAnchorType.PrimaryEditable),
        value = value,
        onValueChanged = onValueChanged,
        hint = hint,
        keyboardOptions = keyboardOptions,
        maxNumberOfCharacters = maxNumberOfCharacters
    )

    if (filteredSuggestions.isNotEmpty()) {
        //Using DropdownMenu instead ExposedDropdownMenu because we can use the properties
        // to stop the keyboard from closing. As of writing this code using material3 1.3.0-beta04
        //I could not find a way to stop the keyboard from closing using ExposedDropdownMenu
        DropdownMenu(
            modifier = Modifier.exposedDropdownSize(), //Make the dropdown as big as the ExposedDropdownMenuBox
            containerColor = dropdownColor,
            expanded = expanded,
            onDismissRequest = {
                //Closes the popup when used clicks outside
                expanded = false
            },
            properties = PopupProperties(focusable = false) //Stops the dropdown from closing the keyboard
        ) {
            filteredSuggestions.forEach { name ->
                DropdownMenuItem(
                    onClick = {
                        expanded = false
                        onValueChanged(TextFieldValue(name))
                    },
                    text = {
                        MyCustomAutocompleteDropdownItem(
                            modifier = Modifier.fillMaxWidth(),
                            itemName = name,
                            onDeleteItemClick = onDeleteItemClick
                        )
                    }
                )
            }
        }
    }
}
}

Hope this helps you out!

Cheers!

Upvotes: 1

Sagar Karn
Sagar Karn

Reputation: 36

after so many research I have created perfect Autocomplete that's works like AutoCompleteView

just copy paste and use on your project.


import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DropDown(
    items: List<String>,
    onItemSelected: (String) -> Unit,
    selectedItem: String,
    label: String,
    modifier: Modifier = Modifier,
) {
    var expanded by remember { mutableStateOf(false) }
    var listItems by remember(items, selectedItem) {
        mutableStateOf(
            if (selectedItem.isNotEmpty()) {
                items.filter { x -> x.startsWith(selectedItem.lowercase(), ignoreCase = true) }
            } else {
                items.toList()
            }
        )
    }
    var selectedText by remember(selectedItem) { mutableStateOf(selectedItem) }
    
    LaunchedEffect(selectedItem){
        selectedText = selectedItem
    }

    Box(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 2.dp)
    ) {
        ExposedDropdownMenuBox(
            expanded = expanded,
            onExpandedChange = {
                expanded = !expanded
            },
            modifier = Modifier.fillMaxWidth()
        ) {
            TextField(
                value = selectedText,
                label = { Text(label) },
                onValueChange = {
                    if (!expanded) {
                        expanded = true
                    }
                    selectedText = it
                    listItems = if (it.isNotEmpty()) {
                        items.filter { x -> x.startsWith(it.lowercase(), ignoreCase = true) }
                    } else {
                        items.toList()
                    }
                },
                trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
                modifier = Modifier
                    .fillMaxWidth()
                    .menuAnchor()
            )

            ExposedDropdownMenu(
                expanded = expanded,
                onDismissRequest = { expanded = false }
            ) {
                if (listItems.isEmpty()) {
                    DropdownMenuItem(
                        text = { Text(text = "No items found") },
                        onClick = {
                            expanded = false
                        }
                    )
                } else {
                    listItems.forEach { item ->
                        DropdownMenuItem(
                            text = { Text(text = item) },
                            onClick = {
                                selectedText = item
                                expanded = false
                                onItemSelected(item)
                            }
                        )
                    }
                }
            }
        }
    }
}


Upvotes: 1

machfour
machfour

Reputation: 2710

As of compose 1.1.0-alpha06, Compose Material now offers an ExposedDropdownMenu composable, API here, which can be used to implement a dropdown menu which facilitates the autocompletion process. The actual autocompletion logic has to be implemented yourself.

The API docs give the following usage example, for an editable field:

val options = listOf("Option 1", "Option 2", "Option 3", "Option 4", "Option 5")
var exp by remember { mutableStateOf(false) }
var selectedOption by remember { mutableStateOf("") }
ExposedDropdownMenuBox(expanded = exp, onExpandedChange = { exp = !exp }) {
    TextField(
        value = selectedOption,
        onValueChange = { selectedOption = it },
        label = { Text("Label") },
        trailingIcon = {
            ExposedDropdownMenuDefaults.TrailingIcon(expanded = exp)
        },
        colors = ExposedDropdownMenuDefaults.textFieldColors()
    )
    // filter options based on text field value (i.e. crude autocomplete)
    val filterOpts = options.filter { it.contains(selectedOption, ignoreCase = true) }
    if (filterOpts.isNotEmpty()) {
        ExposedDropdownMenu(expanded = exp, onDismissRequest = { exp = false }) {
            filterOpts.forEach { option ->
                DropdownMenuItem(
                    onClick = {
                        selectedOption = option
                        exp = false
                    }
                ) {
                    Text(text = option)
                }
            }
        }
    }
}

Upvotes: 12

Amir Hossein
Amir Hossein

Reputation: 431

No at least till v1.0.2

so I implemented a nice working one in compose available in this gist

I also put it here:

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.window.PopupProperties


@Composable
fun TextFieldWithDropdown(
    modifier: Modifier = Modifier,
    value: TextFieldValue,
    setValue: (TextFieldValue) -> Unit,
    onDismissRequest: () -> Unit,
    dropDownExpanded: Boolean,
    list: List<String>,
    label: String = ""
) {
    Box(modifier) {
        TextField(
            modifier = Modifier
                .fillMaxWidth()
                .onFocusChanged { focusState ->
                    if (!focusState.isFocused)
                        onDismissRequest()
                },
            value = value,
            onValueChange = setValue,
            label = { Text(label) },
            colors = TextFieldDefaults.outlinedTextFieldColors()
        )
        DropdownMenu(
            expanded = dropDownExpanded,
            properties = PopupProperties(
                focusable = false,
                dismissOnBackPress = true,
                dismissOnClickOutside = true
            ),
            onDismissRequest = onDismissRequest
        ) {
            list.forEach { text ->
                DropdownMenuItem(onClick = {
                    setValue(
                        TextFieldValue(
                            text,
                            TextRange(text.length)
                        )
                    )
                }) {
                    Text(text = text)
                }
            }
        }
    }
}

How to use it

val all = listOf("aaa", "baa", "aab", "abb", "bab")

val dropDownOptions = mutableStateOf(listOf<String>())
val textFieldValue = mutableStateOf(TextFieldValue())
val dropDownExpanded = mutableStateOf(false)
fun onDropdownDismissRequest() {
    dropDownExpanded.value = false
}

fun onValueChanged(value: TextFieldValue) {
    dropDownExpanded.value = true
    textFieldValue.value = value
    dropDownOptions.value = all.filter { it.startsWith(value.text) && it != value.text }.take(3)
}

@Composable
fun TextFieldWithDropdownUsage() {
    TextFieldWithDropdown(
        modifier = Modifier.fillMaxWidth(),
        value = textFieldValue.value,
        setValue = ::onValueChanged,
        onDismissRequest = ::onDropdownDismissRequest,
        dropDownExpanded = dropDownExpanded.value,
        list = dropDownOptions.value,
        label = "Label"
    )

Upvotes: 10

Jagadeesh K
Jagadeesh K

Reputation: 886

Checkout this code that I made using XML and using that layout inside compose using AndroidView. We can use this solution until it is included by default in compose.

You can customize it and style it as you want. I have personally tried it in my project and it works fine

<!-- text_input_field.xml -->
<!-- You can style your textfield here in XML with styles -->
<!-- this file should be in res/layout -->

<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.textfield.TextInputLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <AutoCompleteTextView
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="Label"
        android:inputType="none" />

</com.google.android.material.textfield.TextInputLayout>
// TextFieldWithDropDown.kt
// TextField with dropdown was not included by default in jetpack compose (1.0.2) and less

@Composable
fun TextFieldWithDropDown(
    items: List<String>,
    selectedValue: String?,
    modifier: Modifier = Modifier,
    onSelect: (Int) -> Unit
) {
    AndroidView(
        factory = { context ->
            val textInputLayout = TextInputLayout
                .inflate(context, R.layout.text_input_field, null) as TextInputLayout
            
            // If you need to use different styled layout for light and dark themes
            // you can create two different xml layouts one for light and another one for dark
            // and inflate the one you need here.

            val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView
            val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, items)
            autoCompleteTextView?.setAdapter(adapter)
            autoCompleteTextView?.setText(selectedValue, false)
            autoCompleteTextView?.setOnItemClickListener { _, _, index, _ -> onSelect(index) }
            textInputLayout
        },
        update = { textInputLayout ->
            // This block will be called when recomposition happens
            val autoCompleteTextView = textInputLayout.editText as? AutoCompleteTextView
            val adapter = ArrayAdapter(textInputLayout.context, android.R.layout.simple_list_item_1, items)
            autoCompleteTextView?.setAdapter(adapter)
            autoCompleteTextView?.setText(selectedValue, false)
        },
        modifier = modifier
    )
}
// MainActivity.kt
// It's important to use AppCompatActivity instead of ComponentActivity to get the material
// look on our XML based textfield

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        setContent {
            Column {
                TextFieldWithDropDown(
                    items = listOf("One", "Two", "Three"),
                    selectedValue = "Two",
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(16.dp)
                ) {
                    // You can also set the value to a state
                    index -> println("$index was selected")
                }
            }
        }
    }
}

Upvotes: 1

Maiko Trindade
Maiko Trindade

Reputation: 3039

As you said, there is no such component yet. You have two options: create your own custom using DropDownMenu and BaseTextField or using hybrid xml-autocomplete and compose screen through androidx.compose.ui.platform.ComposeView

Upvotes: -1

Related Questions