A small Kotlin + Jetpack Compose app that demonstrates debounced search, Kotlin Flow, StateFlow, flatMapLatest, and cancellation of in-flight requests.
There is no real network call. A fake repository filters a static list and uses delay(2000) so cancellation is easy to see in Logcat.
Primary color: #6200EE. The screen uses a light purple gradient behind the same search layout (field on top, results below).
- Android Studio Otter or newer (the project uses AGP 9.3 and Kotlin 2.2)
- JDK 11 or newer (Android Studio’s bundled JDK is enough)
- An Android emulator (API 24+) or a physical device with USB debugging
- Internet on first Gradle sync so dependencies can download
- Launch Android Studio.
- File → Open and select this folder (
SearchApp). - Wait for Gradle sync to finish. If prompted, use the Gradle JDK bundled with Android Studio.
From the command line:
cd "/path/to/SearchApp"
./gradlew assembleDebugOn Windows use gradlew.bat instead of ./gradlew.
Android Studio
- Start an emulator or connect a device.
- Select the
apprun configuration. - Click Run (or Shift+F10 / Control+R).
Command line
./gradlew installDebugThen open SearchWithDebounce on the device.
- Open the Logcat tool window.
- Filter by tag:
SearchDebounce - Use the scenarios below.
Calling an API on every keystroke is inefficient.
If the user types Baji, the app would otherwise fire four requests:
Search("B")
Search("Ba")
Search("Baj")
Search("Baji")
Most of those queries are thrown away a moment later. They still cost battery, bandwidth, and backend work. A slow response for "B" can also arrive after "Baji" and show the wrong results.
The ViewModel does not search on every keystroke. It runs this pipeline:
debounce
↓
Wait until user stops typing
distinctUntilChanged
↓
Avoid duplicate searches
flatMapLatest
↓
Cancel previous search when new query arrives
Emits a query only after 500 ms with no new input. Typing B → Ba → Baj → Baji quickly results in a single Search("Baji").
The TextField still updates instantly. Only the search waits.
If the settled query is the same as the last one (for example the user types Kotlin, deletes a letter, then types it back), the extra search is skipped.
Starts a new search Flow for the latest query and cancels the previous one. Cancellation reaches delay() in the fake repository, so the old request does not complete and cannot overwrite the UI.
flatMapConcat waits for the previous inner Flow to finish before starting the next one.
That is wrong for search:
User types "Android" → search starts (2s delay)
User types "Kotlin" → queued behind Android
Android completes → UI shows Android ← stale
Kotlin starts → UI later shows Kotlin
The user already moved on, but outdated results still appear.
flatMapLatest gives the latest query priority. When "Kotlin" arrives, the "Android" Flow is cancelled. Only Kotlin’s result can reach the UI:
SEARCH STARTED: Android
SEARCH CANCELLED: Android
SEARCH STARTED: Kotlin
SEARCH COMPLETED: Kotlin
flatMapMerge would run both at once and could still deliver Android last. Search is last-write-wins; flatMapLatest is the matching operator.
Time →
User types:
A → An → And → Andr → Android
↓
wait 500ms
↓
Search Android starts
↓
delay 2000ms
User types:
Kotlin
↓
Android request cancelled
↓
wait 500ms
↓
Kotlin search starts
↓
Kotlin results displayed
Steps:
- Filter Logcat by
SearchDebounce. - Type
Android, wait until Searching... appears, then quickly replace the text withKotlin.
Expected logs:
SEARCH STARTED: Android
SEARCH CANCELLED: Android
SEARCH STARTED: Kotlin
SEARCH COMPLETED: Kotlin
The Kotlin match is shown. Android never appears after cancellation.
| What you type | What you should see |
|---|---|
| (empty) | Start typing to search |
kot |
Kotlin |
flow |
Flow |
xyz |
No results found |
error |
Something went wrong + Retry |
Catalog items: Android, Kotlin, Jetpack Compose, Coroutines, Flow, Room, Retrofit, Hilt, WorkManager, Firebase.
Compose TextField
│
│ onQueryChanged(query)
▼
SearchViewModel
│
│ MutableStateFlow<String> ← query updates instantly
▼
debounce(500)
│
distinctUntilChanged()
│
flatMapLatest { query -> ← cancels the previous search
Repository.search(query)
}
│
FakeSearchRepository ← delay(2000) + in-memory filter
│
StateFlow<SearchUiState> ← Initial | Loading | Success | Empty | Error
│
▼
Compose UI
Unidirectional data flow: the UI only sends events (onQueryChanged, retry). The ViewModel owns state. The UI never talks to the repository.
Timing diagrams and operator comparisons are in ARCHITECTURE.md.
app/src/main/java/com/search/app/
├── MainActivity.kt
├── ui/
│ ├── SearchScreen.kt Compose UI (gradient + search states)
│ ├── SearchViewModel.kt debounce / distinctUntilChanged / flatMapLatest
│ ├── SearchUiState.kt Initial, Loading, Success, Empty, Error
│ └── theme/ Material 3 purple theme (#6200EE)
└── data/
├── SearchRepository.kt
└── FakeSearchRepository.kt
No extra layers (use cases, Hilt, or a real API). The goal is to see how Flow cancellation and debounce behave in a real Android screen.
- Primary:
#6200EE(Material Purple 500) - Gradient: a light lilac wash at the top that eases to white (a deeper purple wash in dark theme)
- UX: unchanged — search field, then Initial / Loading / results / Empty / Error + Retry
Dynamic (wallpaper) color is disabled so #6200EE stays the primary on every device.
| Piece | Choice |
|---|---|
| Language | Kotlin 2.2 |
| UI | Jetpack Compose, Material 3 |
| State | StateFlow + ViewModel |
| Async | Kotlin Coroutines + Flow |
| Min SDK | 24 |
| Target / compile SDK | 37 |