Reputation: 2710
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
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
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
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:
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
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
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
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
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
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