Why Kotlin + Jetpack Compose
Kotlin is Google's preferred language for Android — concise, null-safe, and fully interoperable with the Java ecosystem. Jetpack Compose is the modern declarative UI toolkit that replaces the old XML layout + findViewById world. You describe what the UI should look like for a given state, and the framework figures out how to update the screen when that state changes.
In 2026 the standard stack is Kotlin 2.x, Compose with the Material 3 library, coroutines and Flow for async work, ViewModel + StateFlow for state, Navigation Compose for screens, and Retrofit for networking.
Kotlin Basics
val, var, and Type Inference
Use val for read-only (immutable) references and var for mutable ones. Prefer val by default. Types are usually inferred.
val name = "Ada" // inferred String, cannot reassign
var count = 0 // inferred Int, mutable
val price: Double = 9.99 // explicit type
count += 1
// String templates
println("Hello $name, count is ${count + 1}")
Null Safety
Types are non-nullable by default. Add ? to allow null, then use the safe-call ?., the Elvis operator ?:, and (rarely) the not-null assertion !!.
var nickname: String? = null
val length = nickname?.length ?: 0 // safe call + Elvis fallback
nickname?.let { println("Nick is $it") } // runs only if non-null
fun greet(user: String?) {
val safe = user ?: return // early return on null
println("Welcome, $safe")
}
Functions and Lambdas
fun add(a: Int, b: Int): Int = a + b // expression body
fun greet(name: String, punct: String = "!") = "Hi $name$punct" // default arg
greet(name = "Sam") // named argument
// Lambda: type is (Int) -> Int
val square = { n: Int -> n * n }
val nums = listOf(1, 2, 3, 4)
val evens = nums.filter { it % 2 == 0 } // trailing lambda + implicit `it`
val doubled = nums.map { it * 2 }
Data Classes and Sealed Classes
data class auto-generates equals, hashCode, toString, and copy. Sealed hierarchies model a closed set of states — perfect for exhaustive when.
data class User(val id: Int, val name: String, val email: String)
val u1 = User(1, "Ada", "ada@x.io")
val u2 = u1.copy(name = "Ada L.") // copy with one field changed
sealed interface UiState {
data object Loading : UiState
data class Success(val users: List<User>) : UiState
data class Error(val message: String) : UiState
}
fun render(state: UiState) = when (state) { // exhaustive, no `else` needed
UiState.Loading -> "Loading…"
is UiState.Success -> "${state.users.size} users"
is UiState.Error -> state.message
}
Declarative UI with @Composable
A composable is a function annotated with @Composable that emits UI. Composables are the building blocks; they can call other composables. The framework calls them to build the tree and re-invokes them when their inputs change.
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}
// Entry point wired from an Activity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme { Greeting(name = "Android") }
}
}
}
Layout Composables
Column stacks children vertically, Row horizontally, and Box overlays them. For long, scrollable lists use LazyColumn — it only composes the items on screen.
@Composable
fun UserList(users: List<User>) {
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(users, key = { it.id }) { user ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(40.dp).clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(Modifier.width(12.dp))
Column {
Text(user.name, style = MaterialTheme.typography.titleMedium)
Text(user.email, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
State and Recomposition
When state a composable reads changes, Compose recomposes — it re-runs that function (and only what needs updating) to reflect the new value. State that Compose can observe is created with mutableStateOf, kept across recompositions with remember.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) } // `by` delegate reads the value directly
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
What triggers recomposition
Compose only recomposes when a tracked state value that a composable actually reads changes. Reading a plain var won't trigger anything — you must read from a State<T>. Keep composables free of side effects; they may run any number of times, in any order, and in parallel.
State Hoisting
State hoisting means lifting state out of a composable so the caller owns it. The child becomes stateless: it receives the value and an event callback. This makes composables reusable, testable, and easy to preview.
// Stateless: value in, event out
@Composable
fun SearchBar(query: String, onQueryChange: (String) -> Unit) {
TextField(value = query, onValueChange = onQueryChange)
}
// Stateful caller owns the state
@Composable
fun SearchScreen() {
var query by remember { mutableStateOf("") }
SearchBar(query = query, onQueryChange = { query = it })
}
remember vs rememberSaveable
remember survives recomposition but is lost on configuration changes (like rotation) or process death. rememberSaveable additionally persists the value to a saved-instance bundle, so it survives rotation. Use it for UI state the user shouldn't lose, like text field contents or a selected tab.
ViewModel + StateFlow
For screen-level state that must survive configuration changes and outlive individual composables, use a ViewModel exposing an immutable StateFlow. The UI collects it lifecycle-aware.
class UserViewModel(private val repo: UserRepository) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
init { load() }
fun load() = viewModelScope.launch {
_state.value = UiState.Loading
_state.value = try {
UiState.Success(repo.fetchUsers())
} catch (e: Exception) {
UiState.Error(e.message ?: "Unknown error")
}
}
}
@Composable
fun UserScreen(vm: UserViewModel = viewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
when (val s = state) {
UiState.Loading -> CircularProgressIndicator()
is UiState.Success -> UserList(s.users)
is UiState.Error -> Text(s.message)
}
}
Modifiers
Modifier is how you decorate and configure a composable — size, padding, background, click handling, and more. Order matters: modifiers are applied left to right, so padding before background differs from padding after it.
Text(
text = "Tap me",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp) // outer padding
.background(Color(0xFF0F766E)) // background fills padded area
.clip(RoundedCornerShape(12.dp))
.clickable { /* handle tap */ }
.padding(12.dp) // inner padding on the content
)
Material 3 Theming
Material 3 (androidx.compose.material3) provides ready-made components and a theming system with dynamic color. Wrap your app in a MaterialTheme and read from colorScheme, typography, and shapes.
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val scheme = if (darkTheme) darkColorScheme() else lightColorScheme()
MaterialTheme(colorScheme = scheme, typography = Typography, content = content)
}
@Composable
fun HomeScaffold() {
Scaffold(
topBar = { TopAppBar(title = { Text("Home") }) },
floatingActionButton = { FloatingActionButton(onClick = {}) { Icon(Icons.Default.Add, null) } }
) { inner ->
Column(Modifier.padding(inner)) { /* screen content */ }
}
}
Navigation Compose
Navigation Compose manages moving between screens with a NavHost and a NavController. Modern 2026 apps use type-safe routes defined as serializable classes rather than string paths.
@Serializable object Home
@Serializable data class Details(val userId: Int)
@Composable
fun AppNav() {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = Home) {
composable<Home> {
HomeScreen(onOpen = { id -> nav.navigate(Details(id)) })
}
composable<Details> { entry ->
val args = entry.toRoute<Details>()
DetailsScreen(userId = args.userId, onBack = { nav.popBackStack() })
}
}
}
Coroutines and suspend Functions
Coroutines make asynchronous code read like sequential code. A suspend function can pause without blocking a thread. Launch them in a scope (like viewModelScope) so they're cancelled automatically when no longer needed. Use Dispatchers.IO for blocking I/O.
suspend fun loadProfile(id: Int): Profile = withContext(Dispatchers.IO) {
val user = api.getUser(id) // network, suspends
val posts = api.getPosts(id) // runs after user returns
Profile(user, posts)
}
// Run two calls concurrently with async/await
suspend fun loadDashboard(): Dashboard = coroutineScope {
val users = async { api.getUsers() }
val stats = async { api.getStats() }
Dashboard(users.await(), stats.await())
}
Flow
A Flow is a cold, asynchronous stream of values — ideal for observing a database, sensor, or repeated events. Operators like map, filter, and debounce transform it.
fun searchResults(queries: Flow<String>): Flow<List<User>> =
queries
.debounce(300) // wait for typing to settle
.filter { it.length >= 2 }
.distinctUntilChanged()
.mapLatest { q -> repo.search(q) } // cancels previous search
// Collect inside a scope
viewModelScope.launch {
searchResults(queryFlow).collect { results -> _state.value = UiState.Success(results) }
}
Networking with Retrofit
Retrofit turns a Kotlin interface into a type-safe HTTP client. Declare endpoints as suspend functions so they integrate directly with coroutines, and pair it with kotlinx.serialization for JSON.
interface UserApi {
@GET("users")
suspend fun getUsers(): List<User>
@GET("users/{id}")
suspend fun getUser(@Path("id") id: Int): User
}
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
val api: UserApi = retrofit.create(UserApi::class.java)
Lifecycle
Compose has its own side-effect APIs tied to the composition lifecycle. Use LaunchedEffect to run suspend work when a key changes, and DisposableEffect for setup/teardown. Collect flows with collectAsStateWithLifecycle so collection stops when the screen is in the background.
@Composable
fun DetailsScreen(userId: Int, vm: DetailsViewModel = viewModel()) {
LaunchedEffect(userId) { // re-runs only when userId changes
vm.load(userId)
}
DisposableEffect(Unit) {
val job = analytics.startTracking()
onDispose { job.stop() } // cleanup when leaving composition
}
// ...
}
Compose vs XML Views
| Aspect | Jetpack Compose | XML Views |
|---|---|---|
| Paradigm | Declarative — describe UI for state | Imperative — mutate views manually |
| UI definition | Kotlin functions | XML layout files |
| State updates | Automatic recomposition | Manual findViewById + setters |
| Lists | LazyColumn | RecyclerView + adapter |
| Reusability | Composable functions | Custom views / includes |
| Previews | @Preview in the IDE | Layout editor |
Practice Exercises
- Write a
data classTask(id, title, done)and asealed interfacefor the screen's UI state (Loading / Success / Empty / Error). Render each case with an exhaustivewhen. - Build a
Countercomposable using state hoisting: a stateless child that takes the count and anonIncrementcallback, and a stateful parent that owns the value withrememberSaveableso it survives rotation. - Create a
LazyColumnof tasks with a stablekey, a checkbox to toggledone, and a swipe-to-delete gesture. - Add a
ViewModelthat exposes aStateFlow<UiState>, loads tasks inviewModelScope, and is collected withcollectAsStateWithLifecycle. - Set up Navigation Compose with type-safe routes: a list screen and a details screen that receives a task id, plus a back action via
popBackStack. - Wire a Retrofit
suspendendpoint into the ViewModel, add a searchFlowwithdebounce+mapLatest, and handle loading and error states in the UI.