Back to Insights

February 9, 2023

Jetpack Compose vs XML: Android UI development compared

What are the differences between Jetpack Compose and XML? Read more to find out!

Jetpack Compose vs XML: Android UI Development Compared

Jetpack Compose is an Android UI toolkit introduced by Google. It improves the way the UI is built in most current Android applications, simplifying the process and speeding it up. One of the best things about it is that it uses Kotlin.

Jetpack Compose, as its name suggests, makes use of the Composite pattern and declarative programming. So, before checking how it works, let’s explain some concepts.

Concepts

Before talking about composables, we need to know some useful concepts. So, I encourage you to read more about the following topics:

Android UI systems comparison

XML UI system

The XML UI system is based on inheritance.

All views inherit from View and all its child views, like a TextView, inherit all the View’s “knowledge”, for example, paint the background. You can think of a TextView as a better version of a View because it adds new functionality.

The same happens with EditText. It inherits from TextView. The “problem” here is that you can only get the knowledge from one specific parent.

Android UI systems comparison

Note that an EditText is a TextView and a TextView is a View.

Compose UI system

The Compose UI system is based on composition.

A composable can be considered as a View but the relationship is not parent-child like in inheritance. Here we have a composable that can have as many composable references as needed. Each composable can be thought of as a task. If you need a more specialized task, you can nest another composable inside another.

Note that Composable has another composable that has another composable and so on.

XML vs Compose code comparison

Let’s see some code.

Just to compare XML vs Compose let’s build a very simple layout:

XML Version

<?xml version="1.0" encoding="utf-8"?> 
<ScrollView xmlns: android= "http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" 
    android:layout_height="match_parent">

    <LinearLayout 
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:orientation="vertical"> 
        
        <TextView 
            android:id="@+id/header"
            style="@style/TextAppearance.MaterialComponents.Headline2"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" 
            android:layout_marginBottom="20dp" 
            android:paddingHorizontal="20dp" 
            android:text="Hello world" 
            android:textAlignment="center" />

        <ImageView 
            android:id="@+id/image"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:contentDescription="content image" 
            android:paddingHorizontal="20dp" 
            android:scaleType="centerCrop"
            android:layout_marginBottom="20dp"
            android:src="@drawable/ic_launcher_foreground" />

        <Button 
            android:id="@+id/button" 
            android:layout_width="wrap_content" 
            android:backgroundTint="@color/black"
            android:textColor="@color/white" 
            android:text="Upload"
            android:textAllCaps="false"
            android:layout_height="wrap_content" /> 

    </LinearLayout> 

</ScrollView> 

Same layout with Compose:

val scrollState = remembersScrollState()
val scope = rememberCoroutineScope()
var selectedAnimal by remember { mutableStateOf<Animal?>(null) }

Column(
    modifier = Modifier
        .fillMaxSize()
        .verticalScroll(scrollState),
    verticalArrangement = Arrangement.spacedBy(20.dp),
    horizontalAlignment = Alignment.CenterHorizontally
) {
    Text(
        text = "Hello world",
        style = MaterialTheme.typography.h2,
        textAlign = TextAlign.Center,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 24.dp)
    )

    Image(
        painter = painterResource(
            id = selectedAnimal?.imageSrc ?: R.drawable.ic_launcher_foreground
        ),
        contentDescription = "content image",
        modifier = Modifier
            .fillMaxWidth(fraction = 0.75f)
            .aspectRatio(3 / 8f),
        contentScale = ContentScale.Crop
    )

    Button(
        onClick = {
            scope.launch {
                scrollState.animateScrollTo(0)
            }
        },
        colors = ButtonDefaults.buttonColors(
            backgroundColor = Color.Black,
            contentColor = Color.White,
        )
    ) {
        Text(text = "Upload")
    }
}

In compose, functions are used to implement component inheritance. A component function can only be called in another composable function. More atomic components mean a more flexible components structure (but more components to maintain).

In the composable code, you might notice some strange “state” code. Let’s talk a little bit about it.

State management

First of all, what is the state in an app? The state in an app is any value that can change over time. The State determines what is shown in the UI at a particular time.

If we create a composable like this:

@Composable
fun Greetings() {
  var name = "world"
  Text(
    text = "Hello $name"
  )
}

We are creating a static composable that prints “Hello world” and we have no chance of updating it.

So, how can we change a composable state? The answer is: events.

Events in compose

Events are inputs generated from outside or inside our application. For example, clicking on a button in the app or receiving a response from a network request.

In all Android apps, there is an UI update loop. This is how it looks:

At first, an initial state is displayed on the screen. If an event happens, the event handler changes the state and now the UI displays the new state on the screen, and so on.

If we modify our current Composable to be something like this:

@Composable
fun Greetings() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name = "world"
        Text(
            text = "Hello $name"
        )
        Button(onClick = {name = “user”}, modifier = Modifier.padding(top = 8.dp)
        ) {
            Text(“Update text”)
        }
    }
}

After pressing the button you will notice that the UI is not updating. So why? Now the answer is recomposition.

Recomposition

Basically, when the state in a Composable has changed, it has to be re-run to show the new state on the screen.

Compose is able to track the Composable state and schedule recompositions when the state has changed. It’s important to say that only the affected composables will be re-rendered and not the whole UI. To know more about recomposition please refer to the following documentation: Lifecycle of composables

So, we have to use the Compose API to achieve this behavior. Let’s modify our current composable:

@Composable 
fun Greetings() { 
    Column(modifier = Modifier.padding(16.dp)) { 
        var name: MutableState<String> = remember { mutableStateOf("world") }
        Text( 
            text "Hello ${name.value}"
        ) 
        Button(onClick = {name.value = “user”}, modifier = Modifier.padding(top = 8.dp)){ 
            Text(“Update text”)
        }
    }
}

Here we can see some particular Compose stuff:

  • MutableState: Wraps a state of type T (generic) inside a value parameter that is observed by Compose. So to access the state, you have to call this value inside the wrapper. It is initialized using mutableStateOf with the initial value.
  • remember: inline Composable function. A value calculated inside remember is stored in the composition and the stored value is kept across the recompositions. If we want to remember the state between configuration changes we should replace this with rememberSaveable (it persists the state in a Bundle).

Usually MutableState and remember are used together in composable functions.

Using delegates could improve code readability and make it much simpler:

@Composable 
fun Greetings() { 
    Column(modifier = Modifier.padding(16.dp)) { 
        var name by remember { mutableStateOf("world") }
        Text( 
            text = "Hello $name"
        ) 
        Button(onClick = {name = “user”}, modifier = Modifier.padding(top = 8.dp)){ 
            Text(“Update text”)
        }
    }
}

State hoisting

Before talking about state hoisting, let’s mention what stateful and stateless composables are.

Stateful composable: is a composable that holds its own state

Stateless composable: is a composable that doesn’t hold any state

With that in mind, let’s talk about state hoisting.

State hoisting in Compose, is a pattern of moving state to a composable’s caller to make a composable stateless. It has some important benefits:

  • Single source of truth
  • It can be shared with multiple composables
  • It is interceptable by callers that can decide to ignore or modify the state
  • It decouple the state from the composable itself

To see it more clearly, let’s refactor our previous composable into a stateful and stateless widget:

@Composable
fun StatefulGreetings() {
    var name by remember { mutableStateOf("world") }
    StatelessGreetings(text = name, onClicked = { name = “user” })
}

@Composable
fun StatelessGreetings(text: String, onClicked: () -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello $text"
        )
        Button(onClick = onClicked, modifier = Modifier.padding(top = 8.dp)) {
            Text(“Update text”)
        }
    }
}

Working with lists

Building a list with XML

To show items in a list with XML, we need a few files and/or classes:

  1. A RecyclerView or a ListView in the parent ViewGroup
  2. A Recycler adapter
  3. A ViewHolder
  4. An item xml layout

It is more complicated than it should be. A lot of code to show a simple items list.

Building a list with Compose

We only need a LazyColumn. Just that. In the item/items scope we can specify the item composable. Look at the example:

LazyColumn( 
    modifier = Modifier 
        .fillMaxSize() 
        .padding(horizontal = 24.dp), 
    verticalArrangement = Arrangement.spacedBy(20.dp), 
    horizontalAlignment = Alignment.CenterHorizontally 
) {

    item {
        Text( 
            text = "List title", 
            style = MaterialTheme.typography.h1, 
            textalign = TextAlign.Center, 
            modifier = Modifier.fillMaxWidth() 
        )
    }

     // Assume that getListItems() returns a list of objects with a text property 
     items(getListItems()) { item -> 
        Text(text = item.text) 
    }
}

There are a lot of things to take into account when building an items list like recomposition, keys, paging, etc. But since this is an introductory blog post I recommend visiting the following link: Lists and grids

Navigation

In Compose, everything is composable. And Navigation is not an exception. To perform a navigation, every screen should have a unique route (composable path) and, we will also need a NavHost. Let’s check how to build it.

Suppose that we have two screens in our app: Home and Profile. And we want to navigate from Home to Profile. It is as simple as creating both screens and building a NavHost with the required routes. In this case both composables will be created and navigated in the MainActivity.

MainActivity

@Composable 
fun AppNavigation() { 
    val navController = rememberNavController() 
    NavHost( 
        navController = navController, 
        startDestination = Navigator.getStartDestination() 
    ) { 
        composable(Navigator.Home.route) { HomeScreen(navController) } 
        composable(Navigator.Profile.route) { ProfileScreen(navController) }
    }
}

sealed class Navigator(val route: String) { 
    object Home : Navigator("home") 
    object Profile : Navigator("profile") 
    companion object { 
        fun getStartDestination() = Home.route
    }
}

HomeScreen

@Composable
fun HomeScreen(
    navController: NavController = rememberNavController()
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = "Home")
        Button(
            onClick = { navController.navigate(Navigator.Profile.route) },
        ) {
            Text(text = "Go to Profile screen")
        }
    }
}

ProfileScreen

@Composable
fun ProfileScreen(
    navController: NavController = rememberNavController()
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(text = "Profile")
        Button(
            onClick = { navController.navigateUp() },
        ) {
            Text(text = "Back")
        }
    }
}

Note that the navController is created in the navigator and passed to the required screens. This follows the principles of state hoisting and allows you to use the NavController and the state it provides via currentBackStackEntryAsState() to be used as the source of truth for updating composables outside of your screens. An example of the usage of this functionality could be the usage of a BottomNavigation: Navigating with Compose

Official Jetpack tutorial

This is the official pathway for Jetpack Compose. It includes articles, videos and codelabs.

Try it out: Jetpack Compose | Android Developers

Conclusion: In Android, Compose represents a complete UI redesign

The common practice for building a UI has been Imperative programming. It’s a robust practice but it becomes quite complex when an app is too big and has too many UI elements. Declarative UI, has shown in other frameworks like React and Flutter, that the adoption and implementation is much easier and the development performance increases.

Specifically in Android, Compose has been a complete redesign of the UI system. It has some advantages over traditional XML system like:

  • Less code required
  • Only Kotlin is needed
  • Intuitive and faster development
  • Easier to build custom views because of its flexibility

If you have been working with XML views until now, it could be that it will require some time to get used to the technology, but its learning curve is not hard at all, so don’t worry!

Get hands on with Compose and see you in the next blog post! And in the meantime, learn more about the work we do in the Qubika App Solutions Studio!

Useful resources

Jetpack Compose | Android Developers

Why Compose | Jetpack Compose | Android Developers

Thinking in Compose | Jetpack Compose | Android Developers

State and Jetpack Compose | Android Developers

Lifecycle of composables | Jetpack Compose | Android Developers

Lists and grids | Jetpack Compose | Android Developers

Navigating with Compose | Jetpack Compose | Android Developers

andres de la grana

By Andres De La Grana

Mobile Developer

Andres de la Grana is a Mobile Developer at Qubika. With 5+ years of experience, Andres is passionate about Android development. He has worked on various scalable applications with lots of users, and believes in creating clean code and following best practices.

News and things that inspire us

Receive regular updates about our latest work

Let’s work together

Get in touch with our experts to review your idea or product, and discuss options for the best approach

Get in touch