Hoppa till huvud innehåll

Allt om LPTT

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:

Med denna kunskap har du verktygen att bygga Android-appar som fungerar perfekt för alla användare och uppfyller LPTT:s krav.