Jetpack Compose(第十一趴)——使用Jetpack Compose改进应用的无障碍功能

1,136 阅读12分钟

1、简介

您将学习如何使用Jetpack Compose改进应用的无障碍功能。我们将详细介绍几个常见的用例,并逐步改进一个示例应用。我们将介绍触摸目标大小、内容描述、点击标签等等。

视力受损、色盲、听力收缩、精细动作失能的人、以及有认知障碍和许多其他残疾的人可以使用Android设备来处理他们日常生活中的各种事物、如果您能够在开发应用时考虑到无障碍功能,那么您便可以改善用户体验,对具有这些许需求以及其他无障碍功能的用户来说尤其如此。

我们将使用 TalkBack 手动测试代码更改。TalkBack 是一项主要供视力受损的人使用的无障碍服务。此外,请确保使用其他无障碍服务(例如开关控制)测试对代码所做的任何更改。

3.gif

1.1、学习内容:

  • 如何通过增大触摸目标大小来辅助惊喜动作失能的用户。
  • 什么是语义属性以及如何更改语义属性。
  • 如何向可组合项提供信息,让其使用起来更便捷。

2、准备工作

在此步骤中,您将下载此 Codelab 的代码,其中包含一个简单的新闻阅读器应用。

所需条件

获取代码

此 Codelab 的代码可以在 android-compose-codelabs GitHub 代码库中找到。如需克隆该代码库,请运行以下命令:

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

3、触摸目标大小

屏幕上可供用户点击、触摸或以其他方式互动的所有元素都应足够大,让用户能够进行可靠的互动。您应确保这些元素的宽度和高度至少为48dp

某些Material组件会为您设置这些大小。例如,Button可组合项的MinHeight设置为36dp,并使用8dp的垂直内边距。这加起来就是要求的48dp高度。

当我们打开示例应用并运行TalkBack时,我们会注意到,文章卡片中叉号图标的触摸目标非常小。我们希望此触摸目标至少为48dp。

在下面的屏幕截图中,左侧是原始应用,而右侧是改进的解决方案。

image.png

让我们来看看相应的实现,看一下此可组合项的大小。打开PostCards.kt并查找PostCardHistory可组合项。如您所见,该实现将溢出菜单图标的大小设置为24dp:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
    // ...
    
    Row(
        // ...
    ) {
        // ...
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            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(LocalContentAlpha provides ContentAlpha.medium) {
            Icon(
                imageVector = Icon.Default.Close,
                contentDescription = stringResource(R.string.cd_show_fewer),
                modifier = Modifier
                    .clickable { openDialog = true }
                    .padding(12.dp)
                    .size(24.dp)
            )
        }
    }
}
注意:修饰符函数的顺序非常重要。由于每个函数都会对上一个函数返回的Modifier进行更改,因此顺序会影响最终结果。在本例中,我们在设置大小之前但在应用clickable修饰符之后应用padding。这样,大小会加上内边距,并且整个元素是可点击的。

在我们的用例中,有一种更简单的方法确保触摸目标至少为48dp。我们可以使用Material组件IconButton,该组件将为我们处理此问题:

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

使用TalkBack浏览屏幕时,现在会正确显示48dp的触摸目标区域。此外,IconButton还添加了涟漪效果,向用户表明该元素是可点击的。

4、点击标签

默认情况下,应用中的可点击元素不会提供任何关于该元素会在点击时做什么的信息。因此,TalkBack等无障碍服务将会使用非常宽泛的默认描述。

为了向具有无障碍功能需求的用户提供最佳的体验,我们可以提供具体的描述,解释当用户点击此元素时会发生什么情况。

在Jetnews应用中,用户可点击各个文章卡片以阅读正片文章。默认情况下,这样讲独处可点击元素的内容,紧接着是文字“Double tap to activate”(点按两次即可激活)。我们希望更具体,使用“Double tap to read article”(点按两次即可阅读文章)。下面是原始版本与理想解决发难的比较:

1.gif

更改可组合项的点击标签。之前(左侧)与之后(右侧)的比较。

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"
            onClickLable = 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修饰符设置点击标签:

@OptIn(ExperimentalMaterialApi::class)
@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的自定义操作中:

2.gif

向文章项添加自定义操作。之前(左侧)与之后(右侧)的比较。

让我们打开PostCard.kt并查看PostCardHistory可组合项的实现。请注意使用Mdoifier.clickableonClickRowIconButton的可点击属性:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
    // ...
    Row(
        Modifier.clickable(
            onClickLabel = stringResource(R.string.action_read_article)
        ) {
            navigateToArticle(post.id)
        }
    ) {
        // ...
        CompositionLocalProvider(LocaalContentAlpha provides ContentAlpha.medium) {
            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(
            onClickLable = stringResource(R.string.action_read_article) 
        ) {
                navigateToArticle(post.id)
            }
    ) {
        // ...
        CompositionLocalProvider(LocalContentAlpha provides ContentAlpah.medium) {
            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 = strignResource(R.string.action_read_article)
            ) {
                navigateToArticle(post.id)
            }
            .semantics {
                customActions = listOf(
                    CustomAccessibilityAction(
                        label = showFewerLabel,
                        // action returns boolean to indicate success
                        action = { openDialog = true; true }
                    )
                )
            }
    ) {
        // ...
        CompositionLabelProvider(LocalContentAlpha provides ContentAlpha.medium) {
            IconButton(
                modifier = Modifier.clearAndSetSemantics { },
                onClick = { openDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = showFewerLabel
                )
            }
        }
    }
    // ...
}

现在,我们可以使用TalkBack中自定义操作弹出菜单来应用该操作。随着列表项中的操作数不断增加,浙江变得越来越有意义。

6、视觉元素描述

并不是应用的每个用户都能够看到或解释应用中显示的视觉元素,如图标和插入。无障碍服务也无法仅仅根据视觉元素的像素来弄清楚这些元素的意思。这使得开发者有必要将有关应用中的视觉元素的更多信息传递给无障碍服务。

ImageIcon等视觉可组合项包含一个contentDescription参数。您可以在其中传递该视觉元素的本地化描述,如果该元素是纯装饰性的,则传递null

在我们的应用中,文章屏幕缺少一些内容描述。让我们运行应用并选择顶部的文章以导航到文章屏幕。

3.gif

添加视觉内容描述。之前(左侧)与之后(右侧)的比较。

如果我们不提供任何信息,当用户点击左上角的导航图标时,TalkBack将简单地独处“Button bdoule tap to activate”。这样并没有告诉用户有关当他们激活该按钮时将会执行什么操作的任何信息。让我们打开ArticleScreen.kt:

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

向Icon添加有意义的内容描述:

@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、标题

当屏幕包含大量的文字时,就像我们的文章屏幕一样,有视觉障碍的用户很难快速找到他们想要查找的板块。为了帮助解决此问题,我们可以指明文字的哪些部分是标题。然后,用户就可以通过向上或向下滑动来快速浏览这些不同的标题。

默认情况下,没有可组合项被标记为标题,因此不可以进行导航。我们希望文章屏幕提供按标题导航:

4.gif

添加标题。之前(左侧)与之后(右侧)的比较。

文章中的标题是在PostContent.kt中定义的。让我们打开该文件并滚动到Paragraph可组合项:

@Composable
private fun Paragraph(paragraph: Paragraph) {
    // ...
    Box(modifier = Modifier.padding(bottom = trailingPadding)) {
        // ...
        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语义属性,因此会获得焦点。

不过,如果屏幕上有太多可聚焦的元素,当用户逐个浏览这些元素时,会导致混乱。我们可以使用semantics修饰符及其mergeDescendants属性将可组合项合并在一起。

让我们看一下文章屏幕。大多数元素都获得了正确级别的焦点。但是,文章的元数据目前是作为几个单独的项目朗读的。可以通过将其合并为一个可聚焦实体来加以改进:

5.gif

合并可组合项。之前(左侧)与之后(右侧)的比较:

让我们打开PostContent.kt并查看PostMetadata可组合项:

@Composable
private fun PostMetadata(metadata: Metadata) {
    // ...
    Row {
        Image(
            // ...
        )
        Spacer(Modifier.width(8.dp))
        Column {
            Text(
                // ...
            )
            
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text(
                    // ...
                )
            }
        }
    }
}

我们可以让顶级行合并它的后代,这样就会产生我们想要的行为:

@Composable
private fun PostMetadata(medatada: Metadata) {
    // ...
    Row(Modifier.semantics(mergeDescendants = true) {}) {
        Image(
            // ...
        )
        Spacer(Modifier.width(8.dp))
        Column {
            Text(
                // ...
            )
            
            CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
                Text(
                    // ...
                )
            }
        }
    }
}

9、切换开关和复选框

当TalkBack选定SwitchCheckbox等可切换元素时,会大声读出其选装状态。不过,如果没有上下文,就很难理解这些课切换元素指的是什么。我们可以通过提升可切换状态来包含可切换元素的上下文,这样用户就可以通过按可组合项本身或描述它的标签来切换SwitchCheckbox

我们可以在"Interests"屏幕中看到一个这样的例子。您可以从主屏幕中打开抽屉式导航栏来导航到该屏幕。在“Interests”屏幕上,有用户可以订阅的主题列表。默认你情况下,次屏幕上的复选框与其标签是分开聚焦的,这使得很难理解其上下文。我们希望整个Row可切换:

6.gif

使用复选框。之前(左侧)与之后(右侧)的比较。

让我们打开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() },
            )
            .padding(horizontal = 16.dp, vertical = 8.dp)
    ) {
        // Checkbox(
            checked = selected,
            onCheckedChange = null,
            modifier = Modifeir.align(Alignment.CenterVertically)
        )
    }
}

10、状态描述

在上一步中,我们将开关切换行为从Checkbox提升到父级Row。我们可以通过可组合项的状态添加自定义描述来进一步改进此元素的无障碍功能。

默认情况下,Checkbox状态读作“Ticked”或“Not ticked”。我们可以将此描述替换为我们自己的自定义描述:

7.gif

添加状态描述。之前(左侧)与之后(右侧)的比较。 我们可以继续使用在最后一步中改写的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(Aligement.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(
                vale = selected,
                onValueChange = { _ -> onToggle() },
                role = Role.Checkbox
            )
            .padding(horizontal = 16.dp, vertical = 8.dp)
    ) {
        // ...
        Checkbox(
            checked = selected,
            onCheckedChange = null,
            modifier = Modifier.align(Alignment.CenterVertically)
        )
    }
}

11、恭喜

恭喜完成Jetpack Compose十一趴,接下来就需要你在项目上实践所学的知识!