Compose 中的無障礙功能

Compose 中撰寫的應用程式應支援不同使用者的無障礙需求。無障礙服務可用來將螢幕上顯示的內容轉化為更貼近特定需求的使用者。為了支援無障礙服務,應用程式會透過 Android 架構中的 API 公開有關 UI 元素的語意資訊。之後,Android 架構會將該語意資訊告知無障礙服務。每項無障礙服務均可選擇向使用者說明應用程式的最佳方式。Android 提供多種無障礙服務,包括 TalkbackSwitch Access

語義

Compose 使用語意屬性將資訊傳遞給無障礙服務。語意屬性可提供向使用者顯示的 UI 元素相關資訊。大部分的內建可組合項,例如 TextButton,會將這些語意屬性填入從可組合項和子項所推測出的資訊。toggleableclickable 等部分輔助鍵也會設定特定語意屬性。不過,有時架構需要更多資訊,才能向使用者說明 UI 元素。

本文件說明多種情境中,您需要在可組合項中明確加入其他資訊,方可向 Android 架構正確說明。同時也會說明如何針對特定的可組合項完全取代語意資訊。本文假設您對 Android 中的無障礙功能有基本的瞭解。

常見用途

如要協助有無障礙需求的使用者順利使用您的應用程式,建議您採用本頁所述的最佳做法。

考量最低觸控目標大小

任何使用者可點擊、輕觸或進行互動的螢幕元素,都必須設為適當大小,方便使用者進行互動。設定這些元素的大小時,請務必將大小下限設為 48dp,以便正確遵循「質感設計無障礙功能指南」。

Material Design 元件 (例如 CheckboxRadioButtonSwitchSliderSurface) 可在內部設定此最小尺寸,但前提是該元件可以接收使用者動作。舉例來說,如果 CheckboxonCheckedChange 參數設為非空值,系統就會在其中包含至少 48dp 的寬度和高度。

@Composable
private fun CheckableCheckbox() {
    Checkbox(checked = true, onCheckedChange = {})
}

onCheckedChange 參數設為空值時,系統不會納入邊框間距,因為該元件無法直接互動。

@Composable
private fun NonClickableCheckbox() {
    Checkbox(checked = true, onCheckedChange = null)
}

導入 SwitchRadioButtonCheckbox 等選取控制項時,您通常會將可點擊的行為推送至父項容器,並將可組合項的點擊回呼設為 null,然後在可組合父項中新增 toggleableselectable 修飾符。

@Composable
private fun CheckableRow() {
    MaterialTheme {
        var checked by remember { mutableStateOf(false) }
        Row(
            Modifier
                .toggleable(
                    value = checked,
                    role = Role.Checkbox,
                    onValueChange = { checked = !checked }
                )
                .padding(16.dp)
                .fillMaxWidth()
        ) {
            Text("Option", Modifier.weight(1f))
            Checkbox(checked = checked, onCheckedChange = null)
        }
    }
}

如果可點擊的可組合項大小小於最低觸控目標大小,Compose 仍會增加觸控目標大小。方法是將觸控目標大小擴大到可組合項的範圍之外。

在下列範例中,我們建立了非常小的可點擊項 Box。觸控目標區域會自動擴大到 Box 的範圍以外,因此輕觸 Box 旁邊的按鈕仍會觸發點擊事件。

@Composable
private fun SmallBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .size(1.dp)
        )
    }
}

為避免不同可組合項的觸控區域重疊,您應盡可能針對可組合項使用夠大的最小尺寸。在我們的範例中,則意味著使用 sizeIn 修飾符來設定內部方塊的最小大小:

@Composable
private fun LargeBox() {
    var clicked by remember { mutableStateOf(false) }
    Box(
        Modifier
            .size(100.dp)
            .background(if (clicked) Color.DarkGray else Color.LightGray)
    ) {
        Box(
            Modifier
                .align(Alignment.Center)
                .clickable { clicked = !clicked }
                .background(Color.Black)
                .sizeIn(minWidth = 48.dp, minHeight = 48.dp)
        )
    }
}

新增點擊標籤

您可以使用點擊標籤,為可組合項的點擊行為新增語意含義。點擊標籤可說明使用者與可組合項的互動情形。無障礙服務會根據點擊標籤,向有特定需求的使用者描述應用程式。

clickable 修飾詞內傳遞參數,藉此設定點擊標籤:

@Composable
private fun ArticleListItem(openArticle: () -> Unit) {
    Row(
        Modifier.clickable(
            // R.string.action_read_article = "read article"
            onClickLabel = stringResource(R.string.action_read_article),
            onClick = openArticle
        )
    ) {
        // ..
    }
}

如果您無法存取可點擊的修飾詞,則可在語意修飾詞內設定點擊標籤:

@Composable
private fun LowLevelClickLabel(openArticle: () -> Boolean) {
    // R.string.action_read_article = "read article"
    val readArticleLabel = stringResource(R.string.action_read_article)
    Canvas(
        Modifier.semantics {
            onClick(label = readArticleLabel, action = openArticle)
        }
    ) {
        // ..
    }
}

描述視覺元素

在您定義 ImageIcon 可組合項時,Android 架構無法自動瞭解正在顯示的內容。您必須傳送視覺元素的文字說明。

請設想一個螢幕,可供使用者與好友分享目前的頁面。這個畫麵包含一個可點選的分享圖示:

可點擊的圖示列,

單憑藉圖示,Android 架構無從得知該如何向視障使用者說明。Android 架構需要圖示的其他文字說明。

contentDescription 參數是用來描述視覺元素。您應使用本地化字串,因為系統會將這個字串提供給使用者。

@Composable
private fun ShareButton(onClick: () -> Unit) {
    IconButton(onClick = onClick) {
        Icon(
            imageVector = Icons.Filled.Share,
            contentDescription = stringResource(R.string.label_share)
        )
    }
}

有些視覺元素只是單純裝飾用途,而您可能不想向使用者傳達這些元素。將 contentDescription 參數設為 null 時,您必須向 Android 架構表明這個元素沒有相關聯的動作或狀態。

@Composable
private fun PostImage(post: Post, modifier: Modifier = Modifier) {
    val image = post.imageThumb ?: painterResource(R.drawable.placeholder_1_1)

    Image(
        painter = image,
        // Specify that this image has no semantic meaning
        contentDescription = null,
        modifier = modifier
            .size(40.dp, 40.dp)
            .clip(MaterialTheme.shapes.small)
    )
}

請依此判斷特定視覺元素是否需要 contentDescription。請自問這個元素是否傳送使用者執行工作所需的資訊。若無,最好略過該說明。

合併元素

無障礙服務 (例如 TalkBack 和切換控制功能) 可讓使用者在不同元素間移動畫面聚焦。元素只聚焦於適當的精細度很重要。如果畫面上的每個低階可組合項各自聚焦,使用者就必須互動許多次才能在畫面上移動。如果元素過度合併,使用者可能就無法瞭解哪些元素屬於同一群組。

當您將 clickable 輔助鍵套用至可組合項時,Compose 會自動合併其中包含的所有元素。這個做法也適用於 ListItem;系統會將清單項目中的元素合併,無障礙服務也會將其視為單一元素。

您可以將一組可組合元件組成一個邏輯群組,但該群組不可點擊,也不是清單項目的一部分。您仍希望無障礙服務將其視為單一元素。舉例來說,假設可組合項會顯示使用者的顯示圖片、使用者名稱和一些額外資訊:

一組 UI 元素,包括使用者名稱。已選取名稱。

您可以在 semantics 輔助鍵中使用 mergeDescendants 參數,讓 Compose 合併這些元素。這樣一來,無障礙服務只會選取已合併的元素,並合併子系的所有語意屬性。

@Composable
private fun PostMetadata(metadata: Metadata) {
    // Merge elements below for accessibility purposes
    Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
        Image(
            imageVector = Icons.Filled.AccountCircle,
            contentDescription = null // decorative
        )
        Column {
            Text(metadata.author.name)
            Text("${metadata.date} • ${metadata.readTimeMinutes} min read")
        }
    }
}

無障礙服務現在一次聚焦於整個容器,並合併其內容:

一組 UI 元素,包括使用者名稱。所有元素一併選取。

新增自訂動作

請查看下列清單項目:

一般清單項目,包含文章標題、作者和書籤圖示。

使用 TalkBack 等螢幕閱讀器聆聽螢幕上顯示的內容時,會先選取整個項目,然後選取書籤圖示。

已一併選取所有元素的清單項目。

清單項目,僅選取書籤圖示

在冗長的清單,重複性可能變得很高。更好的做法是定義自訂動作,讓使用者將項目加入書籤。提醒您,您也必須明確移除書籤圖示本身的行為,以確保無障礙服務不會選取書籤圖示本身。 請使用 clearAndSetSemantics 輔助鍵來完成這項操作:

@Composable
private fun PostCardSimple(
    /* ... */
    isFavorite: Boolean,
    onToggleFavorite: () -> Boolean
) {
    val actionLabel = stringResource(
        if (isFavorite) R.string.unfavorite else R.string.favorite
    )
    Row(
        modifier = Modifier
            .clickable(onClick = { /* ... */ })
            .semantics {
                // Set any explicit semantic properties
                customActions = listOf(
                    CustomAccessibilityAction(actionLabel, onToggleFavorite)
                )
            }
    ) {
        /* ... */
        BookmarkButton(
            isBookmarked = isFavorite,
            onClick = onToggleFavorite,
            // Clear any semantics properties set on this node
            modifier = Modifier.clearAndSetSemantics { }
        )
    }
}

說明元素的狀態

可組合項可定義語意的 stateDescription,供 Android 架構用來解讀可組合項所處的狀態。舉例來說,可切換的可組合項可能處於「已勾選」或「未勾選」狀態。在部分情況下,您可能會想覆寫 Compose 使用的預設狀態說明標籤。您可以先明確指定狀態說明標籤,再將可組合項定義為可切換:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
    val stateSubscribed = stringResource(R.string.subscribed)
    val stateNotSubscribed = stringResource(R.string.not_subscribed)
    Row(
        modifier = Modifier
            .semantics {
                // Set any explicit semantic properties
                stateDescription = if (selected) stateSubscribed else stateNotSubscribed
            }
            .toggleable(
                value = selected,
                onValueChange = { onToggle() }
            )
    ) {
        /* ... */
    }
}

定義標題

應用程式有時會在同一個畫面中,使用可捲動的容器顯示大量內容。舉例來說,畫面可能會顯示使用者正在閱讀文章的完整內容:

網誌文章的螢幕截圖,且文章文字位於可捲動的容器中。

無障礙功能的使用者無法順利瀏覽這類畫面。 如要協助他們順利瀏覽,你可以指出哪些元素是標題。在上例中,每個子區段標題都可設定為無障礙標題。某些無障礙服務 (例如 Talkback) 可讓使用者從標題直接瀏覽到另一個標題。

在 Compose 中,您可以透過定義語意屬性的方式,指出可組合項為標題

@Composable
private fun Subsection(text: String) {
    Text(
        text = text,
        style = MaterialTheme.typography.headlineSmall,
        modifier = Modifier.semantics { heading() }
    )
}

自動測試無障礙屬性

自訂應用程式的語意屬性時 (例如按照上述用途操作時),您可以使用自動化 UI 測試來驗證正確性及防止迴歸問題。

舉例來說,如要測試元素的點擊標籤是否設定正確,請使用以下程式碼:

@Test
fun test() {
    composeTestRule
        .onNode(nodeMatcher)
        .assert(
            SemanticsMatcher("onClickLabel is set correctly") {
                it.config.getOrNull(SemanticsActions.OnClick)?.label == "My Click Label"
            }
        )
}

建立自訂低階可組合項

更進階的用途是將應用程式中的特定「材質」元件替換成自訂版本。在這種情況下,請務必考量無障礙功能。假設您將「材質」Checkbox 替換成自己的實作項目,使用者很容易忘記加入負責處理此元件無障礙屬性的 triStateToggleable 修飾符。

原則上,您應在「材質」程式庫中查看該元件的實作方式,並模仿能找到的任何無障礙功能。此外,請多多利用基礎修飾符 (而非 UI 層級的修飾符),因為前者內建便有考慮無障礙功能。請務必透過多項無障礙服務測試自訂元件的實作,驗證其行為。

修改 isTraversalGrouptraversalIndex 的遍歷順序

根據預設,Compose 應用程式中的無障礙功能螢幕閱讀器行為會按照預期的閱讀順序實作,這類順序通常由左至右依序為從上到下。不過,某些應用程式版面配置需要額外的提示,演算法就無法判斷實際的閱讀順序。在以 View 為基礎的應用程式中,您可以使用 traversalBeforetraversalAfter 屬性修正這類問題。從 Compose 1.5 開始,Compose 提供了同等靈活的 API,但新增一個概念模型。

isTraversalGrouptraversalIndex 是語意屬性,可讓您控制無障礙功能和 TalkBack 焦點順序,在預設排序演算法不適用的情況下。isTraversalGroup 可識別具有語意意義的群組,traversalIndex 則會調整這些群組中個別元素的順序。您可以單獨使用 isTraversalGroup,也可以搭配 traversalIndex 使用,進一步自訂。

本頁面說明如何在應用程式中使用 isTraversalGrouptraversalIndex 控制螢幕閱讀器遍歷順序。

使用 isTraversalGroup 將元素分組

isTraversalGroup 是一個布林值屬性,用於定義語意節點是否為週遊群組。這類節點的作用是做為界線或邊框,以管理子項。

在節點上設定 isTraversalGroup = true 表示在移動到其他元素之前,系統會先造訪該節點的所有子項。您可以在無法使用螢幕閱讀器的可聚焦節點 (例如 欄、列或 Box) 上設定 isTraversalGroup

在這個範例中,程式碼片段被修改為使用 isTraversalGroup。下方程式碼片段會發出四個文字元素。左側兩個元素屬於一個 CardBox 元素,而右側兩個元素則屬於另一個 CardBox 元素:

// CardBox() function takes in top and bottom sample text.
@Composable
fun CardBox(
    topSampleText: String,
    bottomSampleText: String,
    modifier: Modifier = Modifier
) {
    Box(modifier) {
        Column {
            Text(topSampleText)
            Text(bottomSampleText)
        }
    }
}

@Composable
fun TraversalGroupDemo() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is "
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
            topSampleText1,
            bottomSampleText1
        )
        CardBox(
            topSampleText2,
            bottomSampleText2
        )
    }
}

程式碼會產生類似以下的輸出內容:

含有兩欄文字的版面配置,左欄顯示「This sentence is in the left column」(這個句子在左欄中),而右欄顯示「This sentence on theright」(這是在右側)。
圖 1 含有兩個語句的版面配置 (一個位於左欄,一個在右欄中)。

由於未設定語意,螢幕閱讀器的預設行為是從左到右、由上到下週遊元素。因此,TalkBack 會以錯誤的順序唸出語句片段:

「This sentence is in」→ "This sentence is" → "左欄。"→「位於右側。」

如要正確排序片段,請修改原始程式碼片段,將 isTraversalGroup 設為 true

@Composable
fun TraversalGroupDemo2() {
    val topSampleText1 = "This sentence is in "
    val bottomSampleText1 = "the left column."
    val topSampleText2 = "This sentence is"
    val bottomSampleText2 = "on the right."
    Row {
        CardBox(
//      1,
            topSampleText1,
            bottomSampleText1,
            Modifier.semantics { isTraversalGroup = true }
        )
        CardBox(
//      2,
            topSampleText2,
            bottomSampleText2,
            Modifier.semantics { isTraversalGroup = true }
        )
    }
}

由於 isTraversalGroup 是特別設定在每個 CardBox 上,因此在排序其元素時,會遵循 CardBox 邊界。在這種情況下,會先讀取左邊的 CardBox,然後是右邊的 CardBox

現在,TalkBack 會按正確順序讀出句子片段:

「這句話位於」→「左欄」。→ 「此句子為」→「位於右側。」

使用周遊索引進一步自訂遍歷順序

traversalIndex 是浮點屬性,可讓您自訂 TalkBack 遍歷順序。如果只有將元素分組在一起,使 TalkBack 無法正常運作,您可以將 traversalIndexisTraversalGroup 搭配使用,進一步自訂螢幕閱讀器排序。

traversalIndex 屬性具備下列特性:

  • 系統會優先套用 traversalIndex 值較低的元素。
  • 可以是正數或負數。
  • 預設值為 0f
  • 只會影響螢幕閱讀器可聚焦的節點,例如「文字」或「按鈕」等螢幕元素。舉例來說,除非資料欄也設定了 isTraversalGroup,否則僅在資料欄上設定 traversalIndex 不會有任何效果。

以下範例說明如何搭配使用 traversalIndexisTraversalGroup

範例: Traverse 錶面

錶面是標準遍歷順序無法運作的常見情境。本節中的範例是以時間挑選器為基礎,可讓使用者穿越錶面上的數字,並選取小時和分鐘的運算單元數字。

上方有時間挑選器的錶面。
圖 2. 錶面的圖片。

在以下簡化的程式碼片段中,有一個 CircularLayout 繪製 12 個數字,從 12 開始,然後順時針移動圓形:

@Composable
fun ClockFaceDemo() {
    CircularLayout {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier) {
        Text((if (value == 0) 12 else value).toString())
    }
}

由於未採用預設的由左到右以及由上而下的順序,錶面不會按邏輯讀取,因此 TalkBack 會隨機讀出數字。如要修正此問題,請使用遞增的計數器值,如以下程式碼片段所示:

@Composable
fun ClockFaceDemo() {
    CircularLayout(Modifier.semantics { isTraversalGroup = true }) {
        repeat(12) { hour ->
            ClockText(hour)
        }
    }
}

@Composable
private fun ClockText(value: Int) {
    Box(modifier = Modifier.semantics { this.traversalIndex = value.toFloat() }) {
        Text((if (value == 0) 12 else value).toString())
    }
}

如要正確設定遍歷順序,請先將 CircularLayout 設為週遊群組,並設定 isTraversalGroup = true。接著,由於每個時鐘文字都會繪製在版面配置上,請將對應的 traversalIndex 設為計數器值。

由於計數器值會不斷增加,因此每個時鐘值的 traversalIndex 會隨著螢幕新增數字而增長;時鐘值 0 的 traversalIndex 為 0,而時鐘值 1 的 traversalIndex 為 1,依此類推。如此一來,TalkBack 的朗讀順序就會設為開啟。現在,CircularLayout 中的數字會按照預期順序讀取。

由於已設定的 traversalIndexes 只會與同一群組內的其他索引相關,因此系統會保留其餘的畫面順序。換句話說,上方程式碼片段中顯示的語意變更只會在已設定 isTraversalGroup = true 的錶面內修改順序。

請注意,如未將 CircularLayout's 語意設定為 isTraversalGroup = truetraversalIndex 變更仍然適用。然而,如果沒有 CircularLayout 進行繫結,錶面的 12 碼會在使用者存取畫面上所有其他元素後,於最後讀取。這是因為所有其他元素都有預設的 0f traversalIndex,而時鐘文字元素會在所有其他 0f 元素之後讀取。

範例:自訂懸浮動作按鈕的遍歷順序

在這個範例中,您會使用 traversalIndexisTraversalGroup 來控制 Material Design 懸浮動作按鈕 (FAB) 的遍歷順序。此範例以下列版面配置為基礎:

含有頂端應用程式列、範例文字、懸浮動作按鈕和底部應用程式列的版面配置。
圖 3. 這類版面配置包含頂端應用程式列、範例文字、懸浮動作按鈕和底部應用程式列。

根據預設,上述版面配置的 TalkBack 順序如下:

頂端應用程式列 → 範例文字 0 到 6 → 懸浮動作按鈕 (FAB) → 底部應用程式列

您希望螢幕閱讀器先將焦點放在懸浮動作按鈕 (FAB)。如要在 FAB 等 Material 元素上設定 traversalIndex,請按照下列步驟操作:

@Composable
fun FloatingBox() {
    Box(modifier = Modifier.semantics { isTraversalGroup = true; traversalIndex = -1f }) {
        FloatingActionButton(onClick = {}) {
            Icon(imageVector = Icons.Default.Add, contentDescription = "fab icon")
        }
    }
}

在這個程式碼片段中,建立將 isTraversalGroup 設為 true 的方塊,並在同一個方塊中設定 traversalIndex (-1f 低於 0f 的預設值),表示浮動方塊位於畫面上所有其他元素之前。

接下來,您可以將浮動方塊和其他元素放入 Scaffold,以實作簡單的 Material Design 版面配置:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ColumnWithFABFirstDemo() {
    Scaffold(
        topBar = { TopAppBar(title = { Text("Top App Bar") }) },
        floatingActionButtonPosition = FabPosition.End,
        floatingActionButton = { FloatingBox() },
        content = { padding -> ContentColumn(padding = padding) },
        bottomBar = { BottomAppBar { Text("Bottom App Bar") } }
    )
}

TalkBack 會以下列順序與元素互動:

懸浮動作按鈕 → 頂端應用程式列 → 文字範例 0 到 6 → 底部應用程式列

瞭解詳情

如要進一步瞭解在 Compose 程式碼中支援無障礙功能,請參閱 Jetpack Compose 程式碼研究室的無障礙功能