Guides / Building Search UI / UI & UX patterns / Query Suggestions

Build a Query Suggestions UI with InstantSearch Android

When your user interacts with a search box, you can help them discover what they could search for by providing Query suggestions.

Query suggestions are a specific kind of multi-index interface:

  • The main search interface will use a regular index.
  • As users type a phrase, suggestions from your Query Suggestions index are displayed.

Usage

To display the suggestions:

  • Create a Query Suggestions index from your main index.
  • Implement a Multi-Index search experience using both indices.
  • When clicking on a suggestion, set the query to the chosen suggestion.

Before you begin

To use InstantSearch Android, you need an Algolia account. You can [create a new account][algolia_sign_up], or use the following credentials:

  • Application ID: latency
  • Search API key: afc3dd66dd1293e2e2736a5a51b05c0a
  • Results index name: STAGING_native_ecom_demo_products
  • Suggestions index name: STAGING_native_ecom_demo_products_query_suggestions

These credentials give you access to pre-existing datasets of products and Query Suggestions appropriate for this guide.

Project structure

Algolia’s query suggestions uses:

  • QuerySuggestionGuide: main activity presenting the search experience,
  • SuggestionFragment: fragment presenting the Query Suggestions,
  • ProductFragment: fragment presenting the search results,
  • QuerySuggestionViewModel: view model holding connectors and search business logic.

The initial screen shows the search bar and results for an empty query:

Initial state of the search interface on Android showing the search bar and the results for an empty search query.

When users tap the search box, a list of query suggestions are shown (the most popular for an empty query):

List of popular query suggestions in the search interface on Android.

On each keystroke, the list of suggestions is updated:

The list of Query Suggestions updates on every keystroke.

When users select a suggestion from the list, it replaces the query in the search box, and the suggestions fragment disappears. The products fragment presents search results for the selected query suggestion:

After accepting a query suggestion the results of the search are shown.

Algolia doesn’t provide a ready-to-use views, but you can create them with the tools in the InstantSearch Android library.

Business logic

Create QuerySuggestionViewModel view to setup the search components and create the necessary connections between them, establishing the business logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class QuerySuggestionViewModel : ViewModel() {

    private val client = ClientSearch(
        applicationID = ApplicationID("latency"),
        apiKey = APIKey("927c3fe76d4b52c5a2912973f35a3077"),
        logLevel = LogLevel.ALL
    )
    val multiSearcher = MultiSearcher(client)
    val productSearcher = multiSearcher.addHitsSearcher(indexName = IndexName("STAGING_native_ecom_demo_products"))
    val suggestionSearcher = multiSearcher.addHitsSearcher(indexName = IndexName("STAGING_native_ecom_demo_products_query_suggestions"))
    val searchBox = SearchBoxConnector(multiSearcher)
    val suggestions = MutableLiveData<Suggestion>()

    override fun onCleared() {
        multiSearcher.cancel()
        searchBox.disconnect()
        client.close()
    }
}

Products view

  • Create Product data class corresponding the index hits
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Serializable
data class Product(
    val name: String,
    @SerialName("image_urls") val images: List<String>,
    val price: Price,
    val description: String,
    override val objectID: ObjectID,
    override val _highlightResult: JsonObject?
) : Indexable, Highlightable {

    val highlightedName: HighlightedString?
        get() = getHighlight(Attribute("name"))
}

@Serializable
data class Price(
    val currency: String,
    val value: String,
)
  • Create ProductAdapter to display search results
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class ProductAdapter : ListAdapter<Product, ProductViewHolder>(ProductDiffUtil), HitsView<Product> {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        ProductViewHolder(parent.inflate(R.layout.list_item_large))

    override fun onBindViewHolder(holder: ProductViewHolder, position: Int) =
        holder.bind(getItem(position))

    override fun setHits(hits: List<Product>) = submitList(hits)

    class ProductViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {

        fun bind(item: Product) {
            view.findViewById<TextView>(R.id.itemTitle).text =
                item.highlightedName?.toSpannedString() ?: item.name
            view.findViewById<TextView>(R.id.itemSubtitle).text = item.price.value
            Glide
                .with(view.context)
                .load(item.images.first())
                .into(view.findViewById(R.id.itemImage))
        }
    }

    private object ProductDiffUtil : DiffUtil.ItemCallback<Product>() {
        override fun areItemsTheSame(oldItem: Product, newItem: Product) = oldItem.objectID == newItem.objectID
        override fun areContentsTheSame(oldItem: Product, newItem: Product) = oldItem == newItem
    }
}
  • Create ProductFragment to display product search results:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ProductFragment : Fragment(R.layout.fragment_items) {

    private val viewModel: QuerySuggestionViewModel by activityViewModels()
    private val connection = ConnectionHandler()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // Configure products view
        val productAdapter = ProductAdapter()
        view.findViewById<RecyclerView>(R.id.items).configure(productAdapter) // Configure the RecyclerView with the adapter
        connection += viewModel.productSearcher.connectHitsView(productAdapter) {
            it.hits.deserialize(Product.serializer())
        }

        // Run initial search
        viewModel.productSearcher.searchAsync()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        connection.clear()
    }
}

Suggestions view

  • Create Suggestion data class corresponding the suggestions index hits
1
2
3
4
5
6
7
8
9
10
@Serializable
data class Suggestion(
    val query: String,
    override val objectID: ObjectID,
    override val _highlightResult: JsonObject?
) : Indexable, Highlightable {

    val highlightedQuery: HighlightedString?
        get() = getHighlight(Attribute("query"))
}
  • Create SuggestionAdapter to display suggestions list:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class SuggestionAdapter(private val onSuggestionClick: ((Suggestion) -> Unit)) :
    ListAdapter<Suggestion, SuggestionViewHolder>(SuggestionAdapter),
    HitsView<Suggestion> {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        SuggestionViewHolder(parent.inflate(R.layout.list_item_suggestion))

    override fun onBindViewHolder(holder: SuggestionViewHolder, position: Int) {
        val item = getItem(position)
        holder.bind(item, onSuggestionClick)
    }

    override fun setHits(hits: List<Suggestion>) = submitList(hits)

    class SuggestionViewHolder(private val view: View) : RecyclerView.ViewHolder(view) {

        fun bind(item: Suggestion, onClick: ((Suggestion) -> Unit)) {
            view.setOnClickListener { onClick(item) }
            view.findViewById<TextView>(R.id.itemName).text = item.highlightedQuery?.toSpannedString() ?: item.query
        }
    }

    companion object : DiffUtil.ItemCallback<Suggestion>() {
        override fun areItemsTheSame(oldItem: Suggestion, newItem: Suggestion) =
            oldItem.objectID == newItem.objectID

        override fun areContentsTheSame(oldItem: Suggestion, newItem: Suggestion): Boolean =
            oldItem == newItem
    }
}
  • Create SuggestionFragment to display suggestions’ recycler view adapter, and to notify QuerySuggestionViewModel when a suggestion selection is made:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class SuggestionFragment : Fragment(R.layout.fragment_items) {

    private val viewModel: QuerySuggestionViewModel by activityViewModels()
    private val connection = ConnectionHandler()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // Configure suggestions view
        val suggestionAdapter = SuggestionAdapter {
            // On suggestion click, update the
            viewModel.suggestions.value = it
        }
        view.findViewById<RecyclerView>(R.id.items).configure(suggestionAdapter) // Configure the RecyclerView with the adapter
        connection += viewModel.suggestionSearcher.connectHitsView(suggestionAdapter) {
            it.hits.deserialize(Suggestion.serializer())
        }

        // Run initial search
        viewModel.suggestionSearcher.searchAsync()
    }

    override fun onDestroyView() {
        super.onDestroyView()
        connection.clear()
    }
}

Setup layout

Finally, create QuerySuggestionActivity activity, and display ProductFragment and SuggestionFragment on SearchBox

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class QuerySuggestionActivity : AppCompatActivity() {

    private val viewModel by viewModels<QuerySuggestionViewModel>()
    private val connection = ConnectionHandler()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_query_suggestion)

        // Setup search box
        val searchView = findViewById<SearchView>(R.id.searchView)
        val searchBoxView = SearchBoxViewAppCompat(searchView)
        connection += viewModel.searchBox.connectView(searchBoxView)

        // Switch fragments on search box focus
        searchView.setOnQueryTextFocusChangeListener { _, hasFocus ->
            if (hasFocus) showSuggestions() else showProducts()
        }

        // Observe suggestions
        viewModel.suggestions.observe(this) { searchBoxView.setText(it.query, true) }

        // Initially show products view
        showProducts()
    }

    private fun showSuggestions() {
        supportFragmentManager.commit {
            replace<SuggestionFragment>(R.id.container)
            setReorderingAllowed(true)
            addToBackStack("suggestions") // name can be null
        }
    }

    private fun showProducts() {
        supportFragmentManager.commit {
            replace<ProductFragment>(R.id.container)
            setReorderingAllowed(true)
            addToBackStack("products") // name can be null
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        connection.clear()
    }
}

Going further

Your query suggestions search experience is now ready to use. You can find a complete project in the Android examples repository.

Did you find this page helpful?