Skip to content
All posts
August 27, 20266 min read

Migrating a View-Based Screen to Jetpack Compose

A hands‑on walkthrough of converting a legacy XML View layout and its surrounding Activity or Fragment into a modern Jetpack Compose implementation, keeping functionality intact while boosting maintainability.

AndroidKotlinJetpack ComposeArchitecture
Share:

The Reality of Legacy Screens

I still remember the first time I opened a 2012‑era Android app that still used plain XML layouts and verbose

code
findViewById
calls. The code was a maze of
code
setContentView(R.layout.activity_main)
, endless
code
View
casts, and a handful of
code
OnClickListener
s that lived side‑by‑side with business logic. After 13 years in QA and having shipped 22+ apps from my Bangkok‑based solo studio, I realized that those old patterns were slowing me down.

Every new feature meant digging through

code
View
hierarchies, fighting nullability, and patching UI tests that were already brittle. The turning point came when Google announced stable Jetpack Compose support. The promise of a declarative UI, less boilerplate, and seamless state integration was tempting, but migrating a full‑blown screen felt like a mountain. I decided to tackle it head‑on with a real‑world example: a simple “User Profile” screen that I had been maintaining for over two years.

Assessing the Existing View Layout

Before any code rewrite, I documented the current architecture. The old screen lived in an

code
Activity
that inflated
code
activity_user_profile.xml
. The layout consisted of a
code
LinearLayout
wrapper, an
code
ImageView
for the avatar, three
code
TextView
s for name, email, and bio, and a
code
Button
to edit the profile.

The business logic was tucked into the same

code
Activity
. A
code
UserRepository
(implemented with Room) supplied a
code
LiveData<User>
that the UI observed via an observer. Navigation was handled with AndroidX NavComponents, and the screen was tested with Espresso.

I created a simple table to capture the before/after characteristics.

AspectView‑Based ImplementationCompose‑Based Implementation
UI DefinitionXML resource (
code
activity_user_profile.xml
)
Kotlin function (
code
UserProfileScreen()
)
View Binding
code
findViewById<T>()
calls
code
remember { … }
state and
code
DerivedStateOf
State Observation
code
LifecycleObserver
+
code
Observer<LiveData>
code
StateFlow
/
code
SharedFlow
+
code
collectAsState
ThemingStyles and themes defined in XMLMaterial 3 theme APIs +
code
MaterialTheme
TestingEspresso + ViewMatchersCompose Test rules +
code
ComposeAssertion
NavigationNavGraph with
code
action
IDs
NavHostComposable with
code
navActions

[!NOTE]
Keeping a side‑by‑side comparison helps you identify which pieces of logic can be reused and which need a refactor.

Mapping Views to Compose UI Elements

The first step was to translate each XML component into its Compose counterpart.

XML snippet (original):

xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <ImageView
        android:id="@+id/ivAvatar"
        android:layout_width="96dp"
        android:layout_height="96dp"
        android:src="@drawable/ic_avatar"
        android:contentDescription="@string/avatar_desc" />

    <TextView
        android:id="@+id/tvName"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="John Doe"
        android:textSize="20sp"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/tvEmail"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="john@example.com"
        android:textColor="@color/gray" />

    <TextView
        android:id="@+id/tvBio"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Android enthusiast 🚀"
        android:maxLines="2"
        android:ellipsize="end" />

    <Button
        android:id="@+id/btnEdit"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="@string/edit_profile" />
</LinearLayout>

Compose equivalent (Kotlin):

kotlin
@Composable
fun UserProfileScreen(
    user: User,
    onEditClick: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier
            .fillMaxWidth()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        AvatarImage(user.avatarUrl)
        NameText(user.name)
        EmailText(user.email)
        BioText(user.bio)
        EditButton(onClick = onEditClick)
    }
}

@Composable
private fun AvatarImage(avatarUrl: String) {
    AsyncImage(
        model = avatarUrl,
        contentDescription = "User avatar",
        modifier = Modifier.size(96.dp),
        placeholder = painterResource(R.drawable.ic_avatar_placeholder),
        error = painterResource(R.drawable.ic_avatar_error)
    )
}

@Composable
private fun NameText(name: String) {
    Text(
        text = name,
        fontSize = 20.sp,
        fontWeight = FontWeight.Bold
    )
}

@Composable
private fun EmailText(email: String) {
    Text(
        text = email,
        color = Color.Gray,
        fontSize = 14.sp
    )
}

@Composable
private fun BioText(bio: String) {
    Text(
        text = bio,
        maxLines = 2,
            overflow = TextOverflow.Ellipsis,
        fontSize = 14.sp
    )
}

@Composable
private fun EditButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text(text = "Edit Profile")
    }
}

The migration required three mental shifts:

  1. Structure over hierarchy – Instead of nested XML tags, we compose a hierarchy of
    code
    @Composable
    functions.
  2. State ownership – The
    code
    User
    data is now passed as a parameter, making the composable pure and testable.
  3. Side‑effects in dedicated scopes – Network loading for the avatar lives in a
    code
    LaunchedEffect
    or a
    code
    rememberCoroutineScope
    .

[!TIP]
When you start, keep the original

code
View
layout open side‑by‑side. This visual mapping reduces cognitive load and helps you avoid missing critical attributes like
code
contentDescription
.

Refactoring Logic and State Management

The original

code
Activity
held a
code
UserViewModel
that exposed a
code
LiveData<User>
. The UI observed this via an anonymous
code
Observer
. In Compose, we replace
code
LiveData
with a
code
StateFlow
or
code
SharedFlow
to integrate seamlessly with the coroutine lifecycle.

Old ViewModel snippet:

kotlin
class UserViewModel : ViewModel() {
    private val repository = UserRepository()
    val user: LiveData<User> = repository.getCurrentUser()
}

New ViewModel (Kotlin):

kotlin
class UserProfileViewModel : ViewModel() {
    private val repository = UserRepository()
    private val _userState: MutableStateFlow<User?> = MutableStateFlow(null)
    val userState: StateFlow<User?> = _userState.asStateFlow()

    fun loadUser() {
        viewModelScope.launch {
            repository.getCurrentUserFlow().collect { user ->
                _userState.value = user
            }
        }
    }
}

The UI now collects the state using

code
collectAsState
:

kotlin
val uiState = viewModel.userState.collectAsState()
UserProfileScreen(
    user = uiState.value ?: return,
    onEditClick = { /* navigate to edit */ }
)

Navigation is also simplified. Instead of

code
findViewById<Button>(R.id.btnEdit).setOnClickListener
, we use an inline lambda passed from the host
code
NavHostComposable
:

kotlin
navigation(startDestination = "user_profile") {
    composable("user_profile") { backStackEntry ->
        val viewModel: UserProfileViewModel = viewModel()
        val user by viewModel.userState.collectAsState()

        user?.let {
            UserProfileScreen(
                user = it,
                onEditClick = {
                    navController.navigate("edit_profile/${it.id}")
                }
            )
        }
    }
}

[!WARNING]
Switching from

code
LiveData
to
code
StateFlow
means you must update any existing repository methods that still return
code
LiveData
. In my case, I refactored
code
UserRepository.getCurrentUser()
to expose a
code
flow
while keeping a wrapper for backward compatibility during the transition.

Testing and Debugging the Compose Migration

Testing Compose is a game‑changer. Instead of Espresso’s view matchers, we use

code
composeTestRule
and
code
assertThat
assertions on
code
Composer
semantics.

Example JUnit‑Android test (Kotlin):

kotlin
@RunWith(AndroidJUnit4::class)
class UserProfileScreenTest {

    @get:Rule
    val composeTestRule = ComposeTestRule()

    @Test
    fun userProfile_displaysCorrectInfo() {
        val testUser = User(
            id = 1,
            name = "Alice",
            email = "alice@example.com",
            bio = "Compose lover"
        )

        composeTestRule.setContent {
            UserProfileScreen(
                user = testUser,
                onEditClick = {}
            )
        }

        // Assert name text
        composeTestRule.onNodeWithText("Alice")
            .assertIsDisplayed()

        // Assert email text
        composeTestRule.onNodeWithText("alice@example.com")
            .assertIsDisplayed()

        // Assert bio text
        composeTestRule.onNodeWithText("Compose lover")
            .assertIsDisplayed()

        // Assert edit button
        composeTestRule.onNodeWithContentDescription("Edit Profile")
            .assertIsDisplayed()
    }
}

Debugging a Compose UI is also smoother. The Android Studio Layout Inspector now shows a live preview of the composable tree, and

code
Paint
mode highlights clipping and drawing operations.

[!IMPORTANT]
When you migrate, keep a copy of the original Espresso tests. Run them alongside the new Compose tests until you are confident the behavior is identical.

Key Takeaways

  • Plan before you code – Document the View hierarchy, state flow, and navigation to create a clear migration map.
  • Leverage Compose’s composability – Break the UI into small, pure
    code
    @Composable
    functions for easier testing and reuse.
  • Update state handling – Replace
    code
    LiveData
    /
    code
    Observer
    with
    code
    StateFlow
    /
    code
    collectAsState
    to integrate with coroutines and lifecycle awareness.
  • Keep tests running – Migrate UI tests gradually, using both Espresso and Compose Test rules to validate functionality throughout the transition.
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this