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 --> UITheme 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
| Background | Content placed on it |
|---|---|
primary | onPrimary |
primaryContainer | onPrimaryContainer |
secondary | onSecondary |
surface | onSurface |
error | onError |
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:
| Category | Use |
|---|---|
display | Very large important text |
headline | Screen or section heading |
title | Card or component title |
body | Main readable content |
label | Button, 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| AAlways 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, buttonDecorative 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 type | Suitable owner |
|---|---|
| Simple local UI State | Composable |
| Complex UI-element State | Plain state-holder class |
| Screen State and business interaction | ViewModel |
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| VMExample:
@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 |---
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.