Migrating to Android Jetpack Compose

So your Android projects have been using XML layouts since forever, and you’ve finally decided to use Jetpack Compose.

It might seem daunting to change all of your XML layouts in one shot, so I propose doing it piece by piece. The rest of this article is a method for an easier migration. The main idea is to replace each layout with its corresponding Composable and make sure that the Composable is ±100% identical to its predecessor.

The main steps in the migration are:

The first step is to add a layout which will be our Compose view container for “fullscreen” views, primarily created by fragments. (I am aware of the irony that adding XML layouts is the opposite of what we want to achieve.)

res/layout/compose_full.xml

<androidx.compose.ui.platform.ComposeView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/composeView"
android:layout_width="match_parent"
android:layout_height="match_parent" />

A quick reminder about some of the basic corresponding XML-Composable pairs:

Let’s work through a simple example, assuming that your project already has all of the necessary Jetpack Compose dependencies…

Suppose we have a basic application that shows a list. Usually the app will have an XML layout for the main activity. Then that XML will have a placeholder for the main fragment. The main fragment will itself have an XML layout.

For now let’s focus on the fragment with the list:

<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:listitem="@layout/list_item" />

Each list item has its own XML that will be used in the ViewHolder:

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardElevation="3dp"
app:cardCornerRadius="15dp">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp">

<ImageView
android:id="@+id/image"
android:layout_width="60dp"
android:layout_height="60dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toTopOf="@id/image"
tools:text="@tools:sample/full_names" />

<TextView
android:id="@+id/address"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toBottomOf="@id/name"
tools:text="@tools:sample/cities" />

</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
The list using some sample data

The first step is to replace each list item with a Composable. Then replace the list itself with a Composable.

When migrating each layout, it would be nice to see just how accurate the replacement is, so let’s overlay the Composable on top of the old XML:

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

<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="15dp"
app:cardElevation="0dp">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp">

<ImageView
android:id="@+id/image"
android:layout_width="60dp"
android:layout_height="60dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toTopOf="@id/image"
tools:text="@tools:sample/full_names" />

<TextView
android:id="@+id/address"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toBottomOf="@id/name"
tools:text="@tools:sample/cities" />

</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:alpha="0.75" />
</FrameLayout>

Next, create the Composable…

@Composable
fun PersonItem(person: Person) {
Card(
modifier = Modifier
.padding(8.dp)
.fillMaxWidth(),
elevation = 3.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Image(
modifier = Modifier.size(60.dp),
painter = rememberVectorPainter(
image = ImageVector.vectorResource(
id = person.image ?: R.drawable.avatar_1
)
),
contentDescription = ""
)
Column(
modifier = Modifier
.padding(start = 16.dp)
.fillMaxWidth()
) {
Text(text = person.name, fontWeight = FontWeight.Bold)
Text(modifier = Modifier.padding(top = 4.dp), text = person.address)
}
}
}
}

… and inject it into the XML placeholder.

class PersonHolder(private val binding: PersonItemBinding) : ViewHolder(binding.root) {

fun bind(person: Person) {
binding.image.setImageResource(person.image ?: R.drawable.avatar_1)
binding.name.text = person.name
binding.address.text = person.address

binding.composeView.setContent {
AppTheme {
PersonItem(person = person)
}
}
}
}

The app now looks a little bit drunk.

Notice that the card and image are an excellent match, but the default text styles in Text are not identical to their TextView counterparts. Let’s fix that with a little bit of tweaking…

...
Text(
text = person.name,
style = MaterialTheme.typography.body2,
fontWeight = FontWeight.Medium
)
Text(
modifier = Modifier.padding(top = 4.dp),
text = person.address,
style = MaterialTheme.typography.body2
)
...

… and voilà

Now to get rid of that clunky set of RecyclerView and its RecyclerView.Adapter and RecyclerView.ViewHolder.

@Composable
fun PeopleList(items: List<Person>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = rememberLazyListState()
) {
items(count = items.size) { index ->
PersonItem(person = items[index])
}
}
}

Replace the RecyclerView with id “list” element in the XML with a ComposeView, and the adapter with the Composable, so that

binding.list.adapter = adapter

now becomes

binding.composeView.setContent {
AppTheme {
PeopleList(items = items)
}
}

So there you have it!

A relatively easy step-by-step migration to Jetpack Compose for an easy app. Of course, there are still more complex elements to migrate such as click events, animations, and Flows.

Anyways, this is my suggestion for a less-painful migration process.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store