Jetpack Compose 中的無障礙功能

1. 簡介

在本程式碼研究室中,您將瞭解如何使用 Jetpack Compose 強化應用程式的無障礙功能。我們會逐步說明幾種常見用途,並逐步改善範例應用程式。包括觸控目標大小、內容說明、點選標籤等等。

視障、色覺障礙、聽障、精細動作障礙、認知障礙和其他許多身心障礙人士會使用 Android 裝置,處理各種日常事務。開發應用程式時將無障礙設計納入考量,能帶來更優異的使用者體驗,尤其可以造福上述族群,或有其他無障礙需求的使用者。

在本程式碼研究室中,我們將使用 TalkBack 來手動測試程式碼變更。TalkBack 是一項無障礙服務,主要供視障人士使用。此外,請務必使用其他無障礙服務測試任何程式碼變更內容,例如「切換控制功能」。

TalkBack 焦點方框會在 Jetnews 的主畫面中移動。在畫面底部會顯示 TalkBack 公告文字。

在 Jetnews 應用程式中使用 TalkBack。

課程內容

在本程式碼研究室,您將學到:

  • 如何透過增加觸控目標大小,滿足精細動作障礙人士的需求。
  • 什麼是語意屬性,以及其變更方式。
  • 如何提供更容易存取的可組合項資訊。

軟硬體需求

建構項目

在本程式碼研究室中,我們將改善新聞閱讀應用程式的無障礙功能。首先,我們將從不具備重要無障礙功能的應用程式著手,並運用習得的知識,提高應用程式的實用性,進一步滿足具有無障礙需求的使用者。

2. 開始設定

在這個步驟中,您將下載程式碼,其中包含一個簡易的新聞閱讀器應用程式。

軟硬體需求

取得程式碼

您可以在 codelab-android-compose GitHub 存放區中找到本程式碼實驗室的程式碼。如要複製該存放區,請執行下列命令:

$ git clone https://github.com/android/codelab-android-compose

或者,您也可以下載兩個 ZIP 檔案:

查看範例應用程式

您剛才下載的程式碼包含所有 Compose 程式碼研究室可用的程式碼。如要完成本程式碼研究室,請在 Android Studio 中開啟 AccessibilityCodelab 專案。

建議您先從 main 分支版本的程式碼著手,依自己的步調逐步完成本程式碼研究室。

設定 TalkBack

在本程式碼研究室中,我們會使用 TalkBack 檢查變更。在使用實體裝置進行測試時,請遵循以下操作說明開啟 TalkBack。根據預設,模擬器並未安裝 TalkBack。選擇包含 Play 商店的模擬器,並下載 Android 無障礙套件

3. 觸控目標大小

任何使用者可點擊、輕觸或進行互動的螢幕元素,都必須設為適當大小,方便使用者進行互動。您應確認這些元素的寬度和高度至少有 48dp

如果這些控制項會動態調整大小,或是根據內容大小進行調整,建議您採用 sizeIn 修飾符設定這些項目的尺寸下限。

有些 Material 元件可為您設定這些大小。舉例來說,按鈕可組合函式的 MinHeight 會設為 36 dp,並使用 8 dp 垂直邊框間距。這會增加至必要的 48dp 高度。

開啟範例應用程式並執行 TalkBack 時,您會發現貼文資訊卡中交叉圖示的觸控目標太小。我們希望讓此觸控目標的大小至少達到 48dp。

在以下螢幕截圖中,左側為原始應用程式,右側為經過改善的解決方案。

清單項目的比較結果,其中左側的交叉圖示外框偏小,右側的外框則較大。

以下將說明實作方式,並檢查此可組合項的大小。開啟 PostCards.kt 並尋找 PostCardHistory 可組合項。如您所見,實作時會將溢位選單圖示的大小設為 24dp:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...

   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
           Icon(
               imageVector = Icons.Default.Close,
               contentDescription = stringResource(R.string.cd_show_fewer),
               modifier = Modifier
                   .clickable { openDialog = true }
                   .size(24.dp)
           )
       }
   }
   // ...
}

如要增加此 Icon 的觸控目標大小,您可以新增邊框間距:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
           Icon(
               imageVector = Icons.Default.Close,
               contentDescription = stringResource(R.string.cd_show_fewer),
               modifier = Modifier
                   .clickable { openDialog = true }
                   .padding(12.dp)
                   .size(24.dp)
           )
       }
   }
   // ...
}

在我們的使用案例中,您將更輕鬆地確認觸控目標至少為 48dp。我們可以使用 Material 元件 IconButton 來處理以下程式碼:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
           IconButton(onClick = { openDialog = true }) {
               Icon(
                   imageVector = Icons.Default.Close,
                   contentDescription = stringResource(R.string.cd_show_fewer)
               )
           }
       }
   }
   // ...
}

透過 TalkBack 導覽畫面時,現可正確顯示 48 dp 的觸控目標區域。此外,IconButton 還會加入漣漪效果指標,讓使用者知道此元素可以點選。

4. 按一下標籤

根據預設,應用程式中可供點選的元素不會說明點選該元素後會執行何種操作。因此,TalkBack 等無障礙服務會使用非常籠統的預設說明。

為了向具有無障礙需求的使用者提供最佳體驗,我們可以提供具體說明,解釋使用者點選此元素時會有什麼影響。

在 Jetnews 應用程式中,使用者點選各種貼文資訊卡後,即可閱讀完整貼文。根據預設,系統會讀出可點選元素的內容,以及「Double tap to activate」這段文字。我們希望改為提供更具體的說明,並採用「Double tap to read article」(輕觸兩下即可閱讀文章) 文字。相較於理想解決方案,原始版本看起來如下所示:

已啟用 TalkBack 的兩個螢幕錄製畫面,一個輕觸了直向清單中的貼文,另一個則輕觸橫向輪轉介面中的貼文。

變更可組合項的點擊標籤。之前 (左側) 與之後 (右側) 的對比。

clickable 修飾詞內含可讓您直接設定此點擊標籤的參數。

接下來將說明 PostCardHistory 實作項目:

@Composable
fun PostCardHistory(
   // ...
) {
   Row(
       Modifier.clickable { navigateToArticle(post.id) }
   ) {
       // ...
   }
}

如您所見,此實作項目使用 clickable 修飾符。如要設定點擊標籤,可設定 onClickLabel 參數:

@Composable
fun PostCardHistory(
   // ...
) {
   Row(
       Modifier.clickable(
               // R.string.action_read_article = "read article"
               onClickLabel = stringResource(R.string.action_read_article)
           ) {
               navigateToArticle(post.id)
           }
   ) {
       // ...
   }
}

TalkBack 現在可正確朗讀「Double tap to read article」(輕觸兩下即可閱讀文章)

主畫面上的其他貼文資訊卡也有相同的一般點擊標籤。接著來看看 PostCardPopular 可組合函式的實作情況,並更新其點擊標籤:

@Composable
fun PostCardPopular(
   // ...
) {
   Card(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier.size(280.dp, 240.dp),
       onClick = { navigateToArticle(post.id) }
   ) {
       // ...
   }
}

此可組合函式會在內部使用 Card 可組合函式,使您無法直接設定其點擊標籤。您可以改用 semantics 修飾符設定點擊標籤:

@Composable
fun PostCardPopular(
   post: Post,
   navigateToArticle: (String) -> Unit,
   modifier: Modifier = Modifier
) {
   val readArticleLabel = stringResource(id = R.string.action_read_article)
   Card(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier
          .size(280.dp, 240.dp)
          .semantics { onClick(label = readArticleLabel, action = null) },
       onClick = { navigateToArticle(post.id) }
   ) {
       // ...
   }
}

5. 自訂操作

許多應用程式都會顯示某種清單,而每個清單項目各包含一或多個動作。使用螢幕閱讀器時,系統會一再重複聚焦於相同動作,因此這類清單的導覽作業可能會變得相當繁瑣。

我們可以將自訂無障礙動作新增至可組合項。這樣一來,與同個清單項目相關的動作就會分到同一群組中。

Jetnews 應用程式會顯示使用者可閱讀的文章清單。每個清單項目皆包含一個動作,表示使用者希望減少看到這個主題的內容。在此區段中,我們會將此動作移至自訂無障礙動作,讓您更輕鬆地瀏覽清單。

左側顯示的是預設情況,每個交叉圖示皆可聚焦。右側則顯示解決方案,讓動作包含在 TalkBack 的自訂動作中:

已啟用 TalkBack 的兩個螢幕錄製畫面。左側的畫面會顯示如何讓貼文項目上的交叉圖示可供選取。輕觸兩下以開啟對話方塊。右側畫面顯示只要使用輕觸三下的手勢,即可開啟自訂「Actions」選單。輕觸「Show fewer of this」動作,就會開啟相同的對話方塊。

為貼文項目新增自訂動作。之前 (左側) 與之後 (右側)。

接著開啟 PostCards.kt,瞭解 PostCardHistory 可組合項的實作方式。請注意,RowIconButton 的可點選屬性皆使用 Modifier.clickableonClick

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       Modifier.clickable(
           onClickLabel = stringResource(R.string.action_read_article)
       ) {
           navigateToArticle(post.id)
       }
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
           IconButton(onClick = { openDialog = true }) {
               Icon(
                   imageVector = Icons.Default.Close,
                   contentDescription = stringResource(R.string.cd_show_fewer)
               )
           }
       }
   }
   // ...
}

根據預設,RowIconButton 可組合函式皆可供點選,因此 TalkBack 會聚焦於這些可組合函式。這適用於每個清單項目,也就是說,導覽清單時需要頻繁滑動。建議您將 IconButton 的相關動作納入清單項目的自訂動作。您可以使用 clearAndSetSemantics 修飾符,指示「無障礙服務」不要與此 Icon 互動:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       Modifier.clickable(
           onClickLabel = stringResource(R.string.action_read_article)
       ) {
           navigateToArticle(post.id)
       }
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
            IconButton(
                modifier = Modifier.clearAndSetSemantics { },
                onClick = { openDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = stringResource(R.string.cd_show_fewer)
                )
            }
       }
   }
   // ...
}

不過,移除 IconButton 的語意後,就無法再執行動作。我們可以將動作新增至清單項目,只要在 semantics 修飾符中新增自訂動作即可:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   val showFewerLabel = stringResource(R.string.cd_show_fewer)
   Row(
        Modifier
            .clickable(
                onClickLabel = stringResource(R.string.action_read_article)
            ) {
                navigateToArticle(post.id)
            }
            .semantics {
                customActions = listOf(
                    CustomAccessibilityAction(
                        label = showFewerLabel,
                        // action returns boolean to indicate success
                        action = { openDialog = true; true }
                    )
                )
            }
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
            IconButton(
                modifier = Modifier.clearAndSetSemantics { },
                onClick = { openDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = showFewerLabel
                )
            }
       }
   }
   // ...
}

我們現可在 TalkBack 中,使用自訂動作彈出式視窗來套用動作。隨著清單項目中的動作數量增加,這也會更加相關。

6. 視覺元素說明

並非每個應用程式使用者都能查看或解讀應用程式顯示的視覺元素,例如圖示和插圖。無障礙服務也不能基於個別像素而呈現視覺元素。因此身為開發人員,您必須在應用程式中將關於視覺元素的更多資訊傳遞至無障礙服務。

ImageIcon 等視覺可組合項包含參數 contentDescription。您會傳遞該視覺元素的本地化說明;如果元素只有裝飾功能,則會傳遞 null

應用程式中的文章畫面缺少部分內容說明。執行應用程式並選取熱門文章,以前往文章畫面。

已啟用 TalkBack 的兩個螢幕錄製畫面,皆顯示輕觸了文章畫面中的返回按鈕。左側畫面呼叫「Button - double tap to activate」,右側畫面則呼叫「Navigate up - double tap to activate」。

新增視覺內容說明。之前 (左側) 與之後 (右側)。

若未提供任何資訊,點選左上方的導覽圖示時,只會宣告「Button, double tap to activate」。這並不會向使用者告知啟用該按鈕後會採取哪些動作。開啟 ArticleScreen.kt

@Composable
fun ArticleScreen(
   // ...
) {
   // ...
   Scaffold(
       topBar = {
           InsetAwareTopAppBar(
               title = {
                   // ...
               },
               navigationIcon = {
                   IconButton(onClick = onBack) {
                       Icon(
                           imageVector = Icons.Filled.ArrowBack,
                           contentDescription = null
                       )
                   }
               }
           )
       }
   ) { 
       // ...
   }
}

在圖示中加入有意義的內容說明:

@Composable
fun ArticleScreen(
   // ...
) {
   // ...
   Scaffold(
       topBar = {
           InsetAwareTopAppBar(
               title = {
                   // ...
               },
               navigationIcon = {
                   IconButton(onClick = onBack) {
                       Icon(
                           imageVector = Icons.Filled.ArrowBack,
                           contentDescription = stringResource(
                               R.string.cd_navigate_up
                           )
                       )
                   }
               }
           )
       }
   ) { 
       // ...
   }
}

本文中的另一個視覺元素為標題圖片。在這個案例中,此圖片僅為裝飾性,並未顯示任何需要傳達給使用者的資訊。因此,可以將內容說明設為 null,這樣在使用無障礙服務時,系統就會略過該元素。

畫面上最後一個視覺元素是個人資料相片。由於我們在此案例中使用的是一般顯示圖片,因此無須在此處新增內容說明。當我們使用此作者的實際個人資料相片時,可要求對方提供適當的內容說明

7. 標題

若是包含大量文字的畫面,例如文章畫面,有視覺障礙的使用者會難以迅速找到所需的章節內容。為了方便辨識,我們可以指明文字的哪些部分為標題。使用者只要向上或向下滑動,就能快速瀏覽不同的標題。

根據預設,系統無法將任何可組合項標示為標題,因此無法執行瀏覽動作。我們希望讓文章畫面依標題瀏覽來提供標題:

已啟用 TalkBack 的兩個螢幕錄製畫面,透過向下滑動瀏覽標題。左側畫面讀出「No next heading」。右側畫面則循環顯示標題,並大聲讀出每個標題。

新增標題。之前 (左側) 與之後 (右側)。

本文中的標題定義於 PostContent.kt。讓我們開啟該檔案,並捲動至 Paragraph 可組合項:

@Composable
private fun Paragraph(paragraph: Paragraph) {
   // ...
   Box(modifier = Modifier.padding(bottom = trailingPadding)) {
       when (paragraph.type) {
           // ...
           ParagraphType.Header -> {
               Text(
                   modifier = Modifier.padding(4.dp),
                   text = annotatedString,
                   style = textStyle.merge(paragraphStyle)
               )
           }
           // ...
       }
   }
}

此處的 Header 定義為簡易 Text 可組合項。我們可設定 heading 語意屬性,以指明此可組合項為標題。

@Composable
private fun Paragraph(paragraph: Paragraph) {
   // ...
   Box(modifier = Modifier.padding(bottom = trailingPadding)) {
       when (paragraph.type) {
           // ...
           ParagraphType.Header -> {
               Text(
                   modifier = Modifier.padding(4.dp)
                     .semantics { heading() },
                   text = annotatedString,
                   style = textStyle.merge(paragraphStyle)
               )
           }
           // ...
       }
   }
}

8. 自訂合併

如前述步驟所示,TalkBack 等無障礙服務會依元素導覽畫面。根據預設,Jetpack Compose 中每個已設定「至少一個」語意屬性的低階可組合函式皆可接收焦點。舉例來說,Text 可組合項設定 text 語意屬性,因此會接收焦點。

但如果畫面顯示過多可聚焦元素,可能會在使用者逐一瀏覽各個元素時造成混淆。您可使用 semantics 修飾符與其 mergeDescendants 屬性,將這些可組合項合併在一起。

接著來看看文章畫面。大多數元素皆會取得正確的焦點層級。但就目前而言,文章的中繼資料會朗讀為數個獨立的項目。只要將其合併為單一可聚焦實體,即可改善此情況:

已啟用 TalkBack 的兩個螢幕錄製畫面。左側畫面會針對「Author」(作者) 與「Metadata」(中繼資料) 欄位,顯示獨立的綠色 TalkBack 方框。右側畫面會在兩個欄位周圍顯示單一方框,並讀取串連內容。

合併可組合項。之前 (左側) 與之後 (右側)。

開啟 PostContent.kt 並檢查 PostMetadata 可組合項:

@Composable
private fun PostMetadata(metadata: Metadata) {
   // ...
   Row {
       Image(
           // ...
       )
       Spacer(Modifier.width(8.dp))
       Column {
           Text(
               // ...
           )

           CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
               Text(
                   // ..
               )
           }
       }
   }
}

我們可以指示頂層列合併子系,進而產生想要的行為:

@Composable
private fun PostMetadata(metadata: Metadata) {
   // ...
   Row(Modifier.semantics(mergeDescendants = true) {}) {
       Image(
           // ...
       )
       Spacer(Modifier.width(8.dp))
       Column {
           Text(
               // ...
           )

           CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
               Text(
                   // ..
               )
           }
       }
   }
}

9. 切換按鈕與核取方塊

當 TalkBack 選取 SwitchCheckbox 等可切換元素時,這些元素會大聲讀出自己的已勾選狀態。然而若無背景資訊,或許會難以理解這些可切換元素的意義。我們可以提升可切換元素的狀態,納入可切換元素的背景資訊,讓使用者只要按下可組合函式本身或其說明標籤,即可切換 SwitchCheckbox

我們可在「Interests」(興趣) 畫面中查看此範例。在主畫面中開啟導覽匣,即可在其中瀏覽。「Interests」(興趣) 畫面會顯示使用者可訂閱的主題清單。根據預設,系統會分別聚焦畫面上的核取方塊和標籤,因此使用者不容易瞭解相關背景資訊。我們希望將整個 Row 設為可切換:

已啟用 TalkBack 的兩個螢幕錄製畫面,顯示列出可選取主題的興趣畫面。在左側畫面中,TalkBack 會分別勾選每個核取方塊。在右側畫面中,TalkBack 會選取整列。

使用核取方塊。之前 (左側) 與之後 (右側)。

開啟 InterestsScreen.kt,瞭解 TopicItem 可組合項的實作方式:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   Row(
       modifier = Modifier
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = { onToggle() },
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

如您所見,Checkbox 具有用於切換元素的 onCheckedChange 回呼。我們可以將此回呼升至整個 Row 的層級:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   Row(
       modifier = Modifier
           .toggleable(
               value = selected,
               onValueChange = { _ -> onToggle() },
               role = Role.Checkbox
           )
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = null,
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

10. 狀態說明

在上一個步驟中,我們已將切換行為從 Checkbox 提升至父項 Row。我們可新增可組合項狀態的自訂說明,進一步改善此元素的無障礙功能。

根據預設,Checkbox 狀態會讀取為「Ticked」(已勾選) 或「Not tick」(未勾選)。我們可以將此說明替換為專屬自訂說明:

已啟用 TalkBack 的兩個螢幕錄製畫面,皆顯示輕觸了興趣畫面中的主題。左側畫面宣告「Not ticked」,右側畫面則宣告「Not subscribed」。

新增狀態說明。之前 (左側) 與之後 (右側)。

我們可以繼續處理在上個步驟中調整的 TopicItem 可組合項。

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   Row(
       modifier = Modifier
           .toggleable(
               value = selected,
               onValueChange = { _ -> onToggle() },
               role = Role.Checkbox
           )
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = null,
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

您可使用 semantics 修飾符中的 stateDescription 屬性來新增自訂狀態說明:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   val stateNotSubscribed = stringResource(R.string.state_not_subscribed)
   val stateSubscribed = stringResource(R.string.state_subscribed)
   Row(
       modifier = Modifier
           .semantics {
               stateDescription = if (selected) {
                   stateSubscribed
               } else {
                   stateNotSubscribed
               }
           }
           .toggleable(
               value = selected,
               onValueChange = { _ -> onToggle() },
               role = Role.Checkbox
           )
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = null,
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

11. 恭喜!

恭喜!您已成功完成本程式碼研究室,並進一步瞭解 Compose 中的無障礙功能。您已瞭解觸控目標、視覺元素說明和狀態說明。您已新增點擊標籤、標題和自訂動作。您已瞭解如何新增自訂合併功能,以及如何使用切換按鈕和核取方塊。將這些學習成果運用在您的應用程式中,可大幅改善應用程式的無障礙設計!

請參閱 Compose 課程中的其他程式碼研究室。另外亦可參閱其他程式碼範例,包括 Jetnews。

說明文件

如需有關這些主題的更多資訊和指南,請參閱以下說明文件: