Bỏ qua để đến nội dung

Accessibility

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.

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"
}

// ❌ 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"
)
// Decorative icon (không cần đọc)
Icon(Icons.Default.Star, contentDescription = null)
// Meaningful icon
Icon(Icons.Default.Favorite, contentDescription = "Add to favorites")

@Composable
fun CustomAccessibleButton() {
Box(
modifier = Modifier
.size(100.dp)
.background(Color.Blue)
.clickable { }
.semantics {
role = Role.Button
contentDescription = "Confirm purchase"
}
) {
// Custom content
}
}
// 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"

@Composable
fun ExpandableCard(expanded: Boolean, onExpand: () -> Unit) {
Card(
modifier = Modifier
.clickable { onExpand() }
.semantics {
stateDescription = if (expanded) "Expanded" else "Collapsed"
}
) {
// Content
}
}
@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
)
}
}

@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
}
}

@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)
}
}

Đả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")
}
}

@Composable
fun FocusExample() {
val focusRequester = remember { FocusRequester() }
Column {
TextField(
value = "",
onValueChange = { },
modifier = Modifier.focusRequester(focusRequester)
)
Button(onClick = { focusRequester.requestFocus() }) {
Text("Focus TextField")
}
}
}
@Composable
fun CustomFocusOrder() {
val (first, second, third) = remember { FocusRequester.createRefs() }
Column {
TextField(
value = "",
onValueChange = { },
modifier = Modifier
.focusRequester(second)
.focusProperties {
next = third
previous = first
}
)
}
}

@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
}
)
}
}

@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()
}

Sử dụng Accessibility Scanner app để tìm vấn đề accessibility trong ứng dụng.


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
  • 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