通过无障碍控制 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 这种的组件,并没有和传统的可滚动的 ListView、ScrollView、RecylerView 的名称进行转换,用的仍然是默认的 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 回调最重要的步骤:ScrollScope 的 scrollBy()。

     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,但后续都是调用 ScrollScope 的 scrollBy() 完成。

详细链路不再赘述,感兴趣的同学可以 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 false​val 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() 进行,其最终调用 ScrollScope 的 scrollBy()。

虽然入口稍稍不同,但最后的逻辑便和物理上手动点击 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 的执行由 ScrollScope 的 scrollBy() 完成,这和物理上执行滚动操作是一样的逻辑。

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

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

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

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

推荐阅读

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.tpcf.cn/bicheng/25004.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

状态设计模式完成商品的创建状态之间的流转

/** * 产品状态&#xff1a;1编辑、2提审、3撤审、4通过、5运行(审核通过后worker扫描状态)、6拒绝、7关闭、8开启 */ public enum ActivityState { /** 1&#xff1a;编辑 */EDIT(1, "编辑"),/** 2&#xff1a;提审 */ARRAIGNMENT(2, "提审"),/** 3&#…

[office] excel2003横向求和的方法步骤图 #媒体#笔记#经验分享

excel2003横向求和的方法步骤图 Excel中经常需要横向求和&#xff0c;横向求和具体该如何操作呢?下面是小编带来的关于excel2003横向求和的方法&#xff0c;希望阅读过后对你有所启发! excel2003横向求和的方法 横向求和步骤1&#xff1a;下面这个表格就是我们需要求和的示例表…

应对差旅报销的“三多一低”,科世达、众瑞智能、高仙机器人为何都选择分贝通?

制造企业是典型的复杂体,从研发、采购、生产、销售到售后等盘根错节。在业务复杂度、流程规范化等持续进阶的同时,报销单据量更是月月破万,可企业在差旅报销的管理依然保持“传统风格”。 从申请、审批、预订到报销,环节复杂冗长,这也逐渐形成了制造业特有的支出管理难题———…

【Linux】进程(9):进程控制1

大家好&#xff0c;我是苏貝&#xff0c;本篇博客带大家了解Linux进程&#xff08;9&#xff09;进程控制1&#xff0c;如果你觉得我写的还不错的话&#xff0c;可以给我一个赞&#x1f44d;吗&#xff0c;感谢❤️ 目录 1 fork函数2 进程终止&#xff08;A&#xff09;终止是…

java中异常-异常概述+异常体系结构

一、异常概述 1、什么是异常&#xff1f; java程序在运行时出现的不正常情况 2、java中提供的默认的异常处理机制 java中对java程序运行时可能会出现的每种不正常情况都创建了一个唯一对应的类&#xff0c;在java程序运行时如果出现不正常情况&#xff0c;java程序就会创建…

[minepy][python]minepy所有whl文件下载地址汇总

minepy是一个基于最大信息系数&#xff08;MIC和MINE族&#xff09;的Python库&#xff0c;它提供了对非参数勘探的有力支持。以下是关于minepy的详细介绍&#xff1a; 主要功能与特点&#xff1a; minepy提供了APPROX-MIC、MIC_e、总信息系数&#xff08;TIC&#xff09;和广义…

Java | Leetcode Java题解之第140题单词拆分II

题目&#xff1a; 题解&#xff1a; class Solution {public List<String> wordBreak(String s, List<String> wordDict) {Map<Integer, List<List<String>>> map new HashMap<Integer, List<List<String>>>();List<List…

USB Type-C 和 USB供电数据和电源角色

USB Type-C 连接器生态系统随着现代平台和设备需求的变化而不断发展。 USB Type-C 连接器生态系统可满足现代平台和设备不断变化的需求&#xff0c;并且符合更小、更薄且更轻便的外形设计趋势。此外&#xff0c;针对 Type-C 连接器修改 USB PD 有助于满足高耗电应用的需求。 …

自己做的精灵图制作,图片合成,卓宠,窗口置顶,磁力链下载等工具软件

欢迎使用和提bug&#xff0c;才v1.0.2&#xff0c;有新奇的自己需要的功能可以提给我&#xff0c;我看看能不能做。 网站地址 github

Qt对二进制文件进行加密及解密操作

在工作中可能会做一些二进制文件加密及解密的任务&#xff0c;比如说仪器的时序指令bin文件。 #include <iostream> #include <fstream> #include <vector> #include <QCryptographicHash> #include <QFile> #include <QDataStream> #inc…

Next.js初始化和一些配置

Next.js初始化和一些配置 Next.js初始化和一些配置初始化配置路径别名跨域问题图片跨域问题 Next.js初始化和一些配置 我的版本是14.2.3 初始化 配置 路径别名 跨域问题 改为服务端组件 把发起跨域请求的组件顶层的use client删掉使用后端接口转发 在Next.js自定义一个接…

数据总线、位扩展、字长

数据总线&#xff08;Data Bus&#xff09; 定义 数据总线是计算机系统中的一组并行信号线&#xff0c;用于在计算机内部传输数据。这些数据可以在中央处理器&#xff08;CPU&#xff09;、内存和输入/输出设备之间传输。 作用 数据传输&#xff1a;数据总线负责在计算机各…

c++【入门】求圆环的面积

限制 时间限制 : 1 秒 内存限制 : 128 MB 题目 如下图所示的圆环铁片&#xff0c;中间是空心的&#xff0c;已知圆环外圆的半径是r1厘米&#xff08;如&#xff1a;10cm&#xff09;&#xff0c;内圆半径是r2厘米&#xff08;如&#xff1a;6cm&#xff09;&#xff0c;请编…

心链13---主页切换功能 + loading特效 + 导航栏完善 + 队伍页接口修改

心链 — 伙伴匹配系统 直接取出所有用户&#xff0c;依次和当前用户计算分数&#xff0c;取 TOP N&#xff08;54 秒&#xff09; 优化方法&#xff1a; 切忌不要在数据量大的时候循环输出日志&#xff08;取消掉日志后 20 秒&#xff09;Map 存了所有的分数信息&#xff0c;占…

C++ | Leetcode C++题解之第140题单词拆分II

题目&#xff1a; 题解&#xff1a; class Solution { private:unordered_map<int, vector<string>> ans;unordered_set<string> wordSet;public:vector<string> wordBreak(string s, vector<string>& wordDict) {wordSet unordered_set(w…

SpringBoot+Vue网上购物商城系统(前后端分离)

技术栈 JavaSpringBootMavenMySQLMyBatisVueShiroElement-UI 系统角色对应功能 用户商家管理员 系统功能截图

docker部署redis实践

1.拉取redis镜像 # 拉取镜像 sudo docker pull redis2.创建映射持久化目录 # 创建目录 sudo mkdir -p $PWD/redis/{conf,data}3. 运行redis 容器&#xff0c;查看当前redis 版本号 # 运行 sudo docker run --name redis -d -p 6379:6379 redis # 查看版本号 sudo docker ex…

java异常处理知识点总结

一.前提知识 首先当运行出错的时候&#xff0c;有两种情况&#xff0c;一种叫做“错误”&#xff0c;另一种叫做“异常”。错误指的是运行过程中遇到了硬件或操作系统出错&#xff0c;这种情况程序员是没办法处理的&#xff0c;因为这是硬件和系统的问题&#xff0c;不能靠代码…

React Native中集成ArcGIS以显示地图、渲染自定义图层和获取地理信息数据

在您的数据采集上传的应用中集成ArcGIS以显示地图、渲染自定义图层和获取地理信息数据是一项常见需求。下面是如何实现这些功能的详细指南&#xff0c;包括具体步骤和示例代码。 1. 显示地图 原生开发 Android&#xff1a; 使用ArcGIS Android SDK。您需要在AndroidManifest…

tile-join的详细解释和使用说明

tile-join是Tippecanoe工具集中的一个实用命令&#xff0c;主要用于合并多个MBTiles文件&#xff0c;或者将属性数据添加到已有的MBTiles文件中。它可以处理大量的GeoJSON、Shapefile和MBTiles数据&#xff0c;并将它们合并成一个新的MBTiles文件。这对于需要结合多个数据源或者…