Why Jetpack Compose?
The need for Compose-based UI arose to overcome some of the limitations of the traditional View-based approach. Inspired by React-based UIs on the web and Google’s own success with Flutter, Jetpack Compose introduced declarative UI principles to Android. Its development paralleled similar advancements in iOS with SwiftUI. Jetpack Compose was first introduced in 2019 (Developer Preview) and became stable in July 2021.
The traditional View-based UI system had several limitations that led to the creation of Jetpack Compose:
- Complex UI hierarchy: Deeply nested XML layouts can become hard to manage for complex interfaces.
- Boilerplate code: Repetitive view binding and state handling code made development slower.
- Separated layout and logic: Keeping UI in XML and logic in Kotlin/Java reduced clarity and maintainability.
- Limited reusability: Creating reusable components required custom Views or inheritance, adding complexity.
- Difficult state management: Manually updating the UI to reflect data changes is error-prone.
- Slow iteration: Even minor layout changes require rebuilding and redeploying the app.
The following table highlights key differences between the traditional XML + Java/Kotlin approach and the modern Jetpack Compose framework:
| Aspect | XML + Java/Kotlin | Jetpack Compose |
|---|---|---|
| Learning Curve | Simple, attribute-based, easy to pick up | Higher, requires understanding of state and declarative concepts |
| UI Definition | Separate XML layout files + inflation | UI defined directly in Kotlin code (declarative) |
| State Management | Manual updates via findViewById or ViewBinding |
Reactive updates using remember and MutableState |
| Customization | XML attributes + custom views (sometimes complex) | Composable modifiers, flexible and powerful |
| Debugging | Straightforward, issues visible in layout XML | Can be harder due to recomposition and side effects |
| Performance | Good, optimized over years with some boilerplate | Efficient but may incur recomposition overhead |
| Tooling | XML Layout Editor (WYSIWYG), mature and stable | Compose Preview (newer, improving steadily) |
| Industry Direction | Legacy but still widely used | Google’s primary focus for future Android UI |
Overall, Jetpack Compose modernizes Android UI development by reducing boilerplate, improving maintainability, and offering a faster design-to-code workflow.
Below are some of the key concepts related to Jetpack Compose UI:
- Declarative UI with State-Driven Updates
- Composable Functions
- Single-Activity UI Architecture
- Compose State Management
- Composable Function without any UI Elements
- Fragments Unnecessary in Compose
- Compose Backstack Management
- How Compose Updates Work Under the Hood
- Navigation in Jetpack Compose
- Composable Data Communication
- Test Automation in Compose
- Summary: Main Features of Jetpack Compose
Declarative UI with State-Driven Updates
One of the primary feature of Jetpack Compose is use of declarative approach, similar to mobile UI frameworks of React Native and SwiftUI. Compose describe the UI as a function of the app’s state, as the screen layout is directly driven by the underlying data. When the state change occurs due to user actions, data updates, or programming logic, Compose automatically recomposes the relevant parts of the screen. This keeps the UI always in sync with the current state, without requiring manual updates or full re-rendering, as for view-based android UIs.
Composable Functions
The primary building blocks of Compose screens are @Composable functions, which define UI appearance and behavior. For example, for a simple screen displaying a greeting, composable function appears like:
@Composable
fun Greeting(name: String) {
Text(text = "Hello, $name!")
}
The @Composable annotation tells the Compose compiler to treat it as composable function, so update it in declarative way.
They are the native android equivalent of React-like components used in React Native for building mobile UIs. A @Composable
function can only be called only from another @Composable function or from a composable context, such as the setContent block of an Activity.
Single-Activity UI Architecture
A Compose based Android project often follows a single-activity architecture, where a single Activity (e.g., MainActivity) sets
the UI content using setContent { ... }. Inside this block, composables define the various screens and UI elements of the app. These
composables can display and update data (state), handle navigation, manage backstack, and pass information between screens.
When to use multiple activities based UI?
Though single-activity architecture is more common and convenient option, multiple activities in Compose based UI can be more suitable in certain scenarios, such as handling deep links that launch the app into a specific flow, working with third-party SDKs or external system components, or managing self-contained parts of the app that operate independently from the main user interface (isolated flows).Compose State Management
Understanding state management is essential to Compose-based Android UI development. In Jetpack Compose, state refers to any values that determine how the UI appears and responds over time. This includes things like user input, data fetched from a server, UI selections, or values computed through application logic.
Examples of state-driven UI behavior:
- A user types text into an input field (state = text value)
- A button is pressed and a counter is incremented (state = count)
- A network call returns new data (state = response data)
- A tab is selected in a navigation menu (state = selected tab index)
Jetpack Compose UI follows a lifecycle consisting of three key (or base) stages: Composition, Recomposition, and Disposal (also referred to as teardown).
- Composition is the initial construction of the UI hierarchy from composable functions.
- Recomposition updates only the updated UI parts in response to state changes.
- Disposal handles cleanup when composables leave the composition by releasing resources or cancelling coroutines.
These three stages form the foundation of UI behavior and state-driven updates in a declarative Compose-based Android application. Within this lifecycle framework, other required state management tools are organized and applied. This include:
- In the Composition phase, tools like
rememberandrememberSaveableare used to store values across recompositions. - Initialization logic can be introduced using
LaunchedEffectorDisposableEffect. - During Recomposition, Compose uses a snapshot system, powered by observable state containers such as
mutableStateOfandStateFlow, to rerun only the composables affected by changes. - Concepts like state hoisting and stable types further optimize recompositions.
- In the Disposal phase, cleanup logic is triggered via
onDisposeblocks insideDisposableEffectto release observers, listeners, or any external resources.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("Clicked $count times")
}
}
In the above code:
- count holds the number of times the button has been clicked.
- remember ensures the state persists across recompositions.
- mutableStateOf(0) initializes
countto 0. - Each button click updates the state with
count++. - Compose automatically triggers recomposition, updating the UI accordingly.
This illustrates the declarative and reactive nature of Compose: just describe how the UI should react to state changes, and Compose takes care of the rest.
Composable Functions without any UI Elements
Composable functions can define both UI and logic, and in some cases, they may not contain any visible UI elements.
Such a @Composable function differs from a regular function primarily because it can participate in the
composition process and lifecycle managed by the Jetpack Compose runtime. This allows it to manage state and handle
side effects in ways that regular functions cannot.
Fragments Unnecessary in Compose
In traditional View-based Android development, fragments were widely used to modularize UI, manage lifecycles, and build flexible screen flows within a single activity. They also played a key role in creating responsive layouts for larger screens, for example, displaying a list and its details side by side on tablets.
Jetpack Compose removes the need for fragments entirely. It uses @Composable functions to encapsulate UI logic in a much more lightweight and declarative way. These composables can be dynamically combined to build different screen arrangements, including responsive UIs for tablets and foldables, without relying on fragment transactions or manual lifecycle handling.
Compose encourages adaptive layouts through composables like Row, Column, and Box, and by checking device configuration using APIs like
WindowSizeClass or screen width from Configuration. This gives fine-grained control over how UI adapts across devices, all without the complexity of fragments.
Compose Backstack Management
In app navigation, a backstack refers to the internal stack i.e. a Last-In-First-Out (LIFO) structure—of screens the user has visited, allowing them to return to previous ones in reverse order.
In traditional view-based UIs, this backstack can be managed various ways using activities or fragments, with each new screen may or may not be added to the backstack, depending on how the navigation scenario is implemented. In a typical implementation, pressing the system back button would remove the top entry and return the user to the previous screen, if one existed.
Jetpack Compose follows the same core concept view-based UIs, but implements navigation in a declarative manner. The NavController class manages
a backstack of composable destinations, where each screen is defined by a composable function. Navigating to a new screen pushes it onto this backstack.
When navigating back for common options, either by pressing the system back button or calling popBackStack() programmatically,
Compose removes the current screen from the backstack and displays the previous one.
Compose also supports passing arguments between composables, observing navigation state changes, and conditionally rendering UI based on the current destination. These features enable building navigation flows that were previously managed through activities and fragments in a simpler, modular, and lifecycle-aware way.
How Compose Updates Work Under the Hood
Jetpack Compose handles UI updates automatically, but under the hood, it uses a Kotlin compiler plugin that transforms @Composable functions into a structure
capable of tracking inputs like state and detecting changes. When any state value read inside a composable changes, Compose marks that part of the UI as needing
recomposition. During recomposition, only the affected composables are re-executed and Compose doesn't redraw the entire screen. This comes from a precise mechanism
built into the runtime. Compose also maintains a composition tree (similar to a virtual DOM in React), which stores references and scopes for each composable. This allows it
to skip, reuse, or discard parts of the UI, as needed. There's no need to manually trigger updates or manage view lifecycles, as Compose takes care of it all declaratively.
Navigation in Jetpack Compose
Jetpack Compose provides a declarative and flexible navigation API to transition between different screens in android app.
Simple Navigation (Without Navigation Graph)
For basic use cases such as moving between two screens, Compose navigation uses three core elements:
- NavController – the navigation object used to control and trigger navigation actions
- NavHost – defines where the composable destinations are rendered
- composable(route) – declares a destination screen
Simple Navigation Between Composables
Jetpack Compose provides a modern way to navigate between different screens using NavController. Each screen can be a composable, and navigation is handled declaratively using NavHost.
Here's how to set up navigation between two composables, where a button on the first screen takes the user to the second, and a button on the second screen lets the user return back:
// Inside setContent { }
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "screen1") {
composable("screen1") { ScreenOne(navController) }
composable("screen2") { ScreenTwo(navController) }
}
Composable for Screen 1:
@Composable
fun ScreenOne(navController: NavController) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("This is Screen One")
Button(onClick = { navController.navigate("screen2") }) {
Text("Go to Screen Two")
}
}
}
Composable for Screen 2:
@Composable
fun ScreenTwo(navController: NavController) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("This is Screen Two")
Button(onClick = { navController.popBackStack() }) {
Text("Go Back to Screen One")
}
}
}
This simple navigation does not require a separate navigation graph. It's all declared inline using NavHost and composable() destinations.
Jetpack Compose Navigation with Navigation Graph
Jetpack Compose supports advanced navigation patterns using the Navigation component, which enables managing complex app flows through a centralized
Navigation Graph. This approach is suited for larger apps that involve multiple screens, nested flows, argument passing, bottom navigation, or deep linking.
Instead of defining navigation in XML (as with traditional Android Views), the graph is built directly in Kotlin using the
NavHost, NavController, and NavGraphBuilder.
A Navigation Graph allows to:
- Organize all screen routes in a central location
- Pass arguments safely between destinations
- Implement nested navigation graphs for modular flows
- Enable deep linking support
Key Components
- NavController: Manages app navigation and back stack operations between composable destinations.
- NavHost: A composable that connects the
NavControllerwith the navigation graph and displays the correct screen. - NavGraphBuilder: Used to define the list of composable destinations and how users navigate between them.
Defining the Navigation Graph
The navigation graph can be declared in a separate function using the NavGraphBuilder DSL:
// Defines the navigation graph
fun NavGraphBuilder.appGraph(navController: NavController) {
composable("screen1") { Screen1(navController) }
composable("screen2") { Screen2(navController) }
// Additional destinations can be added here
}
Setting Up NavHost in Main UI
Within the setContent block of the main activity, initialize the NavController and connect it to the NavHost:
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "screen1"
) {
appGraph(navController)
}
Navigating Between Screens
Navigation is performed using navController.navigate("destination"). For example:
// Inside Screen1
Button(onClick = { navController.navigate("screen2") }) {
Text("Go to Screen 2")
}
// Inside Screen2
Button(onClick = { navController.popBackStack() }) {
Text("Back to Screen 1")
}
This setup demonstrates navigation between two screens using route strings like navController.navigate("screen2").
While suitable for simple apps, larger applications benefit from a centralized navigation graph that provides type-safe route definitions,
argument handling, and modular navigation organization.
For real-world projects, defining a NavHost backed by a navigation graph is essential. Centralize all screen routes
(for example, using sealed classes or constants), register them within the NavHost, and take advantage of features
like deep linking, argument passing, and nested navigation flows.
Reference: Jetpack Compose Navigation (Android Developers)
Composable Data Communication
Communication between screens in Android apps is a fundamental requirement. Jetpack Compose supports passing data during navigation, whether it is simple text, numbers, or more complex objects. This allows state sharing and interaction across composables.
1. Passing Simple Parameters via Route
Basic types such as strings or integers can be passed through the navigation route.
// Navigation with a string parameter
composable("screen2/{message}") { backStackEntry ->
val message = backStackEntry.arguments?.getString("message")
Screen2(message)
}
// Navigate with argument
navController.navigate("screen2/HelloWorld")
2. Passing Objects via NavBackStackEntry
Objects can be passed using navigation arguments encoded in a shared ViewModel or by using a navigation result pattern.
// Shared ViewModel scoped to NavHost
@Composable
fun Screen1(sharedViewModel: SharedViewModel, navController: NavController) {
Button(onClick = {
sharedViewModel.setData(SomeData("value"))
navController.navigate("screen2")
}) {
Text("Go to Screen 2")
}
}
@Composable
fun Screen2(sharedViewModel: SharedViewModel) {
val data = sharedViewModel.getData()
Text("Received: ${data.value}")
}
3. Using Navigation Result for Backward Communication
For data that flows backward, navigation result APIs enable sending data back to a previous screen.
// Screen 1 - listening for result
val result = navController.currentBackStackEntry
?.savedStateHandle
?.getLiveData<String>("resultKey")?.observeAsState()
result?.value?.let {
Text("Received from Screen 2: $it")
}
// Screen 2 - setting result and navigating back
navController.previousBackStackEntry
?.savedStateHandle
?.set("resultKey", "Data from Screen 2")
navController.popBackStack()
These techniques provide robust ways to pass data between composables in modern Android applications.
Test Automation in Compose
Jetpack Compose supports test automation both at the unit level and the UI (functional) level, with dedicated APIs designed to simplify testing
in a fully declarative environment. Compose provides a built-in androidx.compose.ui.test library that enables writing UI tests in a way that matches
Compose's declarative style. For example, you can find and interact with UI elements using semantic tags like testTag, and verify behavior
without relying on IDs or view hierarchy traversal.
At the unit level, regular Kotlin testing techniques can be used to validate business logic or individual functions. Libraries like JUnit and
Mockito are used to mock dependencies or validate interactions. Since Compose encourages separation of logic (often via ViewModel), you can
easily write unit tests for state handling, event processing, and business rules without touching the UI layer.
At the UI or functional level, Compose’s test framework allows you to launch composables inside a test rule like createComposeRule. You can then
simulate user interactions such as clicks, text entry, and scrolling, and assert UI behavior and visibility. Unlike the traditional View system, no need
to use Espresso matchers like onView(); instead, Compose provides concise and type-safe APIs like onNodeWithText() or onNodeWithTag()
for targeting elements.
However, when testing hybrid apps or screens that still use traditional views or fragments alongside Compose, tools like Espresso can still be integrated.
Espresso can coordinate with Compose via the AndroidComposeTestRule, allowing synchronization across UI threads. Robolectric may also be used
for unit tests that do not require a full emulator or device, especially when testing logic inside a ViewModel or system-level behavior.
Mockito continues to be useful for mocking dependencies such as repositories, network layers, or state flows during testing.
Overall, Compose testing is modern, fast, and fits well with clean architecture. It reduces boilerplate, avoids fragile view hierarchies, and encourages clear separation of concerns, making automated testing more maintainable and expressive.
Summary: Main Features of Jetpack Compose
- Composable Kotlin Functions: UI elements are defined using functions marked with the @Composable annotation. These composable functions can be nested to build UI hierarchies and are automatically recomposed when the data they depend on changes.
- Live Previews and Live Edit: The @Preview annotation allows developers to see previews of composable functions inside Android Studio using sample data. Live Edit enables real-time updates to the UI on an emulator or physical device without restarting the app.
- Interoperability with XML: Jetpack Compose can coexist with existing View-based layouts. You can embed Compose UI within traditional XML-based screens and embed legacy Views within composables, allowing gradual migration in large projects.
- Powerful Animations: Compose provides built-in support for animations, including transitions, motion effects, and gesture-driven interactions, all using simple APIs that reduce boilerplate code.
- Accessibility: Compose includes built-in support for accessibility features such as screen readers and keyboard navigation. Developers can apply semantic properties to composables to ensure a more inclusive experience.
- Styling and Theming: UI elements in Compose are customized using modifiers, which allow control over layout, padding, colors, interactions, and more. Compose supports Material Design out of the box using structured themes based on Color, Typography, and Shape.
- State Management: Jetpack Compose uses state-driven UI updates. State can be stored using remember and rememberSaveable, and updated using mutableStateOf or integrated with ViewModel for complex scenarios. Compose also supports side-effects and lifecycle awareness through tools like LaunchedEffect and DisposableEffect.
- Testing Support: Compose integrates with testing tools like JUnit, Espresso, Mockito, and Robolectric. It also offers dedicated APIs such as ComposeTestRule and Compose UI test utilities for testing composables in isolation.
Jetpack Compose Sample Codes
-
Explore some sample codes for Jetpack Compose UI at:
Compose Sample Codes