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.
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.
On this page
I still remember the first time I opened a 2012‑era Android app that still used plain XML layouts and verbose
findViewByIdsetContentView(R.layout.activity_main)ViewOnClickListenerEvery new feature meant digging through
ViewBefore any code rewrite, I documented the current architecture. The old screen lived in an
Activityactivity_user_profile.xmlLinearLayoutImageViewTextViewButtonThe business logic was tucked into the same
ActivityUserRepositoryLiveData<User>I created a simple table to capture the before/after characteristics.
| Aspect | View‑Based Implementation | Compose‑Based Implementation |
|---|---|---|
| UI Definition | XML resource ( code | Kotlin function ( code |
| View Binding | code | code code |
| State Observation | code code | code code code |
| Theming | Styles and themes defined in XML | Material 3 theme APIs + code |
| Testing | Espresso + ViewMatchers | Compose Test rules + code |
| Navigation | NavGraph with code | NavHostComposable with code |
[!NOTE]
Keeping a side‑by‑side comparison helps you identify which pieces of logic can be reused and which need a refactor.
The first step was to translate each XML component into its Compose counterpart.
XML snippet (original):
<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):
@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:
@ComposableUserLaunchedEffectrememberCoroutineScope[!TIP]
When you start, keep the originallayout open side‑by‑side. This visual mapping reduces cognitive load and helps you avoid missing critical attributes likecodeView.codecontentDescription
The original
ActivityUserViewModelLiveData<User>ObserverLiveDataStateFlowSharedFlowOld ViewModel snippet:
class UserViewModel : ViewModel() {
private val repository = UserRepository()
val user: LiveData<User> = repository.getCurrentUser()
}New ViewModel (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
collectAsStateval uiState = viewModel.userState.collectAsState()
UserProfileScreen(
user = uiState.value ?: return,
onEditClick = { /* navigate to edit */ }
)Navigation is also simplified. Instead of
findViewById<Button>(R.id.btnEdit).setOnClickListenerNavHostComposablenavigation(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 fromtocodeLiveDatameans you must update any existing repository methods that still returncodeStateFlow. In my case, I refactoredcodeLiveDatato expose acodeUserRepository.getCurrentUser()while keeping a wrapper for backward compatibility during the transition.codeflow
Testing Compose is a game‑changer. Instead of Espresso’s view matchers, we use
composeTestRuleassertThatComposerExample JUnit‑Android test (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
Paint[!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.
@ComposableLiveDataObserverStateFlowcollectAsStateSudarshan 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.
Related Posts
Related Apps
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 meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read