Introduction to Jetpack Compose
Over the years, Android UI development has undergone significant transformations with various frameworks and libraries emerging to simplify the process.
Before Jetpack Compose, this is how we used to write UIs for our apps:
- Views were inflated from XML layout files. XML-based views are still supported alongside Jetpack Compose for backward compatibility and mixed use cases where apps have both XML layouts and Jetpack Compose.
- Themes, styles, and value resources were also defined in XML files.
- For us to be able to access the views from XML files, we used view binding or data binding.
- This method of writing a UI required huge effort, requiring more boilerplate code and being error prone.
Google developed Jetpack Compose as a modern declarative UI toolkit. It allows us to create UIs with less code. Layouts created in Jetpack Compose are responsive to different screen sizes and orientations. It is also easier and more productive to write UIs in Compose. With Jetpack Compose, we can reuse components across our code bases. Jetpack Compose also allows us to use code from XML components in our composables.
Jetpack Compose is entirely in Kotlin, meaning it takes advantage of the powerful language features that Kotlin offers. The view system, which was used to create UIs before Compose, was more procedural. We had to manage complex life cycles and handle any changes in state manually. Jetpack Compose is a whole other paradigm that uses declarative programming. We describe what the UI should be like based on a state. This enables us to have dynamic content and less boilerplate code and develop our UIs faster.
To understand Jetpack Compose, let us first dive deep into the differences between the declarative and imperative approaches to writing UIs.
Declarative versus imperative UIs
In imperative UIs, we specify step by step the instructions describing how the UI should be built and updated. We explicitly define the sequence of operations to create and modify UI elements. We rely on mutable state variables to represent the current state of the UI. We manually update these state variables as the UI changes and respond to user interactions.
In declarative UIs, we focus on describing the desired outcome rather than specifying the step-by-step instructions. We define what the UI should look like based on the current state, and the framework handles the rest. We define the UI using declarative markup or code. We express the desired UI structure, layout, and behavior by describing the relationships between UI elements and their properties.
The declarative approach puts more emphasis on the immutable state, where the UI state is represented by immutable data objects. Instead of directly mutating the state, we create new instances of the data objects to reflect the desired changes in the UI.
In a declarative UI, the framework takes care of updating the UI based on changes in the application state. We specify the relationships between the UI and the underlying state, and the framework automatically updates the UI to reflect those changes.
Now that we understand both imperative and declarative approaches, let’s look at an example of each. Let’s create a simple UI for a counter using both the declarative UI in Jetpack Compose (Kotlin) and the imperative UI in XML (Android XML layout). The example will showcase the differences in syntax and the approach between the two. The Jetpack Compose version looks like this:
import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyApp() } } } @Composable fun MyApp() { var count by remember { mutableStateOf(0) } Column( modifier = Modifier.padding(16.dp) ) { Text(text = "Counter: $count", style = MaterialTheme.typography.bodyLarge) Spacer(modifier = Modifier.height(16.dp)) Button(onClick = { count++ }) { Text("Increment") } } }
In the preceding example, we have a MyApp
composable function that defines the UI for the app. The UI is defined in a declarative manner, by using composables to define the UI and handling state changes using the remember composable. The UI is defined using a functional approach. Also, we can see that the UI is defined in a more concise manner.
With the imperative approach, we must first create the XML UI, as shown in the following code block:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:padding="16dp"> <TextView android:id="@+id/counterTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerHorizontal="true" android:text="Counter: 0" android:textSize="20sp" /> <Button android:id="@+id/incrementButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@id/counterTextView" android:layout_centerHorizontal="true" android:layout_marginTop="16dp" android:text="Increment" /> </RelativeLayout>
With the layout file created, we can now create the activity class, which will inflate the layout file and handle the button click:
import android.os.Bundle import android.widget.Button import android.widget.TextView import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { private var count = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) val counterTextView: TextView = findViewById(R.id.counterTextView) val incrementButton: Button = findViewById(R.id.incrementButton) incrementButton.setOnClickListener { count++ counterTextView.text = "Counter: $count" } } }
In this example, the XML layout is inflated in the onCreate
method of the MainActivity
class, and UI elements are accessed and manipulated programmatically.
In the preceding examples, the Jetpack Compose code is written in Kotlin and provides a more declarative approach, defining the UI in a functional manner. The XML layout, on the other hand, is written imperatively in XML, specifying the UI structure and properties in a more step-by-step manner using XML and interacting with them imperatively in Kotlin code. Jetpack Compose allows for a more concise and expressive representation of the UI using a declarative syntax.
Now that we have a clear understanding of the imperative and declarative ways of writing UIs, in the next section, we will be diving deep into the building blocks of Jetpack Compose.
Composable functions
As shown in Figure 3.1, composable functions are the main building blocks of Jetpack Compose:
Figure 3.1 – Compose UI
A composable function describes how to render a UI. This function must be annotated with the @Composable
function. When you annotate a function with this annotation, it means that the function describes how to compose a specific part of the UI. Composable functions are meant to be reusable. They can be called multiple times while the UI is active. Whenever the state of the composable changes, it goes through a process of recomposition, which enables the UI to display the latest state.
Composable functions are pure functions, meaning they don’t have any side effects. They produce the same output when called several times with the same input. This ensures the functions are predictable and efficient in dispatching updates to the UI. However, there are exceptions, for example, launching a coroutine within a composable of calling external methods that do have side-effects, which should be avoided or handled carefully.
Smaller composable functions can be combined to build complex UIs. You can reuse and nest composables inside other composables.
Let’s look at an example of a composable function:
@Composable fun PacktPublishing(bookName: String) { Text(text = "Title of the book is: $bookName") }
In the preceding code snippet, the PacktPublishing
function is annotated with the @Composable
annotation. The function takes a parameter, bookName
, which is a String
. Inside the function, we have another composable from the Material Design library. The composable renders some text to our UI.
When designing our UIs, we usually want to see how the UIs look without running our app. Luckily, we have previews, which visualize our composable functions. We will be learning about them in the next section.
Previews
In Jetpack Compose, we have the @Preview
annotation, which generates a preview of our composable function or a group of Compose components inside Android Studio. It has an interactive mode to allow us to interact with our Compose functions. This gives us a way to quickly visualize our designs and easily make changes when needed.
This is how our PacktPublishing
composable function would look like with a preview:
@Preview(showBackground = true) @Composable fun PacktPublishingPreview() { PacktPublishing("Android Development with Kotlin") }
We have used the @Preview
annotation to indicate that we want to build a preview for this function. Additionally, we have set the showBackground
parameter to true
, which adds a white background to our preview. We have named the function with the Preview
suffix. The preview is also a composable function.
To be able to see the preview, you need to be in the split or design mode in your editor. These options are normally at the top right of Android Studio. We also need to do a build for Android Studio to generate a preview, which will look as follows:
Figure 3.2 – Text preview
As seen in Figure 3.2, we have a text that displays the string that we passed to the function. The preview also has a white background and its name at the top left.
We can show previews for both dark and light color schemes. We can also configure properties such as the devices and preview windows to be applied.
Previews are great for quick iterations while designing UIs. However, they are not a replacement for actual device/emulator testing, particularly for things such as animations, interactions, or dynamic data.
With an understanding of what previews are and how to create them, let us look into one more Compose feature, modifiers, in the next section.
Modifiers
Modifiers allow us to decorate our composable functions by enabling the following:
- Change composables’ size, behavior, and appearance
- Add more information
- Process user input
- Add interactions such as clicks and ripple effects
With modifiers, we can change various aspects of our composable, such as size, padding, color, and shape. Most Jetpack Compose components from the library allow us to provide a modifier as a parameter. For example, if we need to provide padding to our preview text, we will have the following:
Text( modifier = Modifier.padding(16.dp), text = "Title of the book is: $bookName" )
We have added the padding modifier to the Text
composable. This will add 16.dp
padding to the Text
composable. 16.dp
is a density-independent pixel unit in Jetpack Compose. This means it will remain consistent and adjust properly to different screen densities.
We can chain the different modifier functions in one composable. When chaining modifiers, the order of application is crucial. If we don’t achieve the desired result, we need to double-check the order. Let’s observe this concept in practice:
Text( modifier = Modifier .fillMaxWidth() .padding(16.dp) .background(Color.Green), text = "Title of the book is: $bookName" )
We have added two more modifiers. The first is the fillMaxWidth
modifier, which is added to the text composable. This will make the text composable take the full width of the parent. The other one is the background modifier to the Text
composable. This will add a background color to the text composable. The preview for our text will look as follows:
Figure 3.3 – Text modifier preview
As seen in the preceding screenshot, the text now occupies the whole width of the device and has a green background. It also has a padding of 16dp
all around.
Modifiers do not modify the original composable. They return a new, modified instance. This ensures our composable remains unchanged and immutable. Immutability, a fundamental principle in functional programming, ensures that the state remains unchanged, simplifying state management and reducing side effects. This approach enhances predictability and readability by adhering to the principles of referential transparency. The ability to compose functions, exemplified by chaining modifier functions, facilitates a concise and readable expression of complex UI behavior without altering the original composable. In addition to using the existing modifiers, we can also create our own modifiers when needed.
Now that you have an understanding of what modifiers are, we are going to build on that knowledge by learning about Jetpack Compose layouts in the next section.