Innehållsförteckning
Lär dig implementera tillgänglighet i Android-appar med Accessibility API och Jetpack Compose. Denna guide ger dig konkreta kodexempel, tekniska patterns och best practices för att bygga appar som fungerar perfekt med TalkBack och andra hjälpmedel.
Grundläggande Accessibility API
Android Accessibility API är det centrala ramverket för att göra appar tillgängliga. Varje View har tillgänglighetsegenskaper som du kan konfigurera för att beskriva elementet för hjälpmedel som TalkBack.
ContentDescription – Beskriv vad elementet är
ContentDescription är den viktigaste egenskapen. Den beskriver vad ett element är eller gör för TalkBack-användare. Varje interaktiv view och varje view som förmedlar information måste ha en contentDescription.
// Kotlin - Sätt i kod
button.contentDescription = "Spara dokument"
imageView.contentDescription = "Produktbild: Blå t-shirt med vit logotype"
// Kotlin - Dynamisk baserat på state
likeButton.contentDescription = if (isLiked) {
"Ta bort från favoriter"
} else {
"Lägg till i favoriter"
}
// XML
<Button
android:id="@+id/save_button"
android:contentDescription="Spara dokument"
android:text="Spara" />
<ImageView
android:id="@+id/product_image"
android:contentDescription="Produktbild: Blå t-shirt"
android:src="@drawable/product" />
Skriv contentDescriptions som substantiv eller korta fraser, inte fullständiga meningar. Undvik att inkludera elementtypen eftersom TalkBack redan meddelar ”Knapp” eller ”Bild”. Säg ”Spara dokument” inte ”Knapp för att spara dokument”.
För ikoner utan text måste du alltid sätta contentDescription. En dela-ikon ska ha ”Dela artikel” inte ”Dela-ikon” – beskriv vad som händer, inte hur ikonen ser ut.
ImportantForAccessibility – Kontrollera vad som exponeras
Inte alla views ska vara tillgängliga för TalkBack. Dekorativa element, bakgrunder och container-views utan egen semantisk betydelse ska döljas.
// Kotlin
decorativeImageView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
containerLayout.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
// XML
<ImageView
android:importantForAccessibility="no"
android:src="@drawable/decorative_background" />
<LinearLayout
android:importantForAccessibility="noHideDescendants">
<!-- Barn kommer inte vara tillgängliga -->
</LinearLayout>
IMPORTANT_FOR_ACCESSIBILITY_NO döljer viewen men exponerar fortfarande dess barn. NO_HIDE_DESCENDANTS döljer både viewen och alla dess barn. Använd det senare när en hel container är rent dekorativ eller när du vill gruppera innehåll på annat sätt.
Accessibility Labels och Hints
Utöver contentDescription finns accessibilityPaneTitle för att namnge större sektioner och tooltipText för extra hjälpinformation.
// Accessibility pane title för större sektioner
fragment.view?.accessibilityPaneTitle = "Produktdetaljer"
// Tooltip text för extra kontext
infoButton.tooltipText = "Visar information om produkten"
// I XML
<Button
android:tooltipText="Klicka för att se fullständig produktbeskrivning"
android:text="Läs mer" />
AccessibilityPaneTitle används när en stor del av skärmen ändras, som vid fragment-transitioner. TalkBack meddelar titeln så användare förstår att de kommit till ett nytt område.
Custom Views och tillgänglighet
När du bygger custom views är det ditt ansvar att implementera all tillgänglighetsfunktionalitet. Standard Material Components ger dig mycket gratis, men custom views kräver explicit arbete.
Grundläggande tillgänglig custom view
En basic custom view måste sätta contentDescription, importantForAccessibility och hantera clicks korrekt.
class CustomButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
init {
// Gör viewen tillgänglig för TalkBack
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
contentDescription = "Custom knapp"
// Gör viewen klickbar
isClickable = true
isFocusable = true
// Sätt click listener
setOnClickListener {
performAction()
}
}
private fun performAction() {
// Knappens logik
announceForAccessibility("Action utförd")
}
// Minimum touch target size
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val minSize = (48 * resources.displayMetrics.density).toInt()
val width = maxOf(suggestedMinimumWidth, minSize)
val height = maxOf(suggestedMinimumHeight, minSize)
setMeasuredDimension(
resolveSize(width, widthMeasureSpec),
resolveSize(height, heightMeasureSpec)
)
}
}
Alla custom views som användare kan interagera med måste vara clickable och focusable. Detta låter TalkBack fokusera på dem och användare aktivera dem.
AccessibilityDelegate för avancerad kontroll
För mer komplex tillgänglighetslogik använd AccessibilityDelegate. Detta låter dig anpassa hur viewen presenteras för TalkBack utan att överskriva hela view-klassen.
class MessageViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun bind(message: Message) {
// Sätt innehåll
titleText.text = message.title
bodyText.text = message.body
// Konfigurera tillgänglighet
itemView.accessibilityDelegate = object : View.AccessibilityDelegate() {
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfo
) {
super.onInitializeAccessibilityNodeInfo(host, info)
// Kombinera all info till en beskrivning
val description = "${message.title}. ${message.body}. " +
"Från ${message.sender}, ${message.timestamp}"
info.contentDescription = description
// Lägg till custom actions
val replyAction = AccessibilityNodeInfo.AccessibilityAction(
R.id.action_reply,
"Svara"
)
info.addAction(replyAction)
val deleteAction = AccessibilityNodeInfo.AccessibilityAction(
R.id.action_delete,
"Radera"
)
info.addAction(deleteAction)
}
override fun performAccessibilityAction(
host: View,
action: Int,
args: Bundle?
): Boolean {
return when (action) {
R.id.action_reply -> {
onReply(message)
true
}
R.id.action_delete -> {
onDelete(message)
true
}
else -> super.performAccessibilityAction(host, action, args)
}
}
}
}
}
AccessibilityDelegate är perfekt för RecyclerView items där du vill gruppera information och lägga till actions utan att behöva många separata knappar i layouten.
Skicka tillgänglighetshändelser
När innehåll ändras dynamiskt måste du informera TalkBack så användare blir medvetna om förändringen.
// Enkel announcement
view.announceForAccessibility("Dokument sparat")
// När innehåll läggs till
listView.post {
listView.sendAccessibilityEvent(
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
)
}
// När fokus ska flyttas till nytt element
newView.sendAccessibilityEvent(
AccessibilityEvent.TYPE_VIEW_FOCUSED
)
// För major screen changes
val event = AccessibilityEvent.obtain(
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED
)
event.text.add("Ny skärm laddad")
view.sendAccessibilityEventUnchecked(event)
announceForAccessibility() är enklast för korta meddelanden. För mer komplex hantering, skapa AccessibilityEvent manuellt och konfigurera det efter behov.
Jetpack Compose tillgänglighet
Jetpack Compose har inbyggt tillgänglighetsstöd genom semantics-systemet. Det är mer deklarativt än View-baserad kod och ofta enklare att arbeta med.
Grundläggande semantics
Compose-komponenter som Button, Text och Image har automatisk semantics, men du behöver ofta anpassa dem.
// Basic contentDescription
Button(
onClick = { saveDocument() },
modifier = Modifier.semantics {
contentDescription = "Spara dokument"
}
) {
Icon(Icons.Default.Save, contentDescription = null)
Text("Spara")
}
// När button har både ikon och text, sätt ikon contentDescription till null
// eftersom texten räcker som beskrivning
// För ikoner utan text
IconButton(
onClick = { shareArticle() },
modifier = Modifier.semantics {
contentDescription = "Dela artikel"
}
) {
Icon(Icons.Default.Share, contentDescription = null)
}
// Bilder med information
Image(
painter = painterResource(R.drawable.product),
contentDescription = "Produktbild: Blå t-shirt med vit logotype",
modifier = Modifier.size(200.dp)
)
// Dekorativa bilder
Image(
painter = painterResource(R.drawable.background),
contentDescription = null, // Null för dekorativa element
modifier = Modifier.fillMaxSize()
)
I Compose är contentDescription = null det korrekta sättet att dölja dekorativa element från TalkBack, till skillnad från View-systemets importantForAccessibility.
Merge descendants för gruppering
När flera composables bör läsas som en enhet, använd semantics(mergeDescendants = true).
// Utan merge - TalkBack läser varje text separat
Row {
Text("Temperatur:")
Text("22°C", style = MaterialTheme.typography.headlineMedium)
}
// TalkBack: "Temperatur:" [svep] "22°C"
// Med merge - TalkBack läser allt tillsammans
Row(
modifier = Modifier.semantics(mergeDescendants = true) {}
) {
Text("Temperatur:")
Text("22°C", style = MaterialTheme.typography.headlineMedium)
}
// TalkBack: "Temperatur: 22°C"
// Komplex gruppering med egen beskrivning
Card(
modifier = Modifier
.clickable { onProductClick() }
.semantics(mergeDescendants = true) {
contentDescription = "Produkt: Blå t-shirt, 299 kronor"
}
) {
Column {
Image(painterResource(R.drawable.product), contentDescription = null)
Text("Blå t-shirt")
Text("299 kr")
}
}
MergeDescendants är kraftfullt för kort, effektiv TalkBack-navigation. Användare behöver inte svepa genom varje litet textelement i ett produktkort.
Custom accessibility actions
Custom actions i Compose är mycket enklare än i View-systemet.
@Composable
fun MessageItem(message: Message, onReply: () -> Unit, onDelete: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.semantics {
// Main content description
contentDescription = "${message.title}, från ${message.sender}"
// Custom actions tillgängliga via TalkBack-menyn
customActions = listOf(
CustomAccessibilityAction("Svara") {
onReply()
true
},
CustomAccessibilityAction("Vidarebefordra") {
// Forward logic
true
},
CustomAccessibilityAction("Radera") {
onDelete()
true
}
)
}
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(message.title, style = MaterialTheme.typography.titleMedium)
Text(message.body, style = MaterialTheme.typography.bodyMedium)
}
}
}
Custom actions håller UI rent samtidigt som de ger TalkBack-användare kraftfull funktionalitet genom TalkBack-menyn.
State descriptions och live regions
För element vars värde eller tillstånd ändras, kommunicera det tydligt till TalkBack.
// Switch med state description
var isEnabled by remember { mutableStateOf(false) }
Switch(
checked = isEnabled,
onCheckedChange = { isEnabled = it },
modifier = Modifier.semantics {
stateDescription = if (isEnabled) "På" else "Av"
}
)
// Slider med värde
var volume by remember { mutableStateOf(50f) }
Slider(
value = volume,
onValueChange = { volume = it },
valueRange = 0f..100f,
modifier = Modifier.semantics {
contentDescription = "Volym"
stateDescription = "${volume.toInt()} procent"
}
)
// Live region för uppdaterande innehåll
var status by remember { mutableStateOf("Laddar...") }
Text(
text = status,
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
}
)
LaunchedEffect(Unit) {
delay(2000)
status = "Inläsning klar"
// TalkBack meddelar automatiskt ändringen
}
LiveRegionMode kan vara Polite (väntar på att TalkBack är ledig) eller Assertive (avbryter pågående tal). Använd Polite för de flesta uppdateringar.
Heading och strukturell navigation
Markera rubriker så TalkBack-användare kan hoppa mellan sektioner.
@Composable
fun ArticleScreen(article: Article) {
Column {
// Huvudrubrik
Text(
text = article.title,
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.semantics {
heading()
}
)
// Underrubrik
Text(
text = "Bakgrund",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.semantics {
heading()
}
)
Text(article.background)
// Nästa sektion
Text(
text = "Analys",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.semantics {
heading()
}
)
Text(article.analysis)
}
}
Headings gör navigation mycket snabbare i långa dokument. TalkBack-användare kan svepa mellan rubriker istället för att gå genom varje paragraph.
Formulär och input-hantering
Tillgängliga formulär är kritiska för e-handel, bank och andra LPTT-omfattade tjänster. Android kräver tydliga labels, felhantering och validering.
Labels för textfält
TextField i Compose har inbyggt label-stöd som automatiskt blir tillgängligt för TalkBack.
// Material TextField med label
var email by remember { mutableStateOf("") }
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-postadress") },
placeholder = { Text("[email protected]") },
modifier = Modifier.fillMaxWidth()
)
// Label är permanent synlig och tillgänglig för TalkBack
// Placeholder försvinner när användaren skriver
// Textfält med extra semantics
var password by remember { mutableStateOf("") }
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Lösenord") },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.semantics {
contentDescription = "Lösenord, obligatoriskt fält"
password()
}
)
password() semantics-modifier berättar för TalkBack att detta är ett lösenordsfält, vilket kan påverka hur det meddelas till användaren.
Felhantering och validation
Felmeddelanden måste vara tydliga, specifika och tillgängliga för TalkBack.
@Composable
fun EmailField() {
var email by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Column {
OutlinedTextField(
value = email,
onValueChange = {
email = it
error = validateEmail(it)
},
label = { Text("E-postadress") },
isError = error != null,
modifier = Modifier
.fillMaxWidth()
.semantics {
if (error != null) {
error("Fel: $error")
}
}
)
// Visuellt felmeddelande
if (error != null) {
Text(
text = error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(start = 16.dp, top = 4.dp)
.semantics {
// TalkBack läser detta automatiskt efter textfältet
liveRegion = LiveRegionMode.Polite
}
)
}
}
}
fun validateEmail(email: String): String? {
return when {
email.isEmpty() -> null
!email.contains("@") -> "E-postadressen måste innehålla @"
!email.contains(".") -> "E-postadressen måste innehålla en domän"
else -> null
}
}
Felmeddelanden ska vara specifika och beskriva hur användaren fixar problemet. ”Ogiltigt format” är för vagt – ”E-postadressen måste innehålla @” är mycket bättre.
Obligatoriska fält
Kommunicera tydligt vilka fält som är obligatoriska, både visuellt och för TalkBack.
@Composable
fun RequiredTextField(
value: String,
onValueChange: (String) -> Unit,
label: String
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = {
Row {
Text(label)
Text(" *", color = MaterialTheme.colorScheme.error)
}
},
modifier = Modifier
.fillMaxWidth()
.semantics {
contentDescription = "$label, obligatoriskt fält"
}
)
}
// Användning
var name by remember { mutableStateOf("") }
RequiredTextField(
value = name,
onValueChange = { name = it },
label = "Namn"
)
Asterisk (*) är en vanlig visuell indikator för obligatoriska fält, men TalkBack-användare behöver också höra att fältet är obligatoriskt.
Navigation och fokushantering
Korrekt fokushantering är kritisk för god TalkBack-upplevelse vid navigation och modala dialoger.
Fokus vid navigation i Compose
När användaren navigerar till ny skärm ska fokus flyttas till relevant element.
@Composable
fun ArticleDetailScreen(article: Article) {
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Text(
text = article.title,
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier
.focusRequester(focusRequester)
.semantics { heading() }
)
Text(article.content)
}
// Flytta fokus när skärmen visas
LaunchedEffect(Unit) {
delay(100) // Ge tid för layout
focusRequester.requestFocus()
}
}
FocusRequester låter dig programmatiskt styra fokus. Detta är viktigt vid navigation så TalkBack-användare hamnar på rätt ställe direkt.
Modala dialoger och fokus
När modal visas måste fokus fångas i modalen och returnera till rätt plats när den stängs.
@Composable
fun ConfirmDialog(
onDismiss: () -> Unit,
onConfirm: () -> Unit
) {
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(
"Bekräfta radering",
modifier = Modifier
.focusRequester(focusRequester)
.semantics { heading() }
)
},
text = { Text("Är du säker på att du vill radera detta?") },
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Radera")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Avbryt")
}
},
modifier = Modifier.semantics {
// Berätta för TalkBack att detta är en dialog
paneTitle = "Bekräftelsedialog"
}
)
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
}
AlertDialog har inbyggt fokus-fångande, men custom modaler kan behöva explicit hantering.
Fokusordning i komplexa layouter
För komplex layout där automatisk fokusordning inte är logisk, använd focusOrder eller focusProperties.
@Composable
fun ComplexForm() {
val (name, email, phone, submit) = remember { FocusRequester.createRefs() }
Column {
OutlinedTextField(
value = nameValue,
onValueChange = { nameValue = it },
label = { Text("Namn") },
modifier = Modifier
.focusRequester(name)
.focusProperties {
next = email
}
)
OutlinedTextField(
value = emailValue,
onValueChange = { emailValue = it },
label = { Text("E-post") },
modifier = Modifier
.focusRequester(email)
.focusProperties {
previous = name
next = phone
}
)
OutlinedTextField(
value = phoneValue,
onValueChange = { phoneValue = it },
label = { Text("Telefon") },
modifier = Modifier
.focusRequester(phone)
.focusProperties {
previous = email
next = submit
}
)
Button(
onClick = { submitForm() },
modifier = Modifier
.focusRequester(submit)
.focusProperties {
previous = phone
}
) {
Text("Skicka")
}
}
}
FocusProperties låter dig explicit definiera navigationsordning, vilket är användbart när standard tab-ordning inte matchar logisk eller visuell ordning.
Listor och RecyclerView
Listor är en vanlig källa till tillgänglighetsproblem. Korrekt implementation kräver uppmärksamhet på både individuella items och listans helhet.
RecyclerView med tillgänglighet
Varje RecyclerView item måste ha meningsfull contentDescription och hantera fokus korrekt.
class ArticleAdapter : RecyclerView.Adapter<ArticleViewHolder>() {
override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
val article = articles[position]
holder.bind(article)
}
}
class ArticleViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val titleText: TextView = itemView.findViewById(R.id.title)
private val authorText: TextView = itemView.findViewById(R.id.author)
private val dateText: TextView = itemView.findViewById(R.id.date)
fun bind(article: Article) {
titleText.text = article.title
authorText.text = article.author
dateText.text = article.formattedDate
// Gör hela item-viewen tillgänglig som en enhet
itemView.apply {
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
// Kombinera all info till en beskrivning
contentDescription = buildString {
append(article.title)
append(", av ")
append(article.author)
append(", publicerad ")
append(article.formattedDate)
}
// Lägg till actions via delegate
accessibilityDelegate = object : View.AccessibilityDelegate() {
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfo
) {
super.onInitializeAccessibilityNodeInfo(host, info)
val shareAction = AccessibilityNodeInfo.AccessibilityAction(
R.id.action_share,
"Dela"
)
info.addAction(shareAction)
}
override fun performAccessibilityAction(
host: View,
action: Int,
args: Bundle?
): Boolean {
return when (action) {
R.id.action_share -> {
shareArticle(article)
true
}
else -> super.performAccessibilityAction(host, action, args)
}
}
}
}
// Dölj individuella textviews från TalkBack
titleText.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
authorText.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
dateText.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}
}
Genom att göra hela item-viewen till ett accessibility element istället för att exponera varje textview separat blir TalkBack-navigationen mycket snabbare.
LazyColumn i Compose
LazyColumn har automatisk tillgänglighetsstöd men items måste ha rätt semantics.
@Composable
fun ArticleList(articles: List<Article>) {
LazyColumn {
items(articles) { article ->
ArticleItem(
article = article,
onClick = { /* navigate */ },
onShare = { /* share */ }
)
}
}
}
@Composable
fun ArticleItem(
article: Article,
onClick: () -> Unit,
onShare: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable(onClick = onClick)
.semantics(mergeDescendants = true) {
contentDescription = "${article.title}, " +
"av ${article.author}, " +
"publicerad ${article.formattedDate}"
customActions = listOf(
CustomAccessibilityAction("Dela") {
onShare()
true
}
)
}
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = article.title,
style = MaterialTheme.typography.titleMedium
)
Text(
text = article.author,
style = MaterialTheme.typography.bodySmall
)
Text(
text = article.formattedDate,
style = MaterialTheme.typography.bodySmall
)
}
}
}
MergeDescendants gör att TalkBack läser all information i ett svep istället för att kräva flera svep genom textelementen.
Headers i listor
Section headers hjälper TalkBack-användare navigera långa listor.
@Composable
fun GroupedArticleList(groupedArticles: Map<String, List<Article>>) {
LazyColumn {
groupedArticles.forEach { (category, articles) ->
// Section header
item {
Text(
text = category,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.semantics {
heading()
}
)
}
// Items i denna sektion
items(articles) { article ->
ArticleItem(article = article)
}
}
}
}
Med heading-semantics kan TalkBack-användare hoppa mellan kategorier snabbt utan att gå genom alla artiklar.
Prestanda och tillgänglighet
Tillgänglighetsfunktioner kan påverka prestanda om de inte implementeras effektivt, särskilt i listor med många element eller vid frekvent uppdaterande innehåll.
Lazy loading av tillgänglighetsinformation
För stora datasets, beräkna tillgänglighetsinformation endast när element blir synliga.
class ArticleAdapter : RecyclerView.Adapter<ArticleViewHolder>() {
override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) {
val article = articles[position]
// Sätt endast grundläggande info först
holder.titleText.text = article.title
// Beräkna tillgänglighetsbeskrivning lazy
holder.itemView.contentDescription = buildAccessibilityDescription(article)
}
private fun buildAccessibilityDescription(article: Article): String {
// Denna kallas endast för synliga items
return buildString {
append(article.title)
if (article.isNew) append(", Ny artikel")
append(", av ${article.author}")
append(", ${article.formattedDate}")
if (article.hasComments) {
append(", ${article.commentCount} kommentarer")
}
}
}
}
Detta är särskilt viktigt för listor med tusentals element där att förbereda all tillgänglighetsinformation i förväg skulle vara resurskrävande.
Undvik onödiga tillgänglighetshändelser
Skicka endast tillgänglighetshändelser när de verkligen behövs. För innehåll som uppdateras mycket frekvent kan konstanta announcements bli irriterande.
// Dåligt - Announcement för varje uppdatering
timer = Timer().apply {
scheduleAtFixedRate(0, 1000) {
runOnUiThread {
counterText.text = counter.toString()
counterText.announceForAccessibility(counter.toString())
}
}
}
// Bättre - Använd accessibilityLiveRegion
counterText.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
timer = Timer().apply {
scheduleAtFixedRate(0, 1000) {
runOnUiThread {
counterText.text = counter.toString()
// TalkBack hanterar uppdateringar automatiskt
}
}
}
// Compose-version
var counter by remember { mutableStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(1000)
counter++
}
}
Text(
text = counter.toString(),
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
}
)
Live regions är ofta bättre än manuella announcements för uppdaterande innehåll eftersom TalkBack kan optimera hur ofta meddelanden faktiskt läses upp.
Caching av tillgänglighetsberäkningar
Om tillgänglighetsbeskrivningar är komplexa att beräkna, cacha resultaten.
class ProductViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private var cachedDescription: String? = null
private var lastProduct: Product? = null
fun bind(product: Product) {
// Uppdatera UI
titleText.text = product.title
priceText.text = product.formattedPrice
// Använd cachad beskrivning om produkten inte ändrats
if (product != lastProduct) {
cachedDescription = buildComplexDescription(product)
lastProduct = product
}
itemView.contentDescription = cachedDescription
}
private fun buildComplexDescription(product: Product): String {
// Komplex beräkning med flera steg
return buildString {
append(product.title)
append(", ${product.formattedPrice}")
if (product.discount > 0) {
append(", ${product.discount}% rabatt")
}
append(", ${product.rating} av 5 stjärnor")
append(", ${product.reviewCount} recensioner")
if (product.inStock) {
append(", I lager")
} else {
append(", Slut i lager")
}
}
}
}
Caching är särskilt värdefullt när samma data visas flera gånger eller när användare scrollar fram och tillbaka i listor.
Testning och debugging
Systematisk testning är avgörande för att säkerställa att tillgänglighetsimplementationen faktiskt fungerar.
Unit tester för tillgänglighet
Skriv unit tester som verifierar tillgänglighetsegenskaper på komponenter.
@Test
fun testButtonAccessibility() {
val button = Button(context).apply {
text = "Spara"
contentDescription = "Spara dokument"
}
// Verifiera att elementet är tillgängligt
assertTrue(button.importantForAccessibility == View.IMPORTANT_FOR_ACCESSIBILITY_YES)
// Verifiera contentDescription
assertEquals("Spara dokument", button.contentDescription)
// Verifiera att det är klickbart
assertTrue(button.isClickable)
assertTrue(button.isFocusable)
// Verifiera minimum touch target
val minSize = (48 * context.resources.displayMetrics.density).toInt()
button.measure(
View.MeasureSpec.UNSPECIFIED,
View.MeasureSpec.UNSPECIFIED
)
assertTrue(button.measuredWidth >= minSize)
assertTrue(button.measuredHeight >= minSize)
}
@Test
fun testCustomViewAccessibilityActions() {
val view = CustomMessageView(context)
val nodeInfo = AccessibilityNodeInfo.obtain()
view.onInitializeAccessibilityNodeInfo(nodeInfo)
// Verifiera att custom actions finns
val actions = nodeInfo.actionList
assertTrue(actions.any { it.label == "Svara" })
assertTrue(actions.any { it.label == "Radera" })
nodeInfo.recycle()
}
Unit tester ger snabb feedback under utveckling och fångar regressions i CI/CD.
Espresso UI-tester med tillgänglighet
Espresso kan verifiera tillgänglighet i hela användarflöden.
@Test
fun testLoginFlowAccessibility() {
// Verifiera att alla formulärfält har contentDescription
onView(withId(R.id.email_field))
.check(matches(isDisplayed()))
.check(matches(withContentDescription()))
onView(withId(R.id.password_field))
.check(matches(withContentDescription()))
// Verifiera touch target storlekar
onView(withId(R.id.login_button))
.check(matches(hasMinimumSize(48, 48)))
// Testa interaktion
onView(withId(R.id.email_field))
.perform(typeText("[email protected]"))
onView(withId(R.id.password_field))
.perform(typeText("password123"))
onView(withId(R.id.login_button))
.perform(click())
// Verifiera att success-meddelande är tillgängligt
onView(withText("Inloggning lyckades"))
.check(matches(isDisplayed()))
}
// Custom matchers för tillgänglighet
fun withContentDescription(): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("has contentDescription")
}
override fun matchesSafely(view: View): Boolean {
return !view.contentDescription.isNullOrEmpty()
}
}
}
fun hasMinimumSize(minWidth: Int, minHeight: Int): Matcher<View> {
return object : TypeSafeMatcher<View>() {
override fun describeTo(description: Description) {
description.appendText("has minimum size ${minWidth}x${minHeight}dp")
}
override fun matchesSafely(view: View): Boolean {
val density = view.context.resources.displayMetrics.density
val minWidthPx = (minWidth * density).toInt()
val minHeightPx = (minHeight * density).toInt()
return view.width >= minWidthPx && view.height >= minHeightPx
}
}
}
UI-tester validerar att tillgänglighet fungerar i verkliga användarscenarier, inte bara isolerade komponenter.
Debug-verktyg och logging
Skapa debug-verktyg för att inspektera tillgänglighetsträdet under utveckling.
// Extension för att dumpa accessibility tree
fun View.dumpAccessibilityTree(level: Int = 0): String {
val indent = " ".repeat(level)
val sb = StringBuilder()
if (importantForAccessibility != View.IMPORTANT_FOR_ACCESSIBILITY_NO) {
sb.appendLine("$indent[${this::class.simpleName}]")
sb.appendLine("$indent contentDescription: $contentDescription")
sb.appendLine("$indent clickable: $isClickable")
sb.appendLine("$indent focusable: $isFocusable")
sb.appendLine("$indent size: ${width}x${height}px")
}
if (this is ViewGroup) {
for (i in 0 until childCount) {
sb.append(getChildAt(i).dumpAccessibilityTree(level + 1))
}
}
return sb.toString()
}
// Användning i debug builds
#if DEBUG
button.setOnLongClickListener {
Log.d("Accessibility", rootView.dumpAccessibilityTree())
true
}
#endif
Detta kan hjälpa identifiera problem som views som saknar contentDescription eller har fel importantForAccessibility-inställningar.
Best practices sammanfattat
Efter att ha gått igenom teknisk implementation, här är de viktigaste best practices:
Använd Material Components när möjligt. De har inbyggd tillgänglighet och följer Material Design principer.
Sätt alltid contentDescription på interaktiva element och bilder som förmedlar information. Dölj dekorativa element.
Testa med TalkBack regelbundet under utveckling, inte bara i slutet. Aktivera TalkBack och navigera genom din app varje sprint.
Gruppera relaterat innehåll med mergeDescendants eller AccessibilityDelegate så TalkBack-användare inte behöver navigera genom onödigt många element.
Implementera custom actions för att ge kraftfull funktionalitet utan att röra gränssnittet med extra knappar.
Hantera fokus aktivt vid navigation och när innehåll ändras dynamiskt. Använd FocusRequester och accessibility events.
Skriv automatiserade tester för tillgänglighet. Unit tests för komponenter och UI-tester för flows.
Testa på olika enheter från olika tillverkare och Android-versioner. Fragmentering betyder att samma kod kan uppföra sig olika.
Använd Accessibility Scanner regelbundet för att hitta vanliga problem som kontrast, touch targets och saknade labels.
Dokumentera tillgänglighetsbeslut i kod-kommentarer så teamet förstår varför saker gjorts på ett visst sätt.
Relaterade resurser
För mer information om Android-tillgänglighet:
Skapa tillgängliga appar för Android – Översikt över Android-tillgänglighetsfunktioner och Material Design.
Android-tillgänglighet för designers – Designriktlinjer för färg, typografi och layout.
Android-testning för tillgänglighet – Detaljerad guide till Accessibility Scanner, TalkBack-testning och teststrategier.
Google’s officiella dokumentation:
- Android Accessibility Overview
- Build accessible apps
- Test your app’s accessibility
- Jetpack Compose Accessibility
Med denna kunskap har du verktygen att bygga Android-appar som fungerar perfekt för alla användare och uppfyller LPTT:s krav.