通过无障碍控制 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 无障碍的原理》

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

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

相关文章

【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程序就会创建…

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 有助于满足高耗电应用的需求。 …

数据总线、位扩展、字长

数据总线&#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;不能靠代码…

使用GPT-soVITS再4060下2小时训练声音模型以及处理断句带来的声音模糊问题

B站UP主视频 感谢UP主“白菜工厂1145号员工”的“熟肉”&#xff0c;我这篇笔记就不展示整一个训练和推理流程&#xff0c;重点写的4060该注意的一些事项。如何解决断句模糊的问题&#xff0c;在本篇笔记的最末尾。 相关连接&#xff1a; 原项目github UP主的说明文档 1、训…

Linux的目录结构介绍和环境变量的设置

目录 前言一、系统环境二、Linux的目录结构2.1 Linux目录结构介绍2.2 Linux文件的路径描述2.2.1 绝对路径2.2.2 相对路径2.2.3 特殊的路径符 三、Linux的环境变量设置3.1 环境变量PATH3.2 关于$符的使用3.3 环境变量的设置 总结 前言 本篇文章介绍Linux的目录结构和环境变量的…

【云原生Kubernetes项目部署】k8s集群+高可用负载均衡层+防火墙

目录 环境准备 拓朴图 项目需求 一、Kubernetes 区域可采用 Kubeadm 方式进行安装 1.1所有节点master、node01、node02 1.2所有节点安装docker 1.3所有节点安装kubeadm&#xff0c;kubelet和kubectl 1.4部署K8S集群 1.4.1复制镜像和脚本到 node 节点&#xff0c;并在 …

html--酷炫背景引导主页

<!DOCTYPE html> <html lang"en"><head><meta charset"UTF-8"><title>ZZVIPS酷炫背景引导主页</title><meta name"viewport" content"widthdevice-width,initial-scale1,maximum-scale1,user-scala…

jquery.datetimepicker控件不弹出的问题

项目场景&#xff1a; CRM项目&#xff0c;在项目中涉及日期类输入框&#xff0c;打算采用平常见到的点击选择日期的方式。在浏览了网页后&#xff0c;目前比较好的解决方案是jquery.datetimepicker和flatpicker两种&#xff0c;flatpicker的缺点是官网是英文版的&#xff0c;…

Android 13.0 Launcher3单层模式workspace中app列表页排序功能实现

1.概述 在13.0的定制化开发中,对于Launcher3的功能定制也是好多的,而对于单层app列表页来说排序功能的开发,也是常有的功能这就需要了解加载app数据的流程,然后根据需要进行排序就可以了,接下来就来实现这个功能 如图: 2. Launcher3单层模式workspace中app列表页排序功能…

【漏洞复现】Apache OFBiz 路径遍历导致RCE漏洞(CVE-2024-36104)

0x01 产品简介 Apache OFBiz是一个电子商务平台&#xff0c;用于构建大中型企业级、跨平台、跨数据库、跨应用服务器的多层、分布式电子商务类应用系统。是美国阿帕奇(Apache)基金会的一套企业资源计划(ERP)系统。该系统提供了一整套基于Java的Web应用程序组件和工具。 0x02 …

How to: Add and Customize the Ribbon Skin List and Skin Gallery

皮肤列表和皮肤库允许用户选择皮肤。本文介绍如何在功能区中显示“皮肤列表”或“皮肤库”并对其进行自定义。 DevExpress演示中心中的大多数应用程序都允许您选择皮肤。例如&#xff0c;运行XtraGrid演示并导航到皮肤功能区页面以更改当前皮肤。 在功能区UI中显示皮肤列表或…

谁能赢?阿里的通义 VS 百度的文心

关注卢松松&#xff0c;会经常给你分享一些我的经验和观点。 国产AI大模型领域&#xff0c;当前有两大阵营&#xff1a; (1)以百度文心一言为代表的闭源大模型。李彦宏曾说过&#xff1a;AI大模型开源意义不大&#xff0c;百度绝不抢开发者饭碗。 (2)以阿里通义AI为代表的开…