Post

How to provide View IDs in Jetpack Compose

Waterfall flowing into the Sea

Automation Frameworks like Appium Inspector or Maestro Studio need to find views ids in our mobile apps to be able to interact with its views and run the thousands of E2E tests we have in our projects.

But in the new world of Jetpack Compose, we don’t have XML files anymore, so we don’t have View IDs anymore, so how do we provide those IDs to the automation framework?

Definition of Done

Before we start, let’s define what we want to achieve:

  • We want to be able to provide View IDs to any of our Composables
  • We want the IDs to be 100% unique, meaning we don’t have to worry about the same id existing twice in a list, aka the most common issue with RecyclerView
  • We want to relate the ids to the “context” of the screen

I call this whole thing AutomationContext because we’re using it to provide the context (and ids) of a screen to an Automation framework.

Code Project

Let’s have a ContactsScreen that shows a list of contacts:

1
2
3
4
5
6
7
8
9
10
11
@Composable
fun ContactsScreen(contacts: List<Contact>) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
    ) {
        items(contacts) { contact ->
            ContactItem(contact)
        }
    }
}

And a ContactItem that shows the contact name and phone number:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Composable
fun ContactItem(contact: Contact) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(
                imageVector = Icons.Filled.Person,
                contentDescription = "Person Icon",
                tint = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier
                    .size(50.dp)
                    .clip(CircleShape),
            )
            Spacer(modifier = Modifier.width(16.dp))
            Column {
                Text(text = contact.name, fontWeight = FontWeight.Bold)
                Spacer(modifier = Modifier.height(4.dp))
                Text(text = contact.phoneNumber)
            }
        }
    }
}

Now let’s look at what Maestro Studio sees when we run the app and launch maestro studio:

1
2
3
4
5
6
7
8
9
10
11
12
13
 ~  maestro studio
Running on 19181FDF600E9W

╭────────────────────────────────────────────────────────╮
│                                                        │
│   Maestro Studio is running at http://localhost:9999   │
│                                                        │
╰────────────────────────────────────────────────────────╯


Tip: Maestro Studio can now run simultaneously alongside other Maestro CLI commands!

Navigate to http://localhost:9999 in your browser to open Maestro Studio. Ctrl-C to exit.

This opens automatically the browser on that url (http://localhost:9999) and we can see the following:

Maestro Studio

We see some view ids from the Android system like the clock or the battery, and we do see the text of our views, but we don’t see any view ids for our items, as expected.

First Approach: Composables

The basic idea is that we’ll use a CompositionLocal to provide a context (String) that will be chained to any previously provided context (String) in the composition tree, then use that to set resource ids via Modifier.testTag().

Step 1: Create the CompositionLocal

We’ll create a CompositionLocal that will hold the context (String):

1
private val LocalAutomationContext = compositionLocalOf { "" }

Step 2: Create the AutomationContext Composable

1
2
3
4
5
6
@Composable
fun AutomationContext(context: String, content: @Composable () -> Unit) {
    val prev = LocalAutomationContext.current
    val chained = if (prev.isEmpty()) context else "${prev}_${context}"
    CompositionLocalProvider(LocalAutomationContext provides chained, content = content)
}

And we’ll also create a helper function called AutomationIndex based on AutomationContext to provide the index of the item in the list:

1
2
3
4
@Composable
fun AutomationIndex(index: Int, content: @Composable () -> Unit) {
    AutomationContext("index_$index", content)
}

Step 3: Create the AutomationId Composable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AutomationId(id: String, content: @Composable () -> Unit) {
    val packagePrefix = LocalContext.current.packageName
    val prev = LocalAutomationContext.current
    val newContext = if (prev.isEmpty()) id else "${prev}_${id}"
    val newId = "$packagePrefix:id/$newContext"
    CompositionLocalProvider(LocalAutomationContext provides newContext) {
        Box(
            modifier = Modifier
                .semantics { testTagsAsResourceId = true }
                .testTag(newId)
        ) {
            content()
        }
    }
}

Step 4: Use those new Composables in our ContactsScreen

We’ll wrap the content of the screen with AutomationContext, use AutomationIndex to provide the index of the item in the list, and use AutomationId to provide the id of the card, icon, name and phone number:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Composable
fun ContactsScreen(contacts: List<Contact>) {
    AutomationContext("contacts") { // Set the context for the screen
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
        ) {
            itemsIndexed(contacts) { index, contact ->
                AutomationIndex(index) { // Set the index for each item
                    ContactItem(contact)
                }
            }
        }
    }
}

@Composable
fun ContactItem(contact: Contact) {
    AutomationId("card") { // Set the ID for the card as a container
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(8.dp),
        ) {
            Row(
                modifier = Modifier.padding(16.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                AutomationId("icon") { // Set the ID for the icon
                    Icon(
                        imageVector = Icons.Filled.Person,
                        contentDescription = "Person Icon",
                        tint = MaterialTheme.colorScheme.onSurfaceVariant,
                        modifier = Modifier
                            .size(50.dp)
                            .clip(CircleShape),
                    )
                }
                Spacer(modifier = Modifier.width(16.dp))
                Column {
                    AutomationId("name") { // Set the ID for the name
                        Text(text = contact.name, fontWeight = FontWeight.Bold)
                    }
                    Spacer(modifier = Modifier.height(4.dp))
                    AutomationId("phone") { // Set the ID for the phone number
                        Text(text = contact.phoneNumber)
                    }
                }
            }
        }
    }
}

First approach: Result

Now we can see that Maestro Studio is finally able to see the ids of our views:

Maestro Studio

This first approach has a big issue which is that we have to wrap every single Composable with AutomationContext, AutomationInex and AutomationId, which is far from ideal, to say the least! 🤮

Second Approach: Modifiers

We can write this differently using ModifierLocal which allows us to pass information through the Modifier tree 🤩

To use ModifierLocal, we need to:

  • Consume the previously provided context via Modifier.modifierLocalConsumer {}
  • Provide the new context via Modifier.modifierLocalProvider() {}

Step 1: Create the ModifierLocal

We’ll create a ModifierLocal that will hold the context (String):

1
private val ModifierLocalAutomationContext = modifierLocalOf { "" }

Step 2: Create the AutomationContext and AutomationIndex Modifiers

1
2
3
4
5
6
7
8
9
10
11
12
13
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun Modifier.automationContext(context: String): Modifier {
    var prev by remember { mutableStateOf<String?>(null) }
    return this then Modifier
        .modifierLocalConsumer { prev = ModifierLocalAutomationContext.current }
        .modifierLocalProvider(ModifierLocalAutomationContext) {
            if (prev.isNullOrEmpty()) context else "${prev}_${context}"
        }
}

@Composable
fun Modifier.automationIndex(index: Int): Modifier = automationContext("index_$index")

Step 3: Create the AutomationId Modifier

1
2
3
4
5
6
7
8
9
@Composable
fun Modifier.automationId(id: String): Modifier {
    val packagePrefix = LocalContext.current.packageName
    var prev by remember { mutableStateOf<String?>(null) }
    return this then Modifier
        .modifierLocalConsumer { prev = ModifierLocalAutomationContext.current }
        .semantics { testTagsAsResourceId = true }
        .testTag(if (prev.isNullOrEmpty()) "$packagePrefix:id/$id" else "$packagePrefix:id/${prev}_${id}")
}

Step 4: Use those new Modifiers in our ContactsScreen

Now that we don’t need to wrap anything, we’ll simply use the new modifiers in our Composables:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
@Composable
fun ContactsScreen(contacts: List<Contact>) {
    LazyColumn(
        modifier = Modifier
            .fillMaxSize()
            .automationContext("contacts") // Set the context for the screen
    ) {
        itemsIndexed(contacts) { index, contact ->
            ContactItem(
                contact = contact,
                modifier = Modifier
                    .automationIndex(index) // Set the index for the item
            )
        }
    }
}

@Composable
fun ContactItem(
    contact: Contact,
    modifier: Modifier = Modifier,
) {
    Card(
        modifier = modifier
            .fillMaxWidth()
            .padding(8.dp)
            .automationId("card"), // Set the ID for the card
    ) {
        Row(
            modifier = Modifier.padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Icon(
                imageVector = Icons.Filled.Person,
                contentDescription = "Person Icon",
                tint = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier
                    .size(50.dp)
                    .clip(CircleShape)
                    .automationId("icon") // Set the ID for the icon,
            )

            Spacer(modifier = Modifier.width(16.dp))
            Column {
                Text(
                    text = contact.name,
                    fontWeight = FontWeight.Bold,
                    modifier = Modifier
                        .automationId("name"), // Set the ID for the name
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = contact.phoneNumber,
                    modifier = Modifier
                        .automationId("phone"), // Set the ID for the phone number
                )
            }
        }
    }
}

Second approach: Result

Exactly the same as the first approach in terms of view ids seen by Automation Frameworks, let’s filter this time and look at the ids only on index 4:

Maestro Studio

Conclusion

The second approach, build on Modifiers, is much cleaner and easier to use, and we got exactly what we wanted: unique view ids, per row, that are related to the context of the screen! ❤️

I’ve prepared you a Gist with all the relevant code so you can copy/paste it in your project and start using it right away! 💪

Let me know what you think or if you have any questions in the comments! 📝

Happy composing! 🧪

This post is licensed under CC BY 4.0 by the author.