Jetpack Compose – A Simple Opiniated AutoCompleteTextView

Jetpack Compose – A Simple Opiniated AutoCompleteTextView

When working on migrating the AutoCompleteTextView in my existing app to a new app with Jetpack Compose, I noticed that there is no out of the box functionality today in Compose. So I decided to write my own AutoCompleteTextView  to achieve similar behavior.

Breaking Down AutoCompleteTextView

At it's core, we rely on two components for the auto complete feature:

  • Query Search: this is the text field where we type in the text and have the suggestions be shown underneath
  • Suggestions: suggestions we want to show for the entered text and allow selection of a suggestion.

We define these in our custom AutoCompleteTextView composable function that we tie together to get the desired behavior.

For my use case, I want to show address suggestions when a user starts to type in the address, I am using a simple OutlinedTextField to type in the query that we would than use to surface the data. Let's look at the parameters we need for this composable:

  • modifier: parameter to provide any desired styling as needed from the parent composable.
  • query: text that the user enters
  • label: provide the label that you want to display on the text field. For example, Address
  • onDoneActionClick: event handler to notify the parent composable when the user has clicked on the done icon on the keyboard. This is useful to make remove the focus on the current field to hide the keyboard.

     

  • onClearClick: similar to onDoneActionClick, used to notify parent composable when the user has clicked the clear button on the text field.
  • onQueryChanged: event handler to notify the parent when the text has changed. This is the main event that we rely on to get the suggestions.

Here is our completed QuerySearch composable.

@Composable
fun QuerySearch(
    modifier: Modifier = Modifier,
    query: String,
    label: String,
    onDoneActionClick: () -> Unit = {},
    onClearClick: () -> Unit = {},
    onQueryChanged: (String) -> Unit
) {


    var showClearButton by remember { mutableStateOf(false) }

    
    OutlinedTextField(
        modifier = modifier
            .fillMaxWidth()
            .onFocusChanged { focusState ->
                showClearButton = (focusState.isFocused)             
            },
        value = query,
        onValueChange = onQueryChanged,
        label = { Text(text = label) },
        textStyle = MaterialTheme.typography.subtitle1,
        singleLine = true,
        trailingIcon = {
            if (showClearButton) {
                IconButton(onClick = { onClearClick() }) {
                    Icon(imageVector = Icons.Filled.Close, contentDescription = "Clear")
                }
            }

        },
        keyboardActions = KeyboardActions(onDone = {
            onDoneActionClick()
        }),
        keyboardOptions = KeyboardOptions(
            imeAction = ImeAction.Done,
            keyboardType = KeyboardType.Text
        )
    )


}
AutoCompleteTextView.kt

Suggestions

Now that we have our QuerySearch in place, let's look at how to define our AutoCompleTextView composable.

We will define our composable  with the below parameters:

  • modifier: used for passing any desired styles from the parent
  • query: parameter that holds the state of the text that the user enters. This will be passed from the parent and also passed down to the child composable QuerySearch
  • queryLabel: label that is passed down to the QuerySearch composable
  • onQueryChanged: event handler that is sent to the parent, to perform the actions needed to build the values for the predictions.
  • predictions: a generic list that holds the suggestions that need to be shown to the user.
  • onDoneActionClick: pass the done click event to the parent. Used to clear out the predictions parameter on the parent.
  • onClearClick: pass the clear click event, also used to clear out the predictions on the parent.
  • onItemClick: event to pass the selected item to the parent.
  • itemContent: composable that defines how each item should be placed on the layout.
@Composable
fun <T> AutoCompleteTextView(
    modifier: Modifier,
    query: String,
    queryLabel: String,
    onQueryChanged: (String) -> Unit = {},
    predictions: List<T>,
    onDoneActionClick: () -> Unit = {},
    onClearClick: () -> Unit = {},
    onItemClick: (T) -> Unit = {},
    itemContent: @Composable (T) -> Unit = {}
) 
AutoCompleteTextView.kt

Next, we define some local state variables

val view = LocalView.current
val lazyListState = rememberLazyListState()

We use the view variable to change the focus of the element and lazyListState holds the state for the LazyColumn.

For showing the suggestions, we could either use a Column or LazyColumn. I prefer LazyColumn as it only composes the visible items and we could also specify our QuerySearch composable as the header using the item. Dynamic suggestions are than rendered using items.

    LazyColumn(
        state = lazyListState,
        modifier = modifier.heightIn(max = TextFieldDefaults.MinHeight * 6)
    ) {
    
    
    }

Notice that I defined the max height for the composable to take the height of the OutlinedTextField, multiplied by 6(1 for the QuerySearch and the remaining 5 for predictions), as I only use a total of 5 predictions for my use case.

If we do not specify this height, we will run into the below error when using nested scrolling.

Nesting scrollable in the same direction layouts like LazyColumn and Column(Modifier.verticalScroll()) is not allowed. If you want to add a header before the list of items please take a look on LazyColumn component which has a DSL api which allows to first add a header via item() function and then the list of items via items().

Let's now call our QuerySearch composable that we previously built as the header on the LazyColumn using the item.

item {
    QuerySearch(
         query = query,
         label = queryLabel,
         onQueryChanged = onQueryChanged,
         onDoneActionClick = {
            view.clearFocus()
            onDoneActionClick()
          },
         onClearClick = {
            onClearClick()
         }
   )
}
AutoCompleteTextView.kt

Notice that we are passing some parameters we defined on our AutoCompleteTextView composable function as the arguments to our child QuerySearch function.

Finally we defined the content for our suggestions and show it only if the number of  predictions are greater than zero. Since we will be changing the predictions every time the query is changed, this will be a good check to prevent old content from being shown. We have to make sure to clear out the list before performing any searches.

if (predictions.count() > 0) {
     items(predictions) { prediction ->
       Row(
          Modifier
              .padding(8.dp)
              .fillMaxWidth()
              .clickable {
                  view.clearFocus()
                  onItemClick(prediction)
              }
        ) {
             itemContent(prediction)
        }
      }
}
AutoCompleteTextView.kt

Combining all the above changes our final code will looks like this

Composable
fun <T> AutoCompleteTextView(
    modifier: Modifier,
    query: String,
    queryLabel: String,
    onQueryChanged: (String) -> Unit = {},
    predictions: List<T>,
    onDoneActionClick: () -> Unit = {},
    onClearClick: () -> Unit = {},
    onItemClick: (T) -> Unit = {},
    itemContent: @Composable (T) -> Unit = {}
) {

    val view = LocalView.current
    val lazyListState = rememberLazyListState()
    LazyColumn(
        state = lazyListState,
        modifier = modifier.heightIn(max = TextFieldDefaults.MinHeight * 6)
    ) {

        item {
            QuerySearch(
                query = query,
                label = queryLabel,
                onQueryChanged = onQueryChanged,
                onDoneActionClick = {
                    view.clearFocus()
                    onDoneActionClick()
                },
                onClearClick = {
                    onClearClick()
                }
            )
        }

        if (predictions.count() > 0) {
            items(predictions) { prediction ->
                Row(
                    Modifier
                        .padding(8.dp)
                        .fillMaxWidth()
                        .clickable {
                            view.clearFocus()
                            onItemClick(prediction)
                        }
                ) {
                    itemContent(prediction)
                }
            }
        }
    }
}
AutoCompleteTextView.kt

Usage

Let's look at how to call this Composable using an example. I have a form where the user can type in the address and it will return the suggestions.

AutoCompleteTextView(
    modifier = Modifier.fillMaxWidth(),
    query = locationItem.streetAddress,
    queryLabel = stringResource(id = R.string.location_section_address),
    onQueryChanged = { updatedAddress ->

        locationItem.streetAddress = updatedAddress
       //Todo: call the view model method to update addressPlaceItemPredictions 
    },
    predictions = addressPlaceItemPredictions,
    onClearClick = {
        
        locationItem.streetAddress = ""
    
    //Todo: call the view model method to clear the predictions
    
    },
    onDoneActionClick = { newLocationUIAction(NewLocationUIAction.OnLocationAutoCompleteDone) },
    onItemClick = { placeItem ->
      
      //Todo: call the view model method to update the UI with the selection
      
    }
) {

    //Define how the items need to be displayed
    Text(it.address, fontSize = 14.sp)

}
Sample Usage

This would produce the following result

As you can see this is a very basic implementation. I did not want to put too much effort into this, as I'm hoping Android will be releasing support for AutoCompleteTextView sometime in the future. You can also customize this implementation to further meet your needs.

Android also has Autofill API but I haven't explored it. Let me know what you thought about this implementation and if there are more easier ways to accomplish this.