前言
针对 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” 即可。
然后运行下 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 的界面,就自动往右进行了翻页。
Compose 支持模拟滚动的原理
滚动界面 Horizontal Pager
想了解 Compose 支持通过无障碍模拟滚动的原理,首先需要了解一下 Horizontal Pager 界面的布局和物理手势上触发滚动的一些背景知识。
该布局主要采用 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 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}...}}
- 当 Action 类型为 ACTION_SCROLL_FORWARD 的时候,赋值 scrollForward 变量
- 从 node 里获取是否支持 x 轴滚动:xScrollState
- 两者皆 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 无障碍的原理》 里已经介绍过 Compose 和 Accessibility 交互的大体原理,这里只将重点的 scroll 差异体现出来。
- Compose 启动的时候根据可滚动组件收集对应语义,以 ScrollBy key 存到整体的
SemanticsConfiguration
中 - 接着在 Accessibility 激活需要准备 Accessibility 信息的时候,将数据提取到
AccessibilityNode
里发送出去 - 当
AccessibilityService
发送了 scroll Action 的时候,经由AccessibilityDelegate
从 SemanticsConfiguration 里查找到对应的AccessibilityAction
并执行 - scrool 的执行由
ScrollScope
的scrollBy()
完成,这和物理上执行滚动操作是一样的逻辑。
看了上述的 Compose 原理剖析之后,读者或许能感受到:除了开发者需要留意 UI 以外的交互细节,Compose 实现者更需要考虑如何将 UI 的各方各面和原生的 Android View 进行兼容。
不仅仅包括本文提到的 touch、accessibility,还包括大家不常关注到的相关开发细节。比如:
- 如何 AndroidView 兼容?
- 如何嵌套的 AndroidView?
- 如何支持的 UIAutomator 自动化?
- 如何支持的 Layout Inspector dump?
- 如何支持的 Android 视图的性能检查?
- 如何支持的 AndroidTest 机制?
- 等等
待 Compose 愈加成熟,对于这些相关的开发能力的支持也会更加完善,后期笔者仍会针对其他部分进行持续的分析和介绍。
推荐阅读
- 《通过调用栈快速探究 Compose 中 touch 事件的处理原理》
- 《一文读懂 Compose 支持 Accessibility 无障碍的原理》