SemanticsModifier
SemanticsModifier 有什么用?
Semantics [s’mntks]: 语义学。
SemanticsModifier,语义修饰符… 什么玩意?老实说,关于国内大多数 Android 开发者,一开始都比较难了解语义修饰符,它能够为元素供给一些额定的信息,首要用于无妨碍服务 Accessibility 和测验 Testing,而这两项技能在国内并不怎样受待见。
Compose 会在组合阶段履行 Composable 函数生成一颗 UI 树,用来描绘界面。
实践上除了这颗 UI 树,Compose 中还有别的一颗结构类似的树,叫做语义树,每个节点都是一个 SemanticsNode。语义树彻底不参与制作和渲染工作,因此是彻底不行见的,它只为 Accessibility 和 Testing 服务。
-
Accessibility 需求依据语义树的节点内容进行发音:
Android 系统中有一个无妨碍服务功用叫 TalkBack,它能够协助视力妨碍人士经过接触 + 语音反应的方法来与手机进行交互。敞开 TalkBack 后,手机会读出用户接触到的界面元素信息,这些信息便是来自语义树,用户能够依照语音提示进一步操作。
-
Testing 需求依据语义树找到想要测验的节点来履行测验逻辑:
将测验和无妨碍服务放在一起评论很简单让人感到奇怪,直觉上咱们会认为它俩毫无关系。不过实践上它们是存在一些共同点的:
- 需求找到某个控件元素,在 Compose 中界面元素是没有 id 的,因此需求经过语义树来定位;
- 需求对控件元素进行操作,比方点击、滑动等等;
- 需求访问控件元素的 Metadata,比方文本内容、描绘信息、启用状况等等。
语义树的效果咱们大约了解清楚了…那么,语义树是怎样生成的呢?答案很明显,便是靠 SemanticsModifier。
好消息是,绝大部分情况下,咱们都在运用一些标准 Composable 函数来编写界面,其间的大多数都建构在 Compose foundation 库之上,这时是不需求专门为了语义树去写 SemanticsModifier 的,组件内部现已帮咱们处理好了这些工作。
Column {
Text(text = "Hello World!")
Button(onClick = { }) { }
Switch(...)
Image(
painter = painterResource(id = R.drawable.compose),
contentDescription = "Compose logo" // contentDescription 便是 Image 组件的语义
)
}

semantics() 修饰符 & 语义特点
而当咱们运用初级 API 来制作界面时,就需求运用 semantics()
修饰符来为组件供给相应的语义了:
Box(
Modifier
.background(Blue)
.size(150.dp)
.semantics { contentDescription = "a blue box" }
)

semantics()
修饰符允许向当时组件增加键值对方法的语义信息,这些键值对也被称作“语义特点”,例如 text 是 Text 组件的一个语义特点 ,值便是 Text 组件的文本内容; contentDescription 是 Image 组件的一个语义特点(假如不设置为 null),值便是所设置的描绘信息。这些特点传达了组件的含义,语义树上的节点 SemanticsNode 便是依据这些特点来构建的。
fun Modifier.semantics(
mergeDescendants: Boolean = false,
properties: (SemanticsPropertyReceiver.() -> Unit) //
): Modifier
semantics()
的函数类型参数 properties
接收者类型为 SemanticsPropertyReceiver:
/**
* SemanticsPropertyReceiver is the scope provided by semantics {} blocks, letting you set
* key/value pairs primarily via extension functions.
*/
interface SemanticsPropertyReceiver {
operator fun <T> set(key: SemanticsPropertyKey<T>, value: T) //
}
var SemanticsPropertyReceiver.contentDescription: String
get() = throwSemanticsGetNotSupported()
set(value) {
set(SemanticsProperties.ContentDescription, listOf(value)) //
}
...
SemanticsPropertyReceiver 是 semantics { } 修饰符供给的效果域,能让开发者经过现有的扩展函数/特点来方便地设置各种键值对,它们是一些公共语义特点,比方前面刚用到的 contentDescription
扩展特点,只是在背面帮咱们调用了 SemanticsPropertyReceiver 的 set() 办法而已。

兼并和未兼并的语义树
semantics()
修饰符有一个可选参数 mergeDescendants: Boolean = false
,这又是什么呢?
fun Modifier.semantics(
mergeDescendants: Boolean = false, //
properties: (SemanticsPropertyReceiver.() -> Unit)
): Modifier
有些情况下,咱们需求对语义树中的节点进行兼并,比方:
Button(onClick = { /*TODO*/ }) {
Text("Like")
}
以上是 Button 组件的根本用法,在 Button 中套了一个 Text,由于它们是两个独立元素,在语义树中 Text 是 Button 的子节点,TalkBack 会为两个 SemanticsNode 独自发音,这明显不符合预期效果,想要 TalkBaack 把 Button 与 Text 视为一个整体发音,就要运用 mergeDescendants
参数对语义树中的节点进行兼并:
Button(
onClick = { /*TODO*/ }
modifie = Modifier.semantics(mergeDescendants = true) {},
) {
Text("Like")
}
将 mergeDescendants
设置为 true 后,所属语义节点会和其下一切子节点进行语义兼并(除了那些 mergeDescendants = true
的子节点),不过关于上面这个比如,咱们是没必要手动兼并的,由于 Button 的 onClick
参数在背面替咱们调用了 clickable()
修饰符,假如一个组件运用了 clickable()
/ toggleable
修饰符,内部会默许进行语义特点兼并。
再来看一个比如, 下面有一个可点击的文章列表项。用户能够点击整行打开文章详情页,也能够点击右侧按钮收藏文章。

由于 Row 是可点击的(clickable),所以它会与语义树中的一切子节点兼并,但不包括收藏按钮,由于收藏按钮也是可点击元素:

语义特点兼并规则
还有一个问题,前面现已看过 SemanticsPropertyReceiver 接口源码了,它的 set()
办法是一个泛型办法,也便是说语义特点能够是任意类型,那么必定不存在一套通用的语义特点兼并规则,详细来说到底是怎样个兼并法呢?还是以 contentDescription 为例,咱们看一下公共语义特点 contentDescription 所运用的键(SemanticsPropertyKey),关键在于 mergePolicy
,这个参数为每个语义特点指定了特定的兼并规则。
// SemanticsProperties.kt
var SemanticsPropertyReceiver.contentDescription: String
get() = throwSemanticsGetNotSupported()
set(value) {
set(SemanticsProperties.ContentDescription, listOf(value)) //
}
object SemanticsProperties {
val ContentDescription = SemanticsPropertyKey<List<String>>(
name = "ContentDescription",
//
mergePolicy = { parentValue, childValue ->
// contentDescription 语义特点的类型是 List<String>,
// 兼并时会简单地将一切子节点的值增加到父节点的值中
parentValue?.toMutableList()?.also { it.addAll(childValue) } ?: childValue
}
)
}
文章一开始说到只要一颗语义树,其实并不谨慎,精确地说有两颗语义树:一颗是在 UI 树的基础上,剔除了那些没有设置语义特点的元素后,生成的未兼并的语义树;第二颗则是经过兼并的语义树。
了解了 unmerged semantics tree 和 merged semantics tree 后,咱们趁便看看 clearAndSetSemantics()
修饰符,与 semantics()
修饰符不同,它会在设置语义信息之前,先铲除一切子节点的语义信息(不会铲除布局节点自身的语义信息)。详细来说,关于兼并语义树,会将一切子节点供给的语义信息擦除;关于未兼并语义树,会将一切子节点打上标记 [SemanticsConfiguration.isClearingSemantics]
,并不会实践擦除语义信息。
检查树
要查看语义树中各个节点的语义信息,有两种方法:
1.运用 Android Studio 的布局检查器 Layout Inspector,直接查看每个元素的语义信息

2.编写插桩测验,打印语义树
class MyComposeTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun MyTest() {
// Start the app
composeTestRule.setContent {
MaterialTheme {
Button(onClick = { /*TODO*/ }) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(text = "Like")
}
}
}
// Log the full semantics tree
composeTestRule.onRoot(useUnmergedTree = true).printToLog(tag = "TAG")
}
}
/*
TAG : printToLog:
TAG : Printing with useUnmergedTree = 'true'
TAG : Node #1 at (l=0.0, t=136.0, r=293.0, b=268.0)px <--这个是根语义节点
TAG : |-Node #2 at (l=0.0, t=147.0, r=293.0, b=257.0)px
TAG : Focused = 'false'
TAG : Role = 'Button'
TAG : Actions = [OnClick, RequestFocus]
TAG : MergeDescendants = 'true'
TAG : |-Node #6 at (l=154.0, t=176.0, r=227.0, b=228.0)px
TAG : Text = '[Like]'
TAG : Actions = [GetTextLayoutResult]
* /
无妨碍服务中的 SemanticsModifier
关于 SemanticsModifier 的理论常识,咱们了解得现已足够多了,下面来看一些无妨碍服务中 SemanticsModifier 的常见用例。
1.最小接触方针尺度
关于无妨碍服务来说,首要考虑的是最小接触尺度,假如尺度太小,视障用户摸不着元素控件,那么语义信息再充足也没用。Material Design 无妨碍设计规范指出,能被用户点击或接触、与用户有互动行为的一切元素,最小尺度应该为 48 dp。

当 Checkbox、RadioButton、Switch、Slider 和 Surface 等 Material 组件被设置为可接收用户操作时,内部会自动帮咱们增加额定的内边距。
假如是其他组件,尺度设置为十分小而且设置了 clickable 修饰符,尽管内部不会帮咱们增加额定的内边距,但实践上它会把元素可点击规模扩大到元素边界外,所以为了避免和邻近的元素的可点击规模重叠,最好不要把可点击元素的大小设置成小于 48 dp。
@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)
)
}
}
由下图能够看到,尽管 Box 只要 1 px 大小,但它边界外的区域同样能够触发点击
假如想把一个可点击元素的大小设置成尽可能小,那么最好运用 sizeIn()
修饰符设置最小尺度:
Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp)
2.设置点击标签
假如有一个文字按钮如下,Text 便是这个按钮点击动作的详细描绘,由于语义兼并,TalkBack 发音时会读出“承认,按钮,点按两次即可激活”,对点击动作的描绘是精准的。
Button(onClick = { /* TODO */}) {
Text(text = "承认")
}

可假如有这么一个文章列表项(点击打开文章详情页),TalkBack 只会读出文章名+作者+“点按即可激活”。“点按即可激活”关于点击动作的描绘是模糊的,假如能自定义“激活”这个词就好了:“点按两次即可(____)”

其实只要设置点击标签 onClickLabel
就能够做到:
// 方法1:
Modifier.clickable(onClickLabel = "打开文章") { ... }
// 方法2:
Modifier.semantics {
onClick(label = "打开文章", action = { ... })
}

3.描绘视觉元素
运用 Image 或 Icon 组件时,Android 结构不知道组件的详细含义,需求咱们手动传递视觉元素的文字性说明,也便是 contentDescription
。当然假如视觉元素仅起到修饰效果,能够将 contentDescription
设置为 null。
IconButton(onClick = { /* TODO */}) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = stringResource(R.string.label_share)
)
}

4.增加自定义操作
仍然以上面的文章列表项为例,但这次在尾部增加一个“稍后再读”按钮:
@Composable
fun ArticleItem(...) {
Row {
...
IconButton(onClick = { /*TODO*/ }) {
Icon(imageVector = Icons.Default.BookmarkAdd, contentDescription = "增加到稍后再读")
}
}
}

这样写在咱们看来并没有任何问题,可是呢,关于一个视障人士来说,他可能用了 app 好几天,每次都恰恰好没摸中“稍后阅读”按钮…或许更绝的一种情况是,这个“稍后再读”的操作没有按钮,而是经过左滑、长按等操作触发,那视障人士怎样办呢?

这时候就得增加无妨碍自定义操作了,咱们能够这么写:
@Composable
fun ArticleItem(...) {
Row(
modifier = modifier
.clickable(onClickLabel = "打开文章") { /* TODO */ }
.semantics {
// 增加自定义无妨碍操作
customActions = listOf(
CustomAccessibilityAction("增加到稍后再读", { /* TODO */ })
)
}
...
) {
...
}
}
增加自定义无妨碍操作后,视障用户能够三指点击打开 TalkBack 菜单,进而挑选触发无妨碍操作。
5.设置语义人物,描绘元素状况
假定咱们有一个个人卡片组件,如下:
尽管重视按钮是一个 Button,但站在语义的视点看,它的人物更像一个 Switch,拥有“ 已重视”和“未重视”两种状况。十分像 Switch 组件对吧:

怎样让 TalkBack 像 Switch 组件一样为咱们的重视按钮发音呢?

咱们能够运用公共语义特点 role 和 stateDescription 为元素设置别离设置语义人物和状况描绘:
// 重视按钮
Button(
modifier = Modifier
...
.clearAndSetSemantics {
role = Role.Switch // 设置元素语义人物
stateDescription = if (following) "已重视" else "未重视" + username // 描绘元素状况
onClick(label = if (following) "撤销重视" else "重视") { ... }
}
) { /* content */ }

写到这里本文的篇幅现已够长了,咱们不行能经过一篇文章就把 Semantics Modifier 方方面面全部都讲完,究竟这是一个大论题,但关于它的一些中心常识,以及它在无妨碍服务中的根本运用方法,相信你一定现已掌握了。恭喜你,又学会了一项新技能,能够为 app 带来更友爱的无妨碍体验。
正如文章一开始所讲,国内大多数开发者对无妨碍服务与测验这两个论题并不感兴趣,假如你是少数派,能够参考下面的材料持续深化学习。
参考:
[Youtube] The Semantics of Jetpack Compose
写给初学者的Jetpack Compose教程,Modifier by 郭霖