Compose Accessibility, Theming, Architecture and Testing

Compose Accessibility, Theming, Architecture and Testing 

6.9 Accessibility and Theming

1. Material Theme

MaterialTheme provides a common design system for the complete application.

It mainly contains:

* Color scheme

* Typography

* Shapes

flowchart TD
    MT["MaterialTheme"] --> C["Color Scheme"]
    MT --> T["Typography"]
    MT --> S["Shapes"]
    C --> UI["Material Components"]
    T --> UI
    S --> UI

Theme definition:

@Composable
fun ShopAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    MaterialTheme(
        colorScheme = if (darkTheme) {
            DarkColorScheme
        } else {
            LightColorScheme
        },
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}

Usage:

ShopAppTheme {
    ProductScreen()
}

Material components automatically read the nearest theme values.

---

2. Color Scheme

Material 3 uses semantic color roles instead of random hard-coded colors.

Text(
    text = "Welcome",
    color = MaterialTheme.colorScheme.onSurface
)

Button(
   
    colors = ButtonDefaults.buttonColors(
        containerColor = MaterialTheme.colorScheme.primary
    )
) {
    Text("Continue")
}

Important color pairs

BackgroundContent placed on it
primaryonPrimary
primaryContaineronPrimaryContainer
secondaryonSecondary
surfaceonSurface
erroronError

Use matching color roles to maintain readable contrast.

Avoid

Text(
    text = "Welcome",
    color = Color.Black
)

Black may work in light mode but become invisible on a dark background.

Prefer

color = MaterialTheme.colorScheme.onBackground

---

3. Typography

Material typography provides consistent text styles.

Text(
    text = "Payment Details",
    style = MaterialTheme.typography.headlineSmall
)

Text(
    text = "Enter the payment amount",
    style = MaterialTheme.typography.bodyMedium
)

Common categories:

CategoryUse
displayVery large important text
headlineScreen or section heading
titleCard or component title
bodyMain readable content
labelButton, chip or small label

Custom typography:

val AppTypography = Typography(
    titleLarge = TextStyle(
        fontSize = 22.sp,
        fontWeight = FontWeight.SemiBold
    ),
    bodyLarge = TextStyle(
        fontSize = 16.sp,
        lineHeight = 24.sp
    )
)

Use sp for text so it responds to the user’s font-size preference.

Avoid fixed-height containers that may clip text when the user increases font size.

---

4. Shapes

Shapes define the corner styles of Material components.

val AppShapes = Shapes(
    small = RoundedCornerShape(4.dp),
    medium = RoundedCornerShape(12.dp),
    large = RoundedCornerShape(24.dp)
)

Usage:

Card(
    shape = MaterialTheme.shapes.medium
) {
    ProductContent()
}

Shapes help maintain consistent:

* Cards

* Buttons

* Dialogs

* Bottom sheets

* Input fields

---

5. Dark Mode

Use isSystemInDarkTheme() to follow the system theme.

@Composable
fun ShopAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit
) {
    val colors =
        if (darkTheme) DarkColorScheme
        else LightColorScheme

    MaterialTheme(
        colorScheme = colors,
        content = content
    )
}
flowchart LR
    S["System theme"] --> C{"Dark mode?"}
    C -->|Yes| D["Dark color scheme"]
    C -->|No| L["Light color scheme"]

Dark mode is not created by simply making every color black. Define correct surface, container, content and status colors for both modes.

---

6. Dynamic Color

Dynamic color creates an application color scheme from the user’s wallpaper.

It is available on Android 12 and above.

@Composable
fun ShopAppTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val context = LocalContext.current

    val colors = when {
        dynamicColor &&
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
            darkTheme -> dynamicDarkColorScheme(context)

        dynamicColor &&
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ->
            dynamicLightColorScheme(context)

        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }

    MaterialTheme(
        colorScheme = colors,
        typography = AppTypography,
        shapes = AppShapes,
        content = content
    )
}
flowchart TD
    V{"Android 12+?"}
    V -->|Yes| D{"Dynamic color enabled?"}
    D -->|Yes| W["Use wallpaper colors"]
    D -->|No| A["Use app colors"]
    V -->|No| A

Always provide normal light and dark fallback schemes. Android: Material 3 in Compose

---

7. What is Accessibility?

Accessibility means making the application usable for people with different abilities and interaction methods.

Users may depend on:

* TalkBack

* Voice Access

* Switch Access

* Large text

* Keyboard navigation

* High contrast

* Alternative actions

Accessibility is not only for permanent disabilities. It also helps users with temporary injuries, poor lighting or one-handed use.

---

8. Semantics

Semantics describe the meaning and behaviour of a composable.

flowchart LR
    UI["Compose UI"] --> ST["Semantics Tree"]
    ST --> T["TalkBack"]
    ST --> VT["Voice and Switch Access"]
    ST --> TEST["Compose UI Tests"]

Example:

Icon(
    imageVector = Icons.Default.Delete,
    contentDescription = "Delete product"
)

TalkBack can announce:

Delete product, button

Decorative icons

If the surrounding text already explains the icon:

Icon(
    imageVector = Icons.Default.Add,
    contentDescription = null
)

Text("Add product")

null prevents TalkBack from repeating unnecessary information.

---

9. TalkBack-Friendly Components

Prefer Material components because they include useful accessibility behaviour.

Prefer

Button(
   
) {
    Text("Continue")
}

Avoid creating a button using only drawing and pointer input

Box(
    modifier = Modifier.pointerInput(Unit) {
        detectTapGestures {
            onContinue()
        }
    }
)

The custom Box does not automatically provide the same:

* Button role

* Click semantics

* Keyboard support

* Ripple feedback

* Focus behaviour

For custom UI, add appropriate interaction and semantics.

---

10. Accessible Custom Component

Example custom selectable card:

@Composable
fun SelectableProductCard(
    productName: String,
    selected: Boolean,
    onSelect: () -> Unit
) {
    val description =
        if (selected) "Selected" else "Not selected"

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .semantics {
                stateDescription = description
            }
            .selectable(
                selected = selected,
                role = Role.RadioButton,
               
            )
            .padding(16.dp)
    ) {
        Text(productName)
    }
}

This provides:

* Current selected state

* Component role

* Accessible click action

* One clear interaction target

---

11. Merging Semantics

A card may visually contain an image, name and price but should be announced as one item.

Row(
    modifier = Modifier
        .semantics(mergeDescendants = true) {}
        .clickable(onClick = onOpen)
        .padding(16.dp)
) {
    ProductImage()
    Text(product.name)
    Text(product.price)
}
flowchart LR
    I["Image"] --> M["Merged product card"]
    N["Product name"] --> M
    P["Price"] --> M
    M --> F["One accessibility focus"]

Do not merge children when they require separate actions.

Android: Accessibility in Compose

---

12. Touch Targets

Interactive controls should normally have a minimum touch area of approximately 48.dp × 48.dp.

IconButton(
   
    modifier = Modifier.size(48.dp)
) {
    Icon(
        imageVector = Icons.Default.Delete,
        contentDescription = "Delete"
    )
}

A visual icon can be smaller while its clickable container remains large:

Box(
    modifier = Modifier
        .minimumInteractiveComponentSize()
        .clickable(onClick = onDelete),
    contentAlignment = Alignment.Center
) {
    Icon(
        imageVector = Icons.Default.Delete,
        contentDescription = "Delete"
    )
}

Avoid placing multiple small click targets too close together.

---

13. Accessibility Checklist

* Use meaningful content descriptions.

* Use null descriptions for decorative images.

* Use matching background/content color roles.

* Do not communicate meaning using color alone.

* Support system font scaling.

* Avoid clipped or fixed-height text.

* Maintain adequate touch targets.

* Use headings for important sections.

* Add State descriptions to custom controls.

* Add accessible alternatives for custom gestures.

* Test using real TalkBack.

* Check focus order and announcements.

---

6.10 Compose Architecture and Testing

14. State Holders

A state holder owns UI State and provides operations that can change it.

State holders may exist at different levels:

State typeSuitable owner
Simple local UI StateComposable
Complex UI-element StatePlain state-holder class
Screen State and business interactionViewModel
flowchart TD
    SH["State Holders"] --> C["Composable state"]
    SH --> P["Plain state holder"]
    SH --> VM["ViewModel"]

Local State

var expanded by rememberSaveable {
    mutableStateOf(false)
}

Keep State local when only one small component needs it.

Plain state holder

class SearchBarState {
    var query by mutableStateOf("")
        private set

    fun updateQuery(value: String) {
        query = value
    }
}

Screen-level ViewModel

class ProductViewModel : ViewModel() {

    private val _uiState =
        MutableStateFlow(ProductUiState())

    val uiState = _uiState.asStateFlow()

    fun retry() {
        loadProducts()
    }
}

State should be kept close to where it is needed and hoisted to the lowest owner that needs to read and change it. Android: State hoisting

---

15. ViewModel Integration

Use a ViewModel in the route-level composable.

@Composable
fun ProductRoute(
    viewModel: ProductViewModel = viewModel(),
    onProductClick: (String) -> Unit
) {
    val state by viewModel.uiState
        .collectAsStateWithLifecycle()

    ProductScreen(
        state = state,
       
       
    )
}

Do not pass the ViewModel deeply into every child.

Prefer

ProductItem(
    product = product,
   
        onProductClick(product.id)
    }
)

This makes child composables:

* Reusable

* Previewable

* Easy to test

* Independent from ViewModel creation

---

16. Unidirectional Data Flow

UDF means:

* State flows down.

* Events flow up.

flowchart TD
    VM["State Holder"] -->|UI State| V["Composable"]
    V -->|User Event| VM

Example:

@Composable
fun LoginScreen(
    state: LoginUiState,
    onEmailChange: (String) -> Unit,
    onLogin: () -> Unit
) {
    TextField(
        value = state.email,
       
    )

    Button(
       
        enabled = state.canLogin
    ) {
        Text("Login")
    }
}

The View displays State and reports events. It does not directly modify external State.

---

17. Screen Separation

Separate ViewModel connection from visual UI.

flowchart TD
    R["ProductRoute"] --> VM["Get ViewModel"]
    R --> CS["Collect State"]
    R --> S["ProductScreen"]
    S --> C["Smaller UI components"]

Route composable

Responsible for:

* Getting ViewModel

* Collecting State

* Handling navigation

* Connecting callbacks

Screen composable

Responsible for:

* Displaying State

* Sending events

* Containing screen layout

@Composable
fun ProductScreen(
    state: ProductUiState,
    onRetry: () -> Unit,
    onProductClick: (String) -> Unit
) {
    when {
        state.isLoading -> LoadingContent()
        state.error != null -> ErrorContent(onRetry)
        else -> ProductList(
            products = state.products,
           
        )
    }
}

This screen can be tested without a real ViewModel.

---

18. Compose Previews

@Preview displays composables inside Android Studio without running the complete application.

@Preview(
    name = "Product Success",
    showBackground = true
)
@Composable
private fun ProductScreenPreview() {
    ShopAppTheme {
        ProductScreen(
            state = ProductUiState(
                products = fakeProducts
            ),
           
           
        )
    }
}

Preview multiple states

Create previews for:

* Loading

* Success

* Empty

* Error

* Light mode

* Dark mode

* Large font

* Different screen sizes

@Preview(name = "Light")
@Preview(
    name = "Dark",
    uiMode = Configuration.UI_MODE_NIGHT_YES
)
@Composable
private fun ProductPreview() {
    ShopAppTheme {
        ProductScreen(
            state = previewState,
           
           
        )
    }
}
For multiple data variations, use `@PreviewParameter`. [Android: Compose previews](https://developer.android.com/develop/ui/compose/tooling/previews)

Previews are design tools, not replacements for automated tests.

---

19. How Compose UI Testing Works

Compose tests interact with the semantics tree rather than directly reading pixels.

flowchart LR
    T["Test"] --> F["Find semantics node"]
    F --> A["Perform action"]
    A --> AS["Assert result"]

Typical test flow:

Set content → Find node → Perform action → Assert result

---

20. Compose Test Rules

### `createComposeRule()`

Use it when testing a composable in isolation.

@get:Rule
val composeTestRule = createComposeRule()

createAndroidComposeRule<Activity>()

Use it when access to an Activity or full Activity integration is required.

@get:Rule
val composeTestRule =
    createAndroidComposeRule<MainActivity>()
| Rule                         | Use                       |
| ---------------------------- | ------------------------- |
| `createComposeRule()`        | Component or screen test  |
| `createAndroidComposeRule()` | Activity integration test |

Android: Compose UI testing

---

21. Basic Compose Test

class LoginScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun loginButton_click_showsWelcome() {
        composeTestRule.setContent {
            ShopAppTheme {
                LoginScreen()
            }
        }

        composeTestRule
            .onNodeWithText("Login")
            .performClick()

        composeTestRule
            .onNodeWithText("Welcome")
            .assertIsDisplayed()
    }
}

---

22. Finding Nodes

Common node finders:

onNodeWithText("Login")

onNodeWithContentDescription("Delete")

onNodeWithTag("product_list")

onNode(hasClickAction())

onAllNodesWithText("Product")

Test tag

LazyColumn(
    modifier = Modifier.testTag("product_list")
) {
    // Items
}

Use visible text, roles and content descriptions when they represent real user behaviour. Use test tags when a stable semantic identifier is genuinely needed.

---

23. Actions and Assertions

Actions

performClick()
performTextInput("user@example.com")
performTextClearance()
performScrollTo()
performTouchInput {
    swipeUp()
}

Assertions

assertExists()
assertDoesNotExist()
assertIsDisplayed()
assertIsEnabled()
assertIsNotEnabled()
assertIsSelected()
assertTextEquals("Welcome")

Example:

composeTestRule
    .onNodeWithText("Email")
    .performTextInput("user@example.com")

composeTestRule
    .onNodeWithText("Login")
    .assertIsEnabled()
    .performClick()

---

24. Semantics Testing

Compose testing and accessibility use the same semantic information.

Custom component:

Modifier.semantics {
    stateDescription =
        if (selected) "Selected" else "Not selected"
}

Test:

composeTestRule
    .onNode(
        hasStateDescription("Selected")
    )
    .assertExists()

This is valuable because good semantics improve both accessibility and testability.

Android: Accessibility testing

---

25. Testing Different UI States

Screen separation allows direct State testing.

Loading test

composeTestRule.setContent {
    ProductScreen(
        state = ProductUiState(isLoading = true),
       
       
    )
}

composeTestRule
    .onNodeWithTag("loading")
    .assertIsDisplayed()

Error test

composeTestRule.setContent {
    ProductScreen(
        state = ProductUiState(
            error = "Network unavailable"
        ),
       
       
    )
}

composeTestRule
    .onNodeWithText("Network unavailable")
    .assertIsDisplayed()

Test at least:

stateDiagram-v2
    [*] --> Loading
    Loading --> Success
    Loading --> Empty
    Loading --> Error
    Error --> Loading: Retry

---

26. UI Test Synchronization

Compose tests automatically wait for Compose to become idle before most actions and assertions.

flowchart LR
    A["Test action"] --> W["Wait for Compose idle"]
    W --> P["Perform action"]
    P --> R["Recomposition"]
    R --> W2["Wait for idle"]
    W2 --> AS["Run assertion"]

Therefore, avoid:

Thread.sleep(2_000)

Wait for a condition

composeTestRule.waitUntil(
    timeoutMillis = 5_000
) {
    composeTestRule
        .onAllNodesWithText("Success")
        .fetchSemanticsNodes()
        .isNotEmpty()
}

Wait for Compose

composeTestRule.waitForIdle()

Control animation time

composeTestRule.mainClock.autoAdvance = false

composeTestRule.mainClock.advanceTimeBy(500)

External asynchronous work

Compose may not automatically know about every background SDK or asynchronous task. Use:

* Fake dependencies

* Controlled test data

* Idling resources when necessary

* Test dispatchers for coroutine tests

Android: Compose test synchronization

---

Common Mistakes

Accessibility and Theming

* Hard-coding black or white colors

* Using mismatched primary and content colors

* Communicating status only through color

* Clipping text when font size increases

* Giving decorative icons repeated descriptions

* Missing descriptions for meaningful icons

* Creating touch targets smaller than recommended

* Using raw gestures for normal buttons

* Forgetting semantics on custom controls

* Testing only light mode

Architecture and Testing

* Passing ViewModel to every child composable

* Keeping the same State in multiple owners

* Putting API calls directly inside composables

* Mixing navigation, State collection and UI rendering everywhere

* Making screens impossible to preview without ViewModel

* Testing only the success State

* Depending on test tags for every node

* Using Thread.sleep() for synchronization

* Using real API calls in component tests

* Ignoring accessibility semantics during testing

---

Interview Questions and Answers

Q1. What are the main parts of MaterialTheme?

MaterialTheme provides the application’s color scheme, typography and shapes.

Q2. How does dark mode work in Compose?

Use isSystemInDarkTheme() to select the appropriate light or dark ColorScheme. Components read colors from MaterialTheme.

Q3. What is dynamic color?

On Android 12 and above, dynamic color generates light and dark color schemes based on the user’s wallpaper.

Q4. What are semantics?

Semantics describe the meaning, State and actions of UI components for accessibility services and Compose tests.

Q5. How do you make a custom component accessible?

Provide an appropriate role, content or State description, click action, focus behaviour and sufficient touch target.

Q6. Why use Material components for accessibility?

Material components include useful default semantics, roles, interactions, focus and touch-target behaviour.

Q7. What is a state holder?

A state holder owns UI State and provides operations that can update it. It can be a composable, plain class or ViewModel.

Q8. What is UDF?

Unidirectional Data Flow means State flows down to the UI and user events flow up to the State holder.

Q9. Why separate Route and Screen composables?

The Route connects ViewModel, State and navigation. The Screen only renders State and emits events, making it reusable, previewable and testable.

Q10. What is the purpose of @Preview?

It displays a composable in Android Studio with sample State without launching the complete application.

Q11. How do Compose UI tests find components?

They search the semantics tree using text, content descriptions, roles, properties or test tags.

Q12. What is the difference between Compose test rules?

createComposeRule() tests composables in isolation. createAndroidComposeRule() is used when an Android Activity is required.

Q13. How are Compose tests synchronized?

Actions and assertions normally wait for Compose to become idle. Test clocks, waitForIdle(), waitUntil() and idling resources support special cases.

---

Final Interview Answer

> Material theming provides consistent colors, typography and shapes, with separate light and dark schemes and optional dynamic colors on Android 12 and above. Accessible Compose UI uses meaningful semantics, proper TalkBack descriptions, sufficient touch targets, scalable text and accessible alternatives for custom gestures. For architecture, State is owned by an appropriate state holder, screen-level State is usually exposed by a ViewModel, and UDF sends State down and events up. Separating Route and Screen composables makes UI easier to preview and test. Compose UI tests interact with the semantics tree using a Compose test rule, perform user actions, assert results and automatically synchronize with Compose recomposition.