Accessibility trong Jetpack Compose
Accessibility giúp ứng dụng của bạn có thể sử dụng được bởi người khuyết tật. Compose làm cho việc implement accessibility trở nên dễ dàng hơn.
1. Semantics là gì?
Compose sử dụng Semantics tree để cung cấp thông tin cho accessibility services (như TalkBack).
// Compose tự động tạo semantics cho nhiều composables
Text("Hello") // Text content tự động được đọc
Button(onClick = { }) {
Text("Submit") // Button với label "Submit"
}2. Content Descriptions
Cho Images
// ❌ SAI: Không có description
Image(
painter = painterResource(R.drawable.cat),
contentDescription = null // Accessibility bỏ qua image này
)
// ✅ ĐÚNG: Có description
Image(
painter = painterResource(R.drawable.cat),
contentDescription = "A cute cat sitting on a couch"
)Cho Icons
// Decorative icon (không cần đọc)
Icon(Icons.Default.Star, contentDescription = null)
// Meaningful icon
Icon(Icons.Default.Favorite, contentDescription = "Add to favorites")3. semantics modifier
Thêm hoặc sửa semantics
@Composable
fun CustomAccessibleButton() {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Blue)
.clickable { }
.semantics {
role = Role.Button
contentDescription = "Confirm purchase"
}
) {
// Custom content
}
}Merge semantics
// Mặc định: mỗi Text được đọc riêng
Row {
Icon(Icons.Default.Star, null)
Text("4.5")
Text("(123 reviews)")
}
// TalkBack đọc: "Star", "4.5", "(123 reviews)"
// Với mergeDescendants: đọc cùng lúc
Row(
modifier = Modifier.semantics(mergeDescendants = true) {
contentDescription = "Rating 4.5 stars from 123 reviews"
}
) {
Icon(Icons.Default.Star, null)
Text("4.5")
Text("(123 reviews)")
}
// TalkBack đọc: "Rating 4.5 stars from 123 reviews"4. State Descriptions
@Composable
fun ExpandableCard(expanded: Boolean, onExpand: () -> Unit) {
Card(
modifier = Modifier
.clickable { onExpand() }
.semantics {
stateDescription = if (expanded) "Expanded" else "Collapsed"
}
) {
// Content
}
}Toggle states
@Composable
fun FavoriteButton(isFavorite: Boolean, onToggle: () -> Unit) {
IconButton(
onClick = onToggle,
modifier = Modifier.semantics {
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites"
}
) {
Icon(
if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null // Description ở IconButton
)
}
}5. Headings và Structure
@Composable
fun AccessibleScreen() {
Column {
Text(
"Settings",
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.semantics { heading() }
)
Text(
"Account",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() }
)
// Settings items
Text(
"Notifications",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.semantics { heading() }
)
// Notification settings
}
}6. Custom Actions
@Composable
fun SwipeableItem(
item: Item,
onDelete: () -> Unit,
onArchive: () -> Unit
) {
Card(
modifier = Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction("Delete") {
onDelete()
true
},
CustomAccessibilityAction("Archive") {
onArchive()
true
}
)
}
) {
Text(item.title)
}
}7. Touch Target Size
Đảm bảo touch targets đủ lớn (ít nhất 48dp):
@Composable
fun SmallIcon() {
// ❌ Icon nhỏ, khó click
IconButton(onClick = { }) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
modifier = Modifier.size(20.dp)
)
}
// ✅ IconButton tự động có touch target 48dp
// hoặc dùng minimumTouchTargetSize
Box(
modifier = Modifier
.size(24.dp)
.minimumInteractiveComponentSize()
.clickable { }
) {
Icon(Icons.Default.Close, "Close")
}
}8. Focus và Keyboard Navigation
@Composable
fun FocusExample() {
val focusRequester = remember { FocusRequester() }
Column {
TextField(
value = "",
onValueChange = { },
modifier = Modifier.focusRequester(focusRequester)
)
Button(onClick = { focusRequester.requestFocus() }) {
Text("Focus TextField")
}
}
}Focus order
@Composable
fun CustomFocusOrder() {
val (first, second, third) = remember { FocusRequester.createRefs() }
Column {
TextField(
value = "",
onValueChange = { },
modifier = Modifier
.focusRequester(second)
.focusProperties {
next = third
previous = first
}
)
}
}9. Live Regions (Announcements)
@Composable
fun LiveAnnouncement(message: String?) {
message?.let {
Text(
text = it,
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite // Đợi, không ngắt
// hoặc LiveRegionMode.Assertive // Ngắt ngay
}
)
}
}10. Testing Accessibility
Kiểm tra với Compose Test
@Test
fun testAccessibility() {
composeTestRule.setContent {
MyScreen()
}
// Kiểm tra content description
composeTestRule
.onNodeWithContentDescription("Submit button")
.assertExists()
// Kiểm tra heading
composeTestRule
.onNode(hasTestTag("title") and isHeading())
.assertExists()
}Accessibility Scanner
Sử dụng Accessibility Scanner app để tìm vấn đề accessibility trong ứng dụng.
📝 Tóm tắt
| Semantics Property | Mục đích |
|---|---|
contentDescription | Mô tả cho screen readers |
stateDescription | Mô tả state (expanded, selected) |
role | Loại element (Button, Checkbox) |
heading() | Đánh dấu heading |
customActions | Actions bổ sung |
liveRegion | Announce thay đổi |
mergeDescendants | Gộp children thành 1 unit |
Checklist Accessibility
- Tất cả images có contentDescription phù hợp
- Interactive elements có touch target >= 48dp
- Headings được đánh dấu đúng
- Color contrast đủ cao
- State changes được announce
- Test với TalkBack
Last updated on