通过无障碍控制 Compose 界面滚动的实战和原理剖析

2024-06-08 14:20

本文主要是介绍通过无障碍控制 Compose 界面滚动的实战和原理剖析,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

Compose-base-accessibility-action-compose.png

前言

针对 Compose UI 工具包,开发者不仅需要掌握如何使用新的 UI 组件达到 design 需求,更需要了解和实现与 UI 的交互逻辑。

比如 touch 事件、Accessibility 事件等等。

  • Compose 中对 touch 事件的处理和原理,笔者已经在《通过调用栈快速探究 Compose 中 touch 事件的处理原理》里进行了阐述
  • Compose 中对 Accessibility 事件的支持和基本原理,笔者已经在 《一文读懂 Compose 支持 Accessibility 无障碍的原理》 里进行了介绍

那么将两个话题相结合,不禁要好奇:利用 Accessibility 针对 Compose 界面模拟 touch 交互,是否真的有效,个中原理又如何?

本文将通过无障碍 DEMO 对 Google Compose 项目 Accompanist 中的 Horizontal Pager sample 模拟注入 Scroll 滚动事件,看下实际效果,并对原理链路进行剖析。

向 Compose 模拟滚动事件

无障碍 DEMO,本来想直接复用曾经红极一时的 AccessibilityTool 开源项目。奈何代码太老编译不过,遂直接写了个 DEMO 来捕捉 AccessibilityEvent 然后分析 AccessibilityNodeInfo

当发现是节点属于 Accompanist 的包名(com.google.accompanist.sample),且可滚动 scrollable 的话,通过无障碍模拟注入 ACTION_SCROLL_FORWARD 的 action。

 public class MyAccessibilityService extends AccessibilityService {...@Overridepublic void onAccessibilityEvent(AccessibilityEvent event) {Log.i(TAG, "onAccessibilityEvent() event: " + event);​AccessibilityNodeInfo root;ArrayList<AccessibilityNodeInfo> roots = new ArrayList<>();ArrayList<AccessibilityNodeInfo> nodeList = new ArrayList<>();try {switch (event.getEventType()) {case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:Log.i(TAG, "TYPE_WINDOW_STATE_CHANGED()");roots.add(service.getRootInActiveWindow());findAllNode(roots, nodeList);printComposeNode(nodeList);​roots.clear();nodeList.clear();break;...}} catch (Throwable e) {e.printStackTrace();}}private void printComposeNode(ArrayList<AccessibilityNodeInfo> root) {for (AccessibilityNodeInfo node : root) {if (node.getPackageName().equals("com.google.accompanist.sample")&& node.getClassName().equals("android.view.View")) {node.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);}}}...}

《一文读懂 Compose 支持 Accessibility 无障碍的原理》 里我们介绍过,Compose 通过无障碍代理 AccessibilityDelegate 依据 UI 组件的类型、情况,进行 AccessibilityNodeInfo 实例的构造。

为了兼容传统 View 的内容,会针对实例里的 className 属性进行一定程度的了改写,但范围有限。

LazyColumn 这种的组件,并没有和传统的可滚动的 ListViewScrollViewRecylerView 的名称进行转换,用的仍然是默认的 View 名称。

所以咱们的无障碍 DEMO 不能像以前那样在判断 isScrollable 之外再额外判断 ListView 等传统可滚动 View 的名称了。

话不多说,我们将无障碍 DEMO 在系统的无障碍设置中启用,选择 “allow” 即可。

ddd

然后运行下 Accompanist 的 Horizontal Pager 界面,打印下收集到的 AccessibilityNodeInfo 信息。

android.view.accessibility.AccessibilityNodeInfo@1cfed; ... 
packageName: com.google.accompanist.sample; className: android.view.View; ... 
enabled: true; ... scrollable: true; ...  
actions: [AccessibilityAction: ... AccessibilityAction: ACTION_SCROLL_FORWARD - null]...

可以看到:

  • className 果然是 android.view.View
  • scrollable 是 true
  • 支持的 AccessibilityAction 有 ACTION_SCROLL_FORWARD 等

模拟滚动的效果如下,可以看到一打开 Horizontal Pager 的界面,就自动往右进行了翻页。

ddd

Compose 支持模拟滚动的原理

滚动界面 Horizontal Pager

想了解 Compose 支持通过无障碍模拟滚动的原理,首先需要了解一下 Horizontal Pager 界面的布局和物理手势上触发滚动的一些背景知识。

ddd

该布局主要采用 TopAppBar 展示 Title 栏,内容区域由 Column 组件堆叠。其中:

  • ScrollableTabRow 负责可以横向滚动的 Tab 栏的内容展示
  • HorizontalPager 负责各 Tab 对应内容的展示,会依据 page index 展示对应的 Text 文本,还需要监听 scroll 手势进行横向滚动

ScrollableTabRow 还需要监听 Tab 的点击事件进行 PagerState 的滚动,采用 animateScrollToPage() 进行。

     class HorizontalPagerTabsSample : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {...setContent {AccompanistSampleTheme {Surface {Sample()}}}}}@Composableprivate fun Sample() {Scaffold(topBar = {TopAppBar(title = { Text(stringResource(R.string.horiz_pager_title_tabs)) },backgroundColor = MaterialTheme.colors.surface,)},modifier = Modifier.fillMaxSize()) { padding ->val pages = remember {listOf("Home", "Shows", "Movies", "Books", "Really long movies", "Short audiobooks")}Column(Modifier.fillMaxSize().padding(padding)) {...ScrollableTabRow(selectedTabIndex = pagerState.currentPage,...) {pages.forEachIndexed { index, title ->Tab(...onClick = {coroutineScope.launch {pagerState.animateScrollToPage(index)}})}}HorizontalPager(...) { page ->Card {Box(Modifier.fillMaxSize()) {Text(text = "Page: ${pages[page]}",...)}}}}}}

animateScrollToPage() 的实现如下,主要是依据 page 计算滚动的 index 和 scrollOffset。然后调用通用的 LazyListState 的 animateScrollToItem() 执行 smooth 的滚动操作。

         public suspend fun animateScrollToPage(@IntRange(from = 0) page: Int,@FloatRange(from = -1.0, to = 1.0) pageOffset: Float = 0f,) {requireCurrentPage(page, "page")requireCurrentPageOffset(pageOffset, "pageOffset")try {...if (pageOffset.absoluteValue <= 0.005f) {lazyListState.animateScrollToItem(index = page)} else {lazyListState.scroll { }...if (target != null) {lazyListState.animateScrollToItem(index = page,scrollOffset = ((target.size + itemSpacing) * pageOffset).roundToInt())} else if (layoutInfo.visibleItemsInfo.isNotEmpty()) {...}}} finally {onScrollFinished()}}

animateScrollToItem() 由 LazyLayoutAnimateScrollScope 完成。

首先需要通过 LazyListState 的 scroll() 挂起函数请求准备执行 scroll 处理,获得调度之后通过 lambda 回调最重要的步骤:ScrollScopescrollBy()

     internal suspend fun LazyLayoutAnimateScrollScope.animateScrollToItem(...) {scroll {try {...while (loop && itemCount > 0) {...anim.animateTo(target,sequentialAnimation = (anim.velocity != 0f)) {if (!isItemVisible(index)) {// Springs can overshoot their target, clamp to the desired rangeval coercedValue = if (target > 0) {value.coerceAtMost(target)} else {value.coerceAtLeast(target)}val delta = coercedValue - prevValueval consumed = scrollBy(delta)...}if (isOvershot()) {snapToItem(index = index, scrollOffset = scrollOffset)loop = falsecancelAnimation()return@animateTo} ...}​loops++}} catch (itemFound: ItemFoundInScroll) {...}}}

在内容区域手动滚动触发 scroll 的入口和点击 Tab 不同,来自 scroll gesture,但后续都是调用 ScrollScopescrollBy() 完成。

详细链路不再赘述,感兴趣的同学可以 debug 跟一下。

     private class ScrollDraggableState(var scrollLogic: ScrollingLogic) : DraggableState, DragScope {var latestScrollScope: ScrollScope = NoOpScrollScope...override suspend fun drag(dragPriority: MutatePriority, block: suspend DragScope.() -> Unit) {scrollLogic.scrollableState.scroll(dragPriority) {latestScrollScope = thisblock()}}...}

收集滚动的无障碍语义

Compose 界面所需的 Accessibility 信息,都是通过 Semantics 语义机制来收集的,包括:AccessibilityEvent、AccessibilityNodeInfo 和 AccessibilityAction 信息。

Horizontal Pager 界面里负责主体内容展示的 HorizontalPager 组件,本质上是扩展 LazyRow 而来的,而 LazyRow 和 LazyColumn 一样最终经由 LazyList 抵达 LazyLayout 组件。

     internal fun LazyList(...) {...LazyLayout(modifier = modifier.then(state.remeasurementModifier).then(state.awaitLayoutModifier)// 收集语义.lazyLayoutSemantics(itemProviderLambda = itemProviderLambda,state = semanticState,orientation = orientation,userScrollEnabled = userScrollEnabled,reverseScrolling = reverseLayout).clipScrollableContainer(orientation).lazyListBeyondBoundsModifier(state,beyondBoundsItemCount,reverseLayout,orientation).overscroll(overscrollEffect)......)}

LazyLayout 初始化的时候会调用 lazyLayoutSemantics() 收集语义。

     internal fun Modifier.lazyLayoutSemantics(...): Modifier {val coroutineScope = rememberCoroutineScope()return this.then(remember(itemProviderLambda,state,orientation,userScrollEnabled) {val isVertical = orientation == Orientation.Vertical...val scrollByAction: ((x: Float, y: Float) -> Boolean)? = if (userScrollEnabled) {{ x, y ->...coroutineScope.launch {state.animateScrollBy(delta)}true}} else {null}...​Modifier.semantics {...if (scrollByAction != null) {scrollBy(action = scrollByAction)}...}})}fun SemanticsPropertyReceiver.scrollBy(label: String? = null,action: ((x: Float, y: Float) -> Boolean)?) {this[SemanticsActions.ScrollBy] = AccessibilityAction(label, action)}

lazyLayoutSemantics() 会定义一个 scrollByAction 名称的 AccessibilityAction 实例,然后以 ScrollBy 为 key 存放到语义 map 中等待 Accessibility 机制查找和回调。

无障碍回调滚动 action

当其他 App 通过 AccessibilityNodeInfo 执行了 Action 之后,通过 AIDL 最终会进入目标 App 的 performActionHelper()

我们以 ACTION_SCROLL_FORWARD 为例,关注下处理逻辑。

     internal class AndroidComposeViewAccessibilityDelegateCompat ... {...    private fun performActionHelper(...): Boolean {val node = currentSemanticsNodes[virtualViewId]?.semanticsNode ?: return false...when (action) {...AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD,AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD,android.R.id.accessibilityActionScrollDown,android.R.id.accessibilityActionScrollUp,android.R.id.accessibilityActionScrollRight,android.R.id.accessibilityActionScrollLeft -> {// Introduce a few shorthands:val scrollForward = action == AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARDval scrollBackward = action == AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD...val scrollHorizontal = scrollLeft || scrollRight || scrollForward || scrollBackwardval scrollVertical = scrollUp || scrollDown || scrollForward || scrollBackward...val scrollAction =node.unmergedConfig.getOrNull(SemanticsActions.ScrollBy) ?: return falseval xScrollState =node.unmergedConfig.getOrNull(SemanticsProperties.HorizontalScrollAxisRange)if (xScrollState != null && scrollHorizontal) {var amountToScroll = viewport.widthif (scrollLeft || scrollBackward) {amountToScroll = -amountToScroll}if (xScrollState.reverseScrolling) {amountToScroll = -amountToScroll}if (node.isRtl && (scrollLeft || scrollRight)) {amountToScroll = -amountToScroll}if (xScrollState.canScroll(amountToScroll)) {return scrollAction.action?.invoke(amountToScroll, 0f) ?: false}}val yScrollState =node.unmergedConfig.getOrNull(SemanticsProperties.VerticalScrollAxisRange)if (yScrollState != null && scrollVertical) {...if (yScrollState.canScroll(amountToScroll)) {return scrollAction.action?.invoke(0f, amountToScroll) ?: false}}return false}...}}
  1. 当 Action 类型为 ACTION_SCROLL_FORWARD 的时候,赋值 scrollForward 变量
  2. 从 node 里获取是否支持 x 轴滚动:xScrollState
  3. 两者皆 OK 的话,从语义 map 里以 ScrollBy 为 key 查到的 AccessibilityAction 实例并回调

该 Action 即回到了语义收集时注入的 lambda:

     coroutineScope.launch {state.animateScrollBy(delta)}

State 的实现为 LazyLayoutSemanticState

     internal fun LazyLayoutSemanticState(state: LazyListState,isVertical: Boolean): LazyLayoutSemanticState = object : LazyLayoutSemanticState {...override suspend fun animateScrollBy(delta: Float) {state.animateScrollBy(delta)}...}

其 animateScrollBy() 实际通过 LazyListState 的 animateScrollBy() 进行,其最终调用 ScrollScopescrollBy()

虽然入口稍稍不同,但最后的逻辑便和物理上手动点击 Tab 或者横向 scroll 一样,完成滚动操作,殊途同归。

     suspend fun ScrollableState.animateScrollBy(value: Float,animationSpec: AnimationSpec<Float> = spring()): Float {var previousValue = 0fscroll {animate(0f, value, animationSpec = animationSpec) { currentValue, _ ->previousValue += scrollBy(currentValue - previousValue)}}return previousValue}

结语

compose_accessibility_scroll.drawio.png

《一文读懂 Compose 支持 Accessibility 无障碍的原理》 里已经介绍过 Compose 和 Accessibility 交互的大体原理,这里只将重点的 scroll 差异体现出来。

  1. Compose 启动的时候根据可滚动组件收集对应语义,以 ScrollBy key 存到整体的 SemanticsConfiguration
  2. 接着在 Accessibility 激活需要准备 Accessibility 信息的时候,将数据提取到 AccessibilityNode 里发送出去
  3. AccessibilityService 发送了 scroll Action 的时候,经由 AccessibilityDelegate 从 SemanticsConfiguration 里查找到对应的 AccessibilityAction 并执行
  4. scrool 的执行由 ScrollScopescrollBy() 完成,这和物理上执行滚动操作是一样的逻辑。

看了上述的 Compose 原理剖析之后,读者或许能感受到:除了开发者需要留意 UI 以外的交互细节,Compose 实现者更需要考虑如何将 UI 的各方各面和原生的 Android View 进行兼容。

不仅仅包括本文提到的 touch、accessibility,还包括大家不常关注到的相关开发细节。比如:

  • 如何 AndroidView 兼容?
  • 如何嵌套的 AndroidView?
  • 如何支持的 UIAutomator 自动化?
  • 如何支持的 Layout Inspector dump?
  • 如何支持的 Android 视图的性能检查?
  • 如何支持的 AndroidTest 机制?
  • 等等

待 Compose 愈加成熟,对于这些相关的开发能力的支持也会更加完善,后期笔者仍会针对其他部分进行持续的分析和介绍。

推荐阅读

  • 《通过调用栈快速探究 Compose 中 touch 事件的处理原理》
  • 《一文读懂 Compose 支持 Accessibility 无障碍的原理》

这篇关于通过无障碍控制 Compose 界面滚动的实战和原理剖析的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1042434

相关文章

Python中构建终端应用界面利器Blessed模块的使用

《Python中构建终端应用界面利器Blessed模块的使用》Blessed库作为一个轻量级且功能强大的解决方案,开始在开发者中赢得口碑,今天,我们就一起来探索一下它是如何让终端UI开发变得轻松而高... 目录一、安装与配置:简单、快速、无障碍二、基本功能:从彩色文本到动态交互1. 显示基本内容2. 创建链

Golang使用minio替代文件系统的实战教程

《Golang使用minio替代文件系统的实战教程》本文讨论项目开发中直接文件系统的限制或不足,接着介绍Minio对象存储的优势,同时给出Golang的实际示例代码,包括初始化客户端、读取minio对... 目录文件系统 vs Minio文件系统不足:对象存储:miniogolang连接Minio配置Min

Redis主从复制实现原理分析

《Redis主从复制实现原理分析》Redis主从复制通过Sync和CommandPropagate阶段实现数据同步,2.8版本后引入Psync指令,根据复制偏移量进行全量或部分同步,优化了数据传输效率... 目录Redis主DodMIK从复制实现原理实现原理Psync: 2.8版本后总结Redis主从复制实

Node.js 中 http 模块的深度剖析与实战应用小结

《Node.js中http模块的深度剖析与实战应用小结》本文详细介绍了Node.js中的http模块,从创建HTTP服务器、处理请求与响应,到获取请求参数,每个环节都通过代码示例进行解析,旨在帮... 目录Node.js 中 http 模块的深度剖析与实战应用一、引言二、创建 HTTP 服务器:基石搭建(一

Python实现局域网远程控制电脑

《Python实现局域网远程控制电脑》这篇文章主要为大家详细介绍了如何利用Python编写一个工具,可以实现远程控制局域网电脑关机,重启,注销等功能,感兴趣的小伙伴可以参考一下... 目录1.简介2. 运行效果3. 1.0版本相关源码服务端server.py客户端client.py4. 2.0版本相关源码1

网页解析 lxml 库--实战

lxml库使用流程 lxml 是 Python 的第三方解析库,完全使用 Python 语言编写,它对 XPath表达式提供了良好的支 持,因此能够了高效地解析 HTML/XML 文档。本节讲解如何通过 lxml 库解析 HTML 文档。 pip install lxml lxm| 库提供了一个 etree 模块,该模块专门用来解析 HTML/XML 文档,下面来介绍一下 lxml 库

Spring Security 基于表达式的权限控制

前言 spring security 3.0已经可以使用spring el表达式来控制授权,允许在表达式中使用复杂的布尔逻辑来控制访问的权限。 常见的表达式 Spring Security可用表达式对象的基类是SecurityExpressionRoot。 表达式描述hasRole([role])用户拥有制定的角色时返回true (Spring security默认会带有ROLE_前缀),去

性能分析之MySQL索引实战案例

文章目录 一、前言二、准备三、MySQL索引优化四、MySQL 索引知识回顾五、总结 一、前言 在上一讲性能工具之 JProfiler 简单登录案例分析实战中已经发现SQL没有建立索引问题,本文将一起从代码层去分析为什么没有建立索引? 开源ERP项目地址:https://gitee.com/jishenghua/JSH_ERP 二、准备 打开IDEA找到登录请求资源路径位置

深入探索协同过滤:从原理到推荐模块案例

文章目录 前言一、协同过滤1. 基于用户的协同过滤(UserCF)2. 基于物品的协同过滤(ItemCF)3. 相似度计算方法 二、相似度计算方法1. 欧氏距离2. 皮尔逊相关系数3. 杰卡德相似系数4. 余弦相似度 三、推荐模块案例1.基于文章的协同过滤推荐功能2.基于用户的协同过滤推荐功能 前言     在信息过载的时代,推荐系统成为连接用户与内容的桥梁。本文聚焦于

hdu4407(容斥原理)

题意:给一串数字1,2,......n,两个操作:1、修改第k个数字,2、查询区间[l,r]中与n互质的数之和。 解题思路:咱一看,像线段树,但是如果用线段树做,那么每个区间一定要记录所有的素因子,这样会超内存。然后我就做不来了。后来看了题解,原来是用容斥原理来做的。还记得这道题目吗?求区间[1,r]中与p互质的数的个数,如果不会的话就先去做那题吧。现在这题是求区间[l,r]中与n互质的数的和