Jetpack Compose - how does it work?

Table of contents

Jetpack Compose - how does it work?

Introduction

Are you exhausted by creating Android UI in XML files? Are you wondering why implementation of a simple view requires so much work? Are you tired of parameterizing of all components, repeating all assignments, positioning objects, creating nested views, implementing adapters for lists and then more View Binding, Data Binding… Don’t worry, forget about that. Now we have Jetpack Compose! All your designs might be implemented quicker, as a declarative UI, using simple Kotlin API, getting rid of boilerplate code. So, let’s take a look how it works.

Setup environment

To use a Jetpack Compose, the toolkit to building Android native UI, we need to prepare our environment. The recommended IDE for working with Jetpack Compose is Android Studio. To create a new application with support for Compose we might select an option Empty Compose Activity from the Project Template window. To set up Compose for an existing app we need to add following build configurations to the project (“x.y.z” is a placeholder for a version number).

android {
  buildFeatures {
    compose true
  }
  composeOptions { 
    kotlinCompilerExtensionVersion = "x.y.z"
  } 
}

and sample set of Compose dependencies:

dependencies { 
...
  implementation 'androidx.activity:activity-compose:x.y.z' 
  implementation 'androidx.compose.ui:ui:x.y.z'
  implementation 'androidx.compose.ui:ui-tooling-preview:x.y.z' 
  implementation 'androidx.compose.material:material:x.y.z'
}

So, now we are ready to start develop our application.

Composable functions

Composable functions are the most primary elements to implement views. They are annotated with @Composable and define a single view unit. They may contain basic compose components e.g. (Text, Button) or customized views (another composable function). There is an example here:

@Composable
fun ContactRow(user: User) {
  Row {
    Image (
      painter = painterResource(id = R.drawable.user), 
      contentDescription = "A photo of a user"
      )
      Column {
        Text(user.name)
        Text(user.phone)
      }
   } 
}

As we can see this is a ordinary function. We can put parameters to it and use them in components. Very often the composable function is defined in a separate file, similar to a class definition. Row and Column objects define an arrangement and positioning of elements. We can put that function to another composable function to be a part (fragment) of a broader view. The most overriding view function should be placed on setContent {..} area in onCreate activity callback to be displayed as a screen view.

Basic elements

The Jetpack libraries provides many basic compose UI elements.

Row and Column defines a position dependency between components included inside them. Row creates a horizontal arrangement for all elements, while Column vertical. We can set more specified properties e.g. distances between elements or their alignment. Entire content may be included in Box layout composable. There is an example below:

Box(modifier = Modifier.fillMaxSize()) { 
  Row(
    modifier = Modifier 
      .fillMaxSize()
      .padding(16.dp)
  ){
    Column(
      modifier = Modifier 
        .fillMaxHeight()
        .weight(1f)
        .padding(end = 8.dp), 
     horizontalAlignment = Alignment.Start, 
     verticalArrangement = Arrangement.Center
   ) {...} 
   Column(
     modifier = Modifier .fillMaxHeight()
       .weight(1f)
       .padding(end = 8.dp),
    horizontalAlignment = Alignment.End, 
    verticalArrangement = Arrangement.SpaceBetween
    ) {...} 
  }
}

We defined a row that fills the entire view. Inside the row we have two half-width columns and with the same height as the row. Additionally we defined paddings and exact position of columns elements. We have used following parameters:

modifier – a fundamental parameter to determine sizes, paddings, background, border or weight

weight determines the element’s width proportional to its weight relative to other weighted sibling elements in the Row, might be applied for Row and Column

horizontalAlignment (Column)/ verticalAlignment (Row) – alignment for objects in a specific layout

verticalArrangement (Column)/ horizontalArrangement (Row) – determines a position for all elements or between them e.g. Arrangement.Center centers the entire set of elements inside Row or Column, while Arrangement.SpaceBetween stretches them along their entire length at equal intervals

To add basic elements we can use e.g. Text, Button or Image composables. Implementation and customization is trivial and relies on overwriting some defaults parameters. It can look like:

Text (
    modifier = Modifier.padding(12.dp),
    text = stringResource(R.string.sample_text), style = MaterialTheme.typography.body1, maxLines = 1,
    overflow = TextOverflow.Ellipsis
)
Button (
    modifier = Modifier.align(Alignment.CenterHorizontally), 
    onClick = {
      ... 
    }
) {
    Text(text = stringResource(R.string.save_btn_text))
}
Spacer(
    modifier = Modifier
      .fillMaxWidth() 
      .height(1.dp) 
      .background(Color.DarkGray)
)

The customization is mainly based on modifier parameter. For the Text object we can define e.g. style, maxLines or overflow. For the Button we have a space for onClick callback and we are able to define exact appearance by putting a specific layout to the RowScope area. It’s a possibility to create a spacer object as we can see above.

The Jetpack Compose changes many thigs. The one of them is a way to implement a navigation mechanism for the application. For this purpose we can use Jetpack Compose Navigation, which is a set of related classes that help us create clearly structured code for navigation.

To implement an application, which allows to navigate between several different views, we can consider NavHost, NavController and Navigation composable functions. That gives us a possibility to create a single activity application. First of all we need to add following dependencies and define a main activity with NavHost and NavController.

dependencies { 
...
    implementation 'androidx.navigation:navigation-compose:x.y.z'
}

This part can be put to our activity

override fun onCreate(savedInstanceState: Bundle?) { 
  super.onCreate(savedInstanceState)
  setContent {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = "home") {
      composable("home") { 
        HomeScreen(navController)
      }
      composable("settings") {
        SettingsScreen (navController)
      }
      composable("profile") { 
        ProfileScreen(navController)
      } 
    }
  } 
}

The NavHost is the container that holds all of the navigation destinations within the app. The NavController is responsible for managing the navigation between different destinations and the Navigation composable is used to declare a specific destination within the NavHost. HomeScreen, SettingsScreen and ProfileScreen represent separate views and implement a composable function for them.

To navigate between screens we can use navController passed to each screen.

val route = "profile" // or "home" or "settings" 
navController.navigate(route)

Recomposition and mutableStateOf

A group of composables and the way they relate to each is called a composition. The recomposition is a process triggered if the composition structure and relations change.

To store a field state we have at our disposal e.g. a state holder. The state might be performed in composable functions and his initialization is shown below.

var fieldState = remember { 
  mutableStateOf(1)
}

or

var fieldState by remember { 
  mutableStateOf(1)
}

The remember keyword ensures that the value will be initialized only once and won’t be changed during a recomposition. The first variant provides State holder variable, the second one by using delegates provide a direct access to the variable Int. The fieldState used to determine a content of the specific view is able to trigger a recomposition process. That means if the value changed, the all components associated with it will be refreshed. This way we don’t need to take care about call all functions whenever the input changes.

LazyColumn

Implementation of adapter for RecyclerView has always been problematic. Jetpack Compose make it thing much easier. We can just use a LazyColumn composable to define and handle a list view without boilerplate code.

LazyColumn is a column which can take variable number of items. We have to determine how to display and handle each item. Additionally we can associate LazyColumn with a mutable list and define all interactions and dependencies between them to keep a consistency. There is an example implementation below:

var reportList by remember { 
  mutableStateOf<List<Report>>(emptyList())
}
...
LazyColumn(modifier = Modifier.fillMaxWidth()) {
    items(reportList.size) {
        ReportListItem(
            item = reportList[it],
            onItemClick = {
               // action after clicking on item
            }
       ) 
   }
}
...
@Composable
fun ReportListItem(
    modifier: Modifier = Modifier,
    item: Report,
    onItemClick: (Report) -> Unit
) {
  // implementation of item view
}

Summary

To sum up the Jetpack Compose is a powerful toolkit to build declarative UI. It simplifies and accelerates UI development on Android. The above set of fundamental topics is only small part of its capabilities, but it aims to introduce to a new way of creating Android UI. I’m sure that every developer will quickly notice advantages of that solution.