User Interfaces with Jetpack ComposeIntroductionA user interface (UI) is the part of an Android application through which users perceive information, make decisions, and control application behaviour. In traditional Android development, interfaces were commonly built with XML layout files and View objects. Jetpack Compose provides a declarative alternative: developers describe what the interface should look like for a given application state, and Compose determines how to update the rendered UI. Compose is particularly important for modern Android development because it reduces the amount of imperative UI code, supports reusable components, integrates with Kotlin, and makes state-driven interfaces easier to reason about. However, Compose is not merely a replacement syntax for XML. It introduces a different mental model based on composition, recomposition, unidirectional data flow, and immutable descriptions of UI. This chapter develops the practical foundations needed to create responsive Compose interfaces. It examines composable functions, layouts, modifiers, Material Design, themes, state, previews, accessibility, and reusable components. The examples use Kotlin and assume a project configured with Jetpack Compose and Material 3. Learning ObjectivesBy the end of this chapter, you should be able to: - Explain the declarative UI model used by Jetpack Compose.
- Create composable functions and combine them into screens.
- Select appropriate layout containers such as Row, Column, Box, LazyColumn, and LazyRow.
- Use modifiers to control layout, appearance, interaction, and accessibility.
- Apply Material 3 components, colour schemes, typography, and themes.
- Manage local and hoisted state safely.
- Use previews and reusable components to improve development quality.
- Design responsive, accessible, and maintainable Android interfaces.
1. The Declarative UI ModelIn an imperative UI system, a program creates views and then changes individual properties when data changes. For example, it may explicitly set a TextView's text, change its visibility, and update a button label. The developer must ensure that every possible state transition updates every affected view correctly. Compose uses a declarative model. A composable function describes the relationship between state and UI. If state changes, Compose calls relevant functions again and updates the portions of the UI whose output has changed. This process is called recomposition. A simple composable is: @Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}The @Composable annotation indicates that the function can emit UI into the Compose composition. A composable function may call other composable functions, but it normally does not return a View object. Instead, it contributes UI elements to the composition. A useful conceptual model is: UI = f(state) This is not a Kotlin formula to be calculated numerically. It expresses that the visible interface is a function of current application state. If the state is Loading, the screen may show a progress indicator; if it is Success, it may show content; if it is Error, it may show an error message. Recomposition and side effectsRecomposition may occur more often than a developer expects. Therefore, composable functions should be fast, deterministic, and free from unintended side effects. A side effect is an operation whose impact exists outside the composable, such as writing to a database, sending a network request, or displaying a one-time message. Side effects should use Compose tools such as LaunchedEffect, DisposableEffect, or rememberCoroutineScope where appropriate. A network request should not be launched directly in the body of a composable because recomposition could start it repeatedly. 2. Building LayoutsCompose provides layout composables that arrange child elements. The principal containers are Column, Row, and Box. | Layout | Main arrangement | Typical use |
|---|
| Column | Vertical | Forms, detail screens, stacked content | | Row | Horizontal | Toolbars, action groups, icon-and-label arrangements | | Box | Overlapping or layered | Badges, image overlays, centred content | | LazyColumn | Vertical scrolling list | Feeds, menus, large collections | | LazyRow | Horizontal scrolling list | Categories, carousels, filters |
A basic screen can be written as follows: @Composable
fun ProfileScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Profile", style = MaterialTheme.typography.headlineSmall)
Text(text = "Manage your account details")
Button(onClick = { /* open editor */ }) {
Text("Edit profile")
}
}
}Arrangement controls how children are distributed along the main axis. Alignment controls positioning along the cross axis. For example, a Column has a vertical main axis and a horizontal cross axis.
Constraints and measurementCompose layouts work through constraints. A parent communicates permitted minimum and maximum width and height to a child. The child chooses a size within those constraints, and the parent places it. This explains why modifier order and parent constraints can affect the final appearance. fillMaxWidth() requests the maximum width allowed by the parent. It does not mean unlimited width. Similarly, weight() distributes remaining space within a Row or Column, subject to the parent's constraints.
For collections, use lazy layouts rather than placing a large number of children in a regular Column: @Composable
fun MessageList(messages: List<String>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = messages,
key = { message -> message }
) { message ->
Text(text = message, modifier = Modifier.fillMaxWidth())
}
}
}A lazy layout composes and measures visible items, rather than eagerly creating every item. Keys help Compose identify items when a list changes. In production, keys should be stable and unique, such as a database identifier. 3. ModifiersA Modifier is an ordered collection of instructions that affect a composable. Modifiers can control size, padding, background, click behaviour, semantics, borders, and more. Common modifiers include: padding() adds space inside or around content.fillMaxWidth() and fillMaxSize() expand within available constraints.size() requests a specific size.weight() allocates proportional remaining space in Row or Column.background() draws a background.clip() clips content to a shape.clickable() makes content interactive.testTag() can identify UI in automated tests.semantics() supplies accessibility and testing information.
Modifier order matters. Consider: Modifier
.background(Color.Blue)
.padding(16.dp)The background is applied before the padding, so the padded area is inside the blue background. In contrast: Modifier
.padding(16.dp)
.background(Color.Blue)The padding is outside the background. Both are valid, but they produce different visual results. A modifier should normally be accepted as a parameter in reusable components and applied to the outermost appropriate element: @Composable
fun SectionTitle(
title: String,
modifier: Modifier = Modifier
) {
Text(
text = title,
modifier = modifier,
style = MaterialTheme.typography.titleLarge
)
}This allows callers to determine placement without forcing the component to know its parent layout. 4. Material Design and Compose ComponentsMaterial Design is a design system containing principles, components, typography, colour guidance, elevation concepts, interaction patterns, and accessibility recommendations. Material 3 is the current Compose-oriented version of the system. Common Material components include: Text for textual content.Button for a primary action.OutlinedButton for a less prominent outlined action.TextButton for low-emphasis actions.OutlinedTextField and TextField for input.Card for related content.Scaffold for screen structure such as top bars and floating action buttons.NavigationBar for primary navigation on compact screens.AlertDialog for decisions requiring focused attention.
A button should communicate an action clearly: Button(
onClick = { submitForm() },
enabled = isFormValid,
modifier = Modifier.fillMaxWidth()
) {
Text("Submit")
}The onClick callback should describe an event, while the state and business decision should normally be managed by a higher-level owner such as a screen or ViewModel. Scaffold provides slots for common application structures:
@Composable
fun HomeScreen(onAddClick: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(title = { Text("Home") })
},
floatingActionButton = {
FloatingActionButton(onClick = onAddClick) {
Icon(Icons.Default.Add, contentDescription = "Add item")
}
}
) { innerPadding ->
Text(
text = "Welcome",
modifier = Modifier.padding(innerPadding)
)
}
}The padding supplied by Scaffold accounts for areas occupied by scaffold elements. Ignoring it can cause content to overlap a top bar or bottom navigation. 5. Themes, Colour, and TypographyA theme centralises visual decisions and permits the entire application to change consistently. A Material 3 theme commonly defines a colour scheme, typography, and shapes. @Composable
fun MyAppTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = LightColorScheme(
primary = Color(0xFF1565C0),
secondary = Color(0xFFFFA000)
),
typography = Typography(),
content = content
)
}Application code should generally use theme values instead of hard-coded colours and text sizes: Text(
text = "Account",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium
)The on... colour convention indicates a colour intended to be readable on another colour. For example, onPrimary is designed to appear on primary. Developers should still verify contrast, particularly when defining custom schemes. Themes also support dark mode. A robust application selects an appropriate colour scheme based on system settings or an explicit user preference. Colour should not be the only way to communicate status because users with colour-vision differences may not distinguish it. Typography should establish hierarchy. A screen title, supporting explanation, label, and body paragraph should not all use the same visual treatment. Excessive font variation, however, reduces coherence. 6. State and Unidirectional Data FlowState is information that can change over time and affect the UI. Examples include a text field value, selected tab, loading status, authentication state, or list of products. Compose offers remember for retaining a value across recompositions: @Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("Increase")
}
}
}remember retains state while the composable remains in the composition. It does not automatically survive process death or configuration changes. rememberSaveable can preserve many primitive or parcelable values through configuration changes, while durable application data should be stored in an appropriate data layer.
State hoistingState hoisting moves state to a caller so that a component becomes reusable and easier to test. A standard pattern is to pass a value and an event callback: @Composable
fun NameField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text("Name") },
modifier = modifier
)
}The component does not own the name. It displays the supplied value and reports user input. This supports unidirectional data flow: State owner → UI state → Composable
Composable → User event → State owner For larger applications, a ViewModel commonly owns screen state. A screen can collect state from a StateFlow and send user events to the ViewModel. The UI layer should render state and communicate events, rather than containing database or network policy. A useful screen state model makes states explicit: sealed interface ProductsUiState {
data object Loading : ProductsUiState
data class Success(val products: List<Product>) : ProductsUiState
data class Error(val message: String) : ProductsUiState
}Explicit states reduce contradictory combinations such as isLoading = true and errorMessage being non-null at the same time. 7. Input, Validation, and EventsText input should be controlled by state. Validation may occur as the user types, when focus leaves a field, or on submission, depending on the user experience. Error messages should be specific and actionable. @Composable
fun EmailField(email: String, onEmailChange: (String) -> Unit) {
val invalid = email.isNotEmpty() && !email.contains("@")
OutlinedTextField(
value = email,
onValueChange = onEmailChange,
label = { Text("Email") },
isError = invalid,
supportingText = {
if (invalid) Text("Enter a valid email address")
},
singleLine = true
)
}This simple check is illustrative, not a complete email validation specification. Production validation should follow the application's actual requirements and should also occur on the server when data is submitted. Events should be named according to intent, such as onSave, onDelete, or onQueryChanged, rather than exposing internal implementation details. This makes components more portable. 8. Previews and Reusable ComponentsPreviews allow developers to inspect a composable in Android Studio without running the complete application. A preview should use representative data and should not depend on a live network connection or database. @Preview(showBackground = true)
@Composable
private fun ProfileScreenPreview() {
MyAppTheme {
ProfileScreen()
}
}Useful preview variations include light and dark themes, different content lengths, empty states, error states, and large font settings. Preview parameters can support multiple sample values. Reusable components should have: - A focused responsibility.
- Clear inputs and event callbacks.
- A default
Modifier parameter. - Minimal hidden state.
- Meaningful semantics and content descriptions.
- No unnecessary dependency on application-wide objects.
Avoid extracting every two lines into a component. Abstraction is valuable when it improves reuse, testing, readability, or consistency; excessive abstraction can hide layout relationships. 9. Responsive Design and AccessibilityResponsive UI adapts to available width, height, orientation, font scale, and device form factor. A layout that works on one phone may fail on a tablet, foldable, landscape screen, or accessibility configuration. Good practices include: - Prefer flexible sizing and constraints over fixed pixel dimensions.
- Use density-independent units such as
dp for layout and sp for text. - Allow text to wrap where appropriate.
- Use lazy layouts for potentially long content.
- Consider window size classes when selecting navigation patterns.
- Avoid assuming that a device has a particular aspect ratio.
- Test with large font settings and dark mode.
Accessibility is part of correctness, not an optional visual improvement. Icons that convey meaning need content descriptions: Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete message"
)Purely decorative icons may use a null description when a nearby text label already communicates the meaning. Touch targets should be sufficiently large and separated from neighbouring targets. Colour contrast, focus behaviour, semantic roles, and logical reading order should also be checked. 10. Worked Application: A Task ScreenConsider a task-management screen with a title, input field, add button, and list of tasks. The screen state is a list of task strings and the current draft. A small reusable item component can be defined as follows: @Composable
fun TaskItem(
task: String,
onRemove: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(task, modifier = Modifier.weight(1f))
IconButton(onClick = onRemove) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove $task"
)
}
}
}The screen can own state locally for a small demonstration: @Composable
fun TaskScreen() {
var draft by rememberSaveable { mutableStateOf("") }
var tasks by rememberSaveable { mutableStateOf(listOf("Read chapter", "Build preview")) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Tasks", style = MaterialTheme.typography.headlineMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
label = { Text("New task") },
modifier = Modifier.weight(1f),
singleLine = true
)
Button(
onClick = {
if (draft.isNotBlank()) {
tasks = tasks + draft.trim()
draft = ""
}
},
modifier = Modifier.align(Alignment.CenterVertically)
) {
Text("Add")
}
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(tasks, key = { it }) { task ->
TaskItem(
task = task,
onRemove = { tasks = tasks - task }
)
}
}
}
}This example demonstrates state, state-driven rendering, modifier use, a lazy list, stable item identification, input validation, and component reuse. In a production application, task identity should be a unique identifier rather than the task text, because two tasks may legitimately have the same text. Persistent data and business rules should also move into a repository and ViewModel. 11. Illustrative Case Study: Campus Event AppBackgroundA college wants an Android application that lists campus events. Students should browse events, filter by category, and save events for later. The application must work on small phones and larger tablets. ProblemAn initial prototype used a single large Column, hard-coded colours, local button mutations, and fixed widths. Long event lists caused poor performance, text was truncated on large font settings, and the tablet layout left excessive empty space. AnalysisThe UI contained three separate concerns: event data, filter state, and presentation. The prototype mixed these concerns in one composable. It also used a regular Column for a potentially large collection and did not expose stable event keys. Approach and solutionThe team created an EventsUiState containing loading, success, and error conditions. A ViewModel owned the selected category and saved-event identifiers. The screen used a Scaffold, a responsive navigation choice, a filter LazyRow, and a LazyColumn of event cards. Event cards accepted event data and callbacks rather than accessing the ViewModel directly. A Material theme supplied colour and typography. Preview functions represented loading, empty, error, and populated states. Content descriptions were added to save and share icons. The team tested with dark mode, large text, landscape orientation, and a tablet emulator. OutcomeScrolling performance improved because only visible list items were composed. The same event-card component was reused in the home and saved-event screens. State hoisting made components easier to preview and test. Explicit UI states prevented the interface from displaying stale content while a new filter was loading. Learning pointsThe case demonstrates that Compose architecture is not only a matter of arranging widgets. Performance, responsiveness, accessibility, state ownership, and visual consistency depend on clear boundaries between state, events, and presentation. 12. Challenges, Limitations, and Best PracticesCompose reduces boilerplate but does not eliminate design complexity. Developers must still understand layout constraints, lifecycle, state ownership, concurrency, accessibility, and performance. Potential problems include unnecessary recomposition caused by unstable data, expensive calculations inside composables, incorrect list keys, and state stored too low in the component tree. Profiling and inspection tools should be used when performance issues are suspected rather than relying on guesswork. Recommended practices are: - Keep composables small enough to understand but not artificially fragmented.
- Hoist state when multiple components need it or when the caller should control it.
- Keep side effects explicit and lifecycle-aware.
- Use lazy containers for large or unbounded collections.
- Use stable keys for dynamic lists.
- Prefer theme tokens to scattered constants.
- Design for accessibility from the beginning.
- Preview important UI states, not only the ideal state.
- Keep business logic outside presentation composables.
- Test behaviour, semantics, and state transitions as well as appearance.
13. Contemporary Development ConsiderationsModern Android projects increasingly combine Compose with ViewModels, Kotlin coroutines, Flow, dependency injection, navigation libraries, and adaptive layout APIs. Compose can coexist with legacy Views, allowing gradual migration rather than requiring an entire application to be rewritten at once. AI-assisted programming tools may generate composables, previews, or test scaffolding, but generated code must be reviewed. Common risks include incorrect state ownership, inaccessible content descriptions, insecure handling of user data, obsolete APIs, and code that compiles but fails on unusual screen sizes. Developers remain responsible for testing and architectural decisions. Privacy also matters at the UI boundary. Applications should avoid exposing sensitive information in previews, screenshots, logs, accessibility labels, or analytics events. User input should be handled according to applicable privacy requirements and the application's stated purpose. Chapter SummaryJetpack Compose is a declarative framework in which composable functions describe UI from current state. Recomposition updates affected parts of the interface when state changes. Column, Row, Box, and lazy layouts provide the structural foundation for screens, while modifiers control layout, drawing, interaction, and semantics. Modifier order is significant. Material 3 supplies consistent components, colour schemes, typography, and themes. State should follow unidirectional data flow: an owner provides UI state, composables render it, and user actions are sent back as events. State hoisting makes components reusable and testable. remember retains state across recompositions, while rememberSaveable supports selected configuration changes; durable data belongs in an appropriate data layer. Responsive design requires flexible constraints, density-independent dimensions, support for text scaling, and testing across device configurations. Accessibility requires meaningful semantics, adequate touch targets, readable contrast, and descriptions for meaningful icons. Previews, stable list keys, lazy containers, and clear component boundaries improve development quality. Compose is powerful, but professional results depend on sound architecture, testing, and attention to users' varied needs. Key TermsComposable function: A function annotated with @Composable that contributes UI to a Compose composition. Declarative UI: A UI approach in which the interface is described as a function of current state. Recomposition: The process of re-executing relevant composable functions when observed state changes. Modifier: An ordered collection of layout, drawing, interaction, or semantics instructions. Constraint: A minimum and maximum size range supplied by a parent during layout measurement. Column: A layout that arranges children vertically. Row: A layout that arranges children horizontally. Box: A layout useful for layering or overlapping children. LazyColumn: A vertically scrolling container that composes visible items efficiently. Material 3: Google's current Material Design system and component library for modern Android UI. Theme: A central definition of application visual properties such as colours and typography. State: Data that can change and influence the rendered interface. Recomposition: Updating UI output in response to changed Compose state. State hoisting: Moving state ownership to a caller while passing values and event callbacks to a component. Unidirectional data flow: A pattern in which state flows toward the UI and events flow toward the state owner. Side effect: An operation whose impact exists outside the composable rendering process. Semantics: Information that describes UI meaning for accessibility and automated testing. Preview: An Android Studio representation of a composable without running the complete application. Responsive design: Designing interfaces that adapt to screen size, orientation, font scale, and device form factor. Stable key: A unique, persistent identifier used by lazy layouts to track list items. Review QuestionsShort Answer Questions- What is the main difference between declarative and imperative UI development?
- Why does modifier order matter in Jetpack Compose?
- When should a developer use
LazyColumn instead of a regular Column? - What is state hoisting, and why is it useful?
- What purpose does a Material theme serve?
Long Answer Questions- Explain the relationship between state, recomposition, and unidirectional data flow in Compose.
- Compare
Row, Column, Box, and LazyColumn, giving an appropriate use case for each. - Explain how Material 3 themes improve consistency, maintainability, and accessibility.
- Describe the design principles of a reusable composable component.
- Discuss the major accessibility and responsiveness considerations when building a Compose screen.
Analytical / Application Questions- A list screen becomes slow after several hundred items are added. Analyse the likely causes and propose a Compose-based solution.
- Design the state and event model for a login screen that supports loading, validation errors, server errors, and successful authentication.
- A tablet version of a phone-only screen has large empty margins and difficult navigation. Propose a responsive redesign and explain the layout decisions you would test.
Multiple Choice Questions with AnswersQuestion 1Which statement best describes Jetpack Compose? A. A database abstraction layer for Android B. A declarative UI toolkit for Android C. A replacement for Kotlin coroutines D. A network security protocol Correct Answer: B. A declarative UI toolkit for Android Question 2Which layout arranges its children vertically? A. Row B. Box C. Column D. LazyRow Correct Answer: C. Column Question 3What is the primary purpose of state hoisting? A. To move state ownership to a caller B. To prevent all recomposition C. To convert Kotlin into XML D. To increase screen density Correct Answer: A. To move state ownership to a caller Question 4Which container is generally most appropriate for a potentially large vertically scrolling list? A. Column B. Box C. Row D. LazyColumn Correct Answer: D. LazyColumn Question 5Why are stable keys useful in a lazy list? A. They set the text colour of each item B. They help Compose identify items when the list changes C. They disable scrolling D. They automatically save data to a database Correct Answer: B. They help Compose identify items when the list changes Question 6What can remember do in a composable? A. Retain a value across recompositions while the composable remains in the composition B. Guarantee persistence after application deletion C. Perform a network request safely D. Replace a ViewModel in every application Correct Answer: A. Retain a value across recompositions while the composable remains in the composition Question 7Which practice improves accessibility for a meaningful icon-only button? A. Give it a content description B. Make its colour transparent C. Remove its click action D. Place it outside the composition Correct Answer: A. Give it a content description Question 8What is the usual role of MaterialTheme.colorScheme? A. To store database records B. To provide consistent themed colours C. To measure network latency D. To define list keys Correct Answer: B. To provide consistent themed colours Question 9Why should a network request generally not be started directly in the body of a composable? A. Composable functions cannot contain Kotlin code B. Recomposition could start the request repeatedly C. Network requests require XML layouts D. Compose automatically deletes network responses Correct Answer: B. Recomposition could start the request repeatedly Question 10Which statement about modifier order is correct? A. Modifier order never affects the result B. Modifiers are applied in an order that can change layout and appearance C. Only the first modifier is used D. Modifiers work only on text components Correct Answer: B. Modifiers are applied in an order that can change layout and appearance
|