Does Jetpack Compose offer a Material AutoComplete TextView replacement?
Asked Answered
S

7

12

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?

Sociality answered 18/10, 2020 at 23:35 Comment(1)
#67111520Joleen
S
11

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)
                }
            }
        }
    }
}
Sociality answered 14/10, 2021 at 3:33 Comment(3)
thanks for the sample link. the only thing that doesn't work is the filtering. the pop up menu always dismisses weirdly. anyone else?Nierman
@Nierman Maybe it's due to newer Compose (using v1.4.3) or Material3, but I had to adjust the DropdownMenuItem to have the Text() composable declared in the constructor, along with the onclick(), rather than as a Composable in the body of the DropdownMenuItem (using the last Composable parameter thing, forget the name for that). Also you need to specify a Modifier on the TextField calling the .menuAchor() function otherwise you'll get an exception regarding Focusable. It works except I can only type a single character in the textfield if that char exists in the dropdown optionsTwist
Working with v1.5.0-beta2, ExposedDropdownMenu#onDismissRequest is called even when TextField is focused and during typing.Sheepcote
G
10

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"
    )
Gereld answered 8/10, 2021 at 7:6 Comment(0)
C
1

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")
                }
            }
        }
    }
}
Chantry answered 12/9, 2021 at 5:41 Comment(2)
While this link may answer the question, it is better to include the essential parts of the answer here and provide the link for reference. Link-only answers can become invalid if the linked page changes. - From ReviewCharming
Hey thanks for letting me know since I am new here I didn't think of that now I have included the code in the answer itself.Chantry
A
1

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)
                            }
                        )
                    }
                }
            }
        }
    }
}


Athiste answered 16/7, 2023 at 0:35 Comment(1)
Your answer could be improved with additional supporting information. Please edit to add further details, such as citations or documentation, so that others can confirm that your answer is correct. You can find more information on how to write good answers in the help center.Herat
P
1

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!

Puissant answered 13/7 at 9:37 Comment(1)
At least show what MyCustomAutocompleteDropdownItem and MyCustomTextField looks likeTigon
T
1

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
}
Tigon answered 24/8 at 4:13 Comment(1)
I really like the debounce, that's a useful patternSociality
F
-1

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

Fey answered 21/10, 2020 at 16:38 Comment(2)
Have you made a custom composable like you described? It would be nice to see an example of what a finished solution looks like.Sociality
I'm sorry I didnt implement that but I know a lib that you could use as AutoCompleteText (you will need to implement a couple of things but this lib will save a lot of your time). Check it out : github.com/skydoves/Orchestra#spinnerFey

© 2022 - 2024 — McMap. All rights reserved.