Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support accessibility scroll #1169

Merged
merged 3 commits into from
Mar 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,16 @@ NS_ASSUME_NONNULL_BEGIN

- (BOOL)accessibilityActivate CMP_MUST_BE_OVERRIDED;

// Private SDK method. Calls when the item is swipe-to-focused in VoiceOver.
- (BOOL)accessibilityScrollToVisible;

// Private SDK method. Calls when the item is swipe-to-focused in VoiceOver.
- (BOOL)accessibilityScrollToVisibleWithChild:(id)child;

- (void)accessibilityElementDidBecomeFocused;

- (void)accessibilityElementDidLoseFocus;

- (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction CMP_MUST_BE_OVERRIDED;

- (BOOL)accessibilityPerformEscape;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,22 @@ - (BOOL)accessibilityPerformEscape {
return [super accessibilityPerformEscape];
}

- (BOOL)accessibilityScrollToVisible {
CMP_MUST_BE_OVERRIDED_INVARIANT_VIOLATION
}

- (BOOL)accessibilityScrollToVisibleWithChild:(id)child {
CMP_MUST_BE_OVERRIDED_INVARIANT_VIOLATION
}

- (void)accessibilityElementDidBecomeFocused {
[super accessibilityElementDidBecomeFocused];
}

- (void)accessibilityElementDidLoseFocus {
[super accessibilityElementDidLoseFocus];
}

@end

NS_ASSUME_NONNULL_END
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package androidx.compose.ui.platform

import androidx.compose.runtime.ExperimentalComposeApi
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.SemanticsActions
Expand All @@ -37,6 +38,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import platform.CoreGraphics.CGRect
import platform.CoreGraphics.CGRectMake
Expand All @@ -47,6 +49,7 @@ import platform.UIKit.UIAccessibilityCustomAction
import platform.UIKit.UIAccessibilityFocusedElement
import platform.UIKit.UIAccessibilityIsVoiceOverRunning
import platform.UIKit.UIAccessibilityLayoutChangedNotification
import platform.UIKit.UIAccessibilityPageScrolledNotification
import platform.UIKit.UIAccessibilityPostNotification
import platform.UIKit.UIAccessibilityScreenChangedNotification
import platform.UIKit.UIAccessibilityScrollDirection
Expand Down Expand Up @@ -341,21 +344,35 @@ private class AccessibilityElement(
log("Focused on:")
log(cachedConfig)
}
}

override fun accessibilityScrollToVisible(): Boolean {
if (!isAlive) {
return
return false
}

scrollToIfPossible()

return true
}

override fun accessibilityScrollToVisibleWithChild(child: Any): Boolean {
if (!isAlive) {
return false
}

if (child is AccessibilityElement && child.isAlive) {
child.scrollToIfPossible()
return true
}

return false
}

/**
* Try to perform a scroll on any ancestor of this element if the element is not fully visible.
*/
private fun scrollToIfPossible() {
// TODO: extremely clunky and unreliable, temporarily disabled
return

val scrollableAncestor = semanticsNode.scrollableByAncestor ?: return
val scrollableAncestorRect = scrollableAncestor.boundsInWindow

Expand All @@ -370,20 +387,28 @@ private class AccessibilityElement(
// TODO: is RTL working properly?
if (unclippedRect.top < scrollableAncestorRect.top) {
// The element is above the screen, scroll up
parent?.scrollByIfPossible(0f, unclippedRect.top - scrollableAncestorRect.top)
return
parent?.scrollByIfPossible(
0f,
unclippedRect.top - scrollableAncestorRect.top - scrollableAncestor.size.height / 2
)
} else if (unclippedRect.bottom > scrollableAncestorRect.bottom) {
// The element is below the screen, scroll down
parent?.scrollByIfPossible(0f, unclippedRect.bottom - scrollableAncestorRect.bottom)
return
parent?.scrollByIfPossible(
0f,
unclippedRect.bottom - scrollableAncestorRect.bottom + scrollableAncestor.size.height / 2
)
} else if (unclippedRect.left < scrollableAncestorRect.left) {
// The element is to the left of the screen, scroll left
parent?.scrollByIfPossible(unclippedRect.left - scrollableAncestorRect.left, 0f)
return
parent?.scrollByIfPossible(
unclippedRect.left - scrollableAncestorRect.left - scrollableAncestor.size.width / 2,
0f
)
} else if (unclippedRect.right > scrollableAncestorRect.right) {
// The element is to the right of the screen, scroll right
parent?.scrollByIfPossible(unclippedRect.right - scrollableAncestorRect.right, 0f)
return
parent?.scrollByIfPossible(
unclippedRect.right - scrollableAncestorRect.right + scrollableAncestor.size.width / 2,
0f
)
}
}

Expand All @@ -402,83 +427,79 @@ private class AccessibilityElement(
}
}

private fun scrollIfPossible(direction: UIAccessibilityScrollDirection): Boolean {
private fun scrollIfPossible(direction: UIAccessibilityScrollDirection): AccessibilityElement? {
val config = cachedConfig

val (width, height) = semanticsNode.size

// TODO: reverse engineer proper dimension scale
val dimensionScale = 0.5f
//val (width, height) = semanticsNode.size

// TODO: post notification about the scroll
when (direction) {
UIAccessibilityScrollDirectionUp -> {
var result = config.getOrNull(SemanticsActions.PageUp)?.action?.invoke()

if (result != null) {
return result
return if (result) this else null
}

result = config.getOrNull(SemanticsActions.ScrollBy)?.action?.invoke(
0F,
-height.toFloat() * dimensionScale
0f,
-semanticsNode.size.height.toFloat()
)

if (result != null) {
return result
return if (result) this else null
}
}

UIAccessibilityScrollDirectionDown -> {
var result = config.getOrNull(SemanticsActions.PageDown)?.action?.invoke()

if (result != null) {
return result
return if (result) this else null
}

result = config.getOrNull(SemanticsActions.ScrollBy)?.action?.invoke(
0f,
height.toFloat() * dimensionScale
semanticsNode.size.height.toFloat()
)

if (result != null) {
return result
return if (result) this else null
}
}

UIAccessibilityScrollDirectionLeft -> {
var result = config.getOrNull(SemanticsActions.PageLeft)?.action?.invoke()

if (result != null) {
return result
return if (result) this else null
}

// TODO: check RTL support
result = config.getOrNull(SemanticsActions.ScrollBy)?.action?.invoke(
-width.toFloat() * dimensionScale,
-semanticsNode.size.width.toFloat(),
0f,
)

if (result != null) {
return result
return if (result) this else null
}
}

UIAccessibilityScrollDirectionRight -> {
var result = config.getOrNull(SemanticsActions.PageRight)?.action?.invoke()

if (result != null) {
return result
return if (result) this else null
}

// TODO: check RTL support
result = config.getOrNull(SemanticsActions.ScrollBy)?.action?.invoke(
width.toFloat() * dimensionScale,
semanticsNode.size.width.toFloat(),
0f,
)

if (result != null) {
return result
return if (result) this else null
}
}

Expand All @@ -491,15 +512,28 @@ private class AccessibilityElement(
return it.scrollIfPossible(direction)
}

return false
return null
}

override fun accessibilityScroll(direction: UIAccessibilityScrollDirection): Boolean {
if (!isAlive) {
return false
}

return scrollIfPossible(direction)
val frame = semanticsNode.boundsInWindow
val approximateScrollAnimationDuration = 350L

val scrollableElement = scrollIfPossible(direction)
return if (scrollableElement != null) {
mediator.notifyScrollCompleted(
delay = approximateScrollAnimationDuration,
focusedNode = semanticsNode,
focusedRectInWindow = frame
)
true
} else {
false
}
}

override fun isAccessibilityElement(): Boolean =
Expand Down Expand Up @@ -715,6 +749,24 @@ private class AccessibilityElement(
log("$indent accessibilityCustomActions: $accessibilityCustomActions")
}
}

fun hitTest(offsetInWindow: Offset): AccessibilityElement? {
if (!isAlive) {
return null
}

val containsPoint = semanticsNode.boundsInWindow.contains(offsetInWindow)
if (containsPoint && isAccessibilityElement) {
return this
}

children.forEach { child ->
child.hitTest(offsetInWindow)?.let {
return it
}
}
return this.takeIf { containsPoint }
}
}

/**
Expand Down Expand Up @@ -1017,6 +1069,30 @@ internal class AccessibilityMediator(
return convertToAppWindowCGRect(rect, window)
}

fun notifyScrollCompleted(
delay: Long,
focusedNode: SemanticsNode,
focusedRectInWindow: Rect
) {
coroutineScope.launch {
delay(delay)

UIAccessibilityPostNotification(
UIAccessibilityPageScrolledNotification,
null
)

if (accessibilityElementsMap[focusedNode.id] == null) {
findElementInRect(rect = focusedRectInWindow)?.let {
UIAccessibilityPostNotification(
UIAccessibilityLayoutChangedNotification,
it
)
}
}
}
}

fun onSemanticsChange() {
debugLogger?.log("onSemanticsChange")

Expand Down Expand Up @@ -1161,15 +1237,12 @@ internal class AccessibilityMediator(
debugTraverse(it, view)
}

val focusedElement = UIAccessibilityFocusedElement(null)
val focusedElement = UIAccessibilityFocusedElement(null) as? AccessibilityElement

// TODO: in future the focused element could be the interop UIView that is detached from the
// hierarchy, but still maintains the focus until the GC collects it, or AX services detect
// that it's not reachable anymore through containment chain
val isFocusedElementAlive = focusedElement?.let {
val accessibilityElement = it as? AccessibilityElement
accessibilityElement?.isAlive ?: false
} ?: false
val isFocusedElementAlive = focusedElement?.isAlive ?: false

val isFocusedElementDead = !isFocusedElementAlive

Expand All @@ -1189,11 +1262,21 @@ internal class AccessibilityMediator(

refocusedElement
} else {
null
focusedElement?.semanticsNodeId?.let {
accessibilityElementsMap[it]
}
}

return NodesSyncResult(newElementToFocus)
}

private fun findElementInRect(rect: Rect): AccessibilityElement? {
val offsetInWindow = Offset(
x = (rect.right + rect.left) / 2,
y = (rect.bottom + rect.top) / 2
)
return accessibilityElementsMap[rootSemanticsNodeId]?.hitTest(offsetInWindow)
}
}

/**
Expand Down
Loading