SemanticsModifier

SemanticsModifier 有什么用?

Semantics [s’mntks]: 语义学。

SemanticsModifier,语义修饰符… 什么玩意?老实说,关于国内大多数 Android 开发者,一开始都比较难了解语义修饰符,它能够为元素供给一些额定的信息,首要用于无妨碍服务 Accessibility 和测验 Testing,而这两项技能在国内并不怎样受待见。

Compose 会在组合阶段履行 Composable 函数生成一颗 UI 树,用来描绘界面。

Jetpack Compose Semantics Modifier

实践上除了这颗 UI 树,Compose 中还有别的一颗结构类似的树,叫做语义树,每个节点都是一个 SemanticsNode。语义树彻底不参与制作和渲染工作,因此是彻底不行见的,它只为 Accessibility 和 Testing 服务。

  • Accessibility 需求依据语义树的节点内容进行发音:

    Android 系统中有一个无妨碍服务功用叫 TalkBack,它能够协助视力妨碍人士经过接触 + 语音反应的方法来与手机进行交互。敞开 TalkBack 后,手机会读出用户接触到的界面元素信息,这些信息便是来自语义树,用户能够依照语音提示进一步操作。

    Jetpack Compose Semantics Modifier
  • Testing 需求依据语义树找到想要测验的节点来履行测验逻辑:

    将测验和无妨碍服务放在一起评论很简单让人感到奇怪,直觉上咱们会认为它俩毫无关系。不过实践上它们是存在一些共同点的:

    1. 需求找到某个控件元素,在 Compose 中界面元素是没有 id 的,因此需求经过语义树来定位
    2. 需求对控件元素进行操作,比方点击、滑动等等;
    3. 需求访问控件元素的 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 组件的语义
    )
}
Jetpack Compose Semantics Modifier

semantics() 修饰符 & 语义特点

而当咱们运用初级 API 来制作界面时,就需求运用 semantics() 修饰符来为组件供给相应的语义了:

Box(
    Modifier
    .background(Blue)
    .size(150.dp)
    .semantics { contentDescription = "a blue box" }
)
Jetpack Compose Semantics Modifier

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() 办法而已。

Jetpack Compose Semantics Modifier

兼并和未兼并的语义树

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 修饰符,内部会默许进行语义特点兼并。

再来看一个比如, 下面有一个可点击的文章列表项。用户能够点击整行打开文章详情页,也能够点击右侧按钮收藏文章。

Jetpack Compose Semantics Modifier

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

Jetpack Compose Semantics Modifier

语义特点兼并规则

还有一个问题,前面现已看过 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 树的基础上,剔除了那些没有设置语义特点的元素后,生成的未兼并的语义树;第二颗则是经过兼并的语义树。

Jetpack Compose Semantics Modifier


了解了 unmerged semantics treemerged semantics tree 后,咱们趁便看看 clearAndSetSemantics() 修饰符,与 semantics() 修饰符不同,它会在设置语义信息之前,先铲除一切子节点的语义信息(不会铲除布局节点自身的语义信息)。详细来说,关于兼并语义树,会将一切子节点供给的语义信息擦除;关于未兼并语义树,会将一切子节点打上标记 [SemanticsConfiguration.isClearingSemantics],并不会实践擦除语义信息。

检查树

要查看语义树中各个节点的语义信息,有两种方法:

1.运用 Android Studio 的布局检查器 Layout Inspector,直接查看每个元素的语义信息

Jetpack Compose Semantics Modifier

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。

Jetpack Compose Semantics Modifier

CheckboxRadioButtonSwitchSliderSurface 等 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 = "承认")
}
Jetpack Compose Semantics Modifier

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

Jetpack Compose Semantics Modifier

其实只要设置点击标签 onClickLabel 就能够做到:

// 方法1:
Modifier.clickable(onClickLabel = "打开文章") { ... }
// 方法2:
Modifier.semantics {
    onClick(label = "打开文章", action = { ... })
}
Jetpack Compose Semantics Modifier

3.描绘视觉元素

运用 Image 或 Icon 组件时,Android 结构不知道组件的详细含义,需求咱们手动传递视觉元素的文字性说明,也便是 contentDescription。当然假如视觉元素仅起到修饰效果,能够将 contentDescription 设置为 null。

IconButton(onClick = { /* TODO */}) {
    Icon(
        imageVector = Icons.Filled.Share,
        contentDescription = stringResource(R.string.label_share)
    )
}
Jetpack Compose Semantics Modifier

4.增加自定义操作

仍然以上面的文章列表项为例,但这次在尾部增加一个“稍后再读”按钮:

@Composable
fun ArticleItem(...) {
    Row {
        ...
        IconButton(onClick = { /*TODO*/ }) {
            Icon(imageVector = Icons.Default.BookmarkAdd, contentDescription = "增加到稍后再读")
        }
    }
}
Jetpack Compose Semantics Modifier

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

Jetpack Compose Semantics Modifier

这时候就得增加无妨碍自定义操作了,咱们能够这么写:

@Composable
fun ArticleItem(...) {
    Row(
        modifier = modifier
        	.clickable(onClickLabel = "打开文章") { /* TODO */ }
            .semantics {
                // 增加自定义无妨碍操作
                customActions = listOf(
                    CustomAccessibilityAction("增加到稍后再读", { /* TODO */ })
                )
            }
            ...
       ) {
        ...
    }
}

增加自定义无妨碍操作后,视障用户能够三指点击打开 TalkBack 菜单,进而挑选触发无妨碍操作。

Jetpack Compose Semantics Modifier

5.设置语义人物,描绘元素状况

假定咱们有一个个人卡片组件,如下:

尽管重视按钮是一个 Button,但站在语义的视点看,它的人物更像一个 Switch,拥有“ 已重视”和“未重视”两种状况。十分像 Switch 组件对吧:

Jetpack Compose Semantics Modifier

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

Jetpack Compose Semantics Modifier

咱们能够运用公共语义特点 rolestateDescription 为元素设置别离设置语义人物和状况描绘:

// 重视按钮
Button(
    modifier = Modifier
    ...
    .clearAndSetSemantics {
        role = Role.Switch //  设置元素语义人物
        stateDescription = if (following) "已重视" else "未重视" + username //  描绘元素状况
        onClick(label = if (following) "撤销重视" else "重视") { ... }
    }
) { /* content */ }
Jetpack Compose Semantics Modifier

写到这里本文的篇幅现已够长了,咱们不行能经过一篇文章就把 Semantics Modifier 方方面面全部都讲完,究竟这是一个大论题,但关于它的一些中心常识,以及它在无妨碍服务中的根本运用方法,相信你一定现已掌握了。恭喜你,又学会了一项新技能,能够为 app 带来更友爱的无妨碍体验。

正如文章一开始所讲,国内大多数开发者对无妨碍服务与测验这两个论题并不感兴趣,假如你是少数派,能够参考下面的材料持续深化学习。


参考:

[官方文档] Compose 中的语义

[Youtube] The Semantics of Jetpack Compose

写给初学者的Jetpack Compose教程,Modifier by 郭霖

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。