| |

How to make instant search from server by using Kotlin Coroutines

Sharing is caring!

Implementing an instant search (also called “search-as-you-type”) from a server using Kotlin Coroutines is a classic use case for coroutines and flows. The key is to debounce user input to avoid flooding the server and cancel previous requests to avoid out-of-order results.

Here’s a step-by-step guide with best practices.

We’ll use a reactive approach with Flow and the MVVM architecture.

  • UI (Fragment/Activity): Collects the search results and sends user input to the ViewModel.

  • ViewModel: Exposes a StateFlow for the UI state and contains the logic to trigger the search flow.

  • Repository: Contains the suspend function to call the API.

  • DataSource/Retrofit: The actual network interface.

Step 1: Set up the Data Source (Retrofit Service)

Create a Retrofit service with a suspend function.

// Service interface
interface ApiService {
    @GET("search")
    suspend fun search(
        @Query("query") query: String,
        @Query("page") page: Int = 1 // Optional: for pagination
    ): SearchResponse // Your data model class
}

// Data class for the response
data class SearchResponse(
    val results: List<SearchResult>,
    val totalPages: Int
)

data class SearchResult(val id: String, val title: String)

Step 2: Create the Repository

The repository provides a clean API for the ViewModel to call.

class SearchRepository @Inject constructor(
    private val apiService: ApiService,
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {

    suspend fun searchQuery(query: String, page: Int = 1): Result<SearchResponse> {
        return withContext(ioDispatcher) {
            try {
                val response = apiService.search(query, page)
                Result.success(response)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
    }
}
// It's better to use a sealed class for Result (see best practices below)

Step 3: Create the ViewModel (The Core Logic)

This is where the magic happens. We will use a MutableStateFlow to capture the query and a callbackFlow to create a stream of queries. We’ll then transform this stream using powerful Flow operators.

@HiltViewModel // Or using your preferred DI
class SearchViewModel @Inject constructor(
    private val repository: SearchRepository
) : ViewModel() {

    // For one-off events like showing a Snackbar for errors
    private val _searchEvent = MutableSharedFlow<UiEvent>()
    val searchEvent = _searchEvent.asSharedFlow()

    // For the state of the UI (Loading, Success, Error)
    private val _uiState = MutableStateFlow<SearchUiState>(SearchUiState.Empty)
    val uiState: StateFlow<SearchUiState> = _uiState.asStateFlow()

    // The current search query
    private val currentQuery = MutableStateFlow("")

    // Function called by the UI (e.g., from a SearchView text listener)
    fun onSearchQueryChanged(query: String) {
        currentQuery.value = query
    }

    init {
        // Start listening to query changes when the ViewModel is created
        observeQueryChanges()
    }

    private fun observeQueryChanges() {
        viewModelScope.launch {
            currentQuery
                .debounce(300) // Wait 300ms after the last keystroke
                .filter { query ->
                    // Only proceed if query is empty or has more than 2 characters
                    if (query.isEmpty()) {
                        _uiState.value = SearchUiState.Empty
                        return@filter false
                    }
                    if (query.length < 3) { // Avoid searching for very short strings
                        _uiState.value = SearchUiState.ReadyToSearch
                        return@filter false
                    }
                    true
                }
                .distinctUntilChanged() // Avoid duplicate consecutive queries (e.g., pasting the same text)
                .flatMapLatest { query -> // Cancel previous search and start a new one
                    performSearch(query)
                }
                .collect() // Collect the results and update UI State
        }
    }

    private fun performSearch(query: String): Flow<SearchUiState> {
        return flow {
            // Emit Loading state
            emit(SearchUiState.Loading)

            // Perform the network request
            val result = repository.searchQuery(query)

            // Emit Success or Error state based on result
            when (result) {
                is Result.Success -> {
                    val results = result.data.results
                    if (results.isEmpty()) {
                        emit(SearchUiState.EmptyResult)
                    } else {
                        emit(SearchUiState.Success(results))
                    }
                }
                is Result.Failure -> {
                    emit(SearchUiState.Error(result.exception.message ?: "Unknown error"))
                    // Also send the error event for one-off handling (e.g., Snackbar)
                    _searchEvent.emit(UiEvent.ShowSnackbar("Failed to search: ${result.exception.message}"))
                }
            }
        }.catch { e ->
            // Catch any exceptions in the flow itself
            emit(SearchUiState.Error(e.message ?: "Unknown error in flow"))
            _searchEvent.emit(UiEvent.ShowSnackbar("Search failed"))
        }
    }
}

// Define the UI State as a sealed class
sealed class SearchUiState {
    object Empty : SearchUiState()
    object ReadyToSearch : SearchUiState() // Query is >0 but <3 chars
    object Loading : SearchUiState()
    data class Success(val results: List<SearchResult>) : SearchUiState()
    data class Error(val message: String) : SearchUiState()
    object EmptyResult : SearchUiState() // Query was valid but returned no results
}

// For one-off events (optional but recommended)
sealed class UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent()
}

Step 4: Collect the State in the UI (Fragment)

The UI simply sends query changes to the ViewModel and reacts to state changes.

@AndroidEntryPoint
class SearchFragment : Fragment() {

    private var _binding: FragmentSearchBinding? = null
    private val binding get() = _binding!!
    private val viewModel: SearchViewModel by viewModels()

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentSearchBinding.inflate(inflater, container, false)
        return binding.root
    }

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

        // Setup SearchView or EditText listener
        binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
            override fun onQueryTextSubmit(query: String?): Boolean {
                return false
            }

            override fun onQueryTextChange(newText: String?): Boolean {
                viewModel.onSearchQueryChanged(newText ?: "")
                return true
            }
        })

        // Collect the UI State and update the UI
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState.collect { state ->
                    when (state) {
                        is SearchUiState.Empty -> {
                            binding.progressBar.hide()
                            binding.recyclerView.hide()
                            binding.textInfo.text = "Start typing to search..."
                        }
                        is SearchUiState.ReadyToSearch -> {
                            binding.progressBar.hide()
                            binding.recyclerView.hide()
                            binding.textInfo.text = "Type more to search..."
                        }
                        is SearchUiState.Loading -> {
                            binding.progressBar.show()
                            binding.recyclerView.hide()
                            binding.textInfo.text = "Searching..."
                        }
                        is SearchUiState.Success -> {
                            binding.progressBar.hide()
                            binding.recyclerView.show()
                            binding.textInfo.text = ""
                            // Update your RecyclerView adapter here
                            adapter.submitList(state.results)
                        }
                        is SearchUiState.Error -> {
                            binding.progressBar.hide()
                            binding.recyclerView.hide()
                            binding.textInfo.text = "Error: ${state.message}"
                        }
                        is SearchUiState.EmptyResult -> {
                            binding.progressBar.hide()
                            binding.recyclerView.hide()
                            binding.textInfo.text = "No results found."
                        }
                    }
                }
            }
        }

        // Optional: Collect one-off events (e.g., for Snackbar)
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.searchEvent.collect { event ->
                    when (event) {
                        is UiEvent.ShowSnackbar -> {
                            Snackbar.make(binding.root, event.message, Snackbar.LENGTH_LONG).show()
                        }
                    }
                }
            }
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

Key Concepts to note:

  1. debounce(300): This is the most important operator. It waits for 300 milliseconds of inactivity before emitting the latest query. This prevents a new network call on every single keystroke.

  2. filter{}: Used to ignore empty or very short queries that wouldn’t yield good results, saving network calls.

  3. distinctUntilChanged(): If the user pastes the same text again, it won’t trigger a duplicate network request.

  4. flatMapLatest{}: The superstar for cancellation. If a new query is emitted while a previous network request is still running, flatMapLatest cancels the previous request and immediately starts the new one. This ensures results always match the current query, preventing race conditions.

  5. StateFlow for UI State: Holds the entire state of the screen (Loading, Success, Error), making UI updates consistent and predictable.

  6. Lifecycle Awareness: Using repeatOnLifecycle(Lifecycle.State.STARTED) ensures the Flow collection stops when the view goes to the background, wasting no resources.

This pattern is robust, efficient, and follows modern Android development best practices.

0 0 votes
Article Rating

Similar Posts

Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments