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 safe area in accessibility scroll #1745

Merged
merged 4 commits into from
Dec 20, 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 @@ -28,13 +28,19 @@ import androidx.compose.ui.platform.accessibility.canBeAccessibilityElement
import androidx.compose.ui.platform.accessibility.isRTL
import androidx.compose.ui.platform.accessibility.isScreenReaderFocusable
import androidx.compose.ui.platform.accessibility.scrollIfPossible
import androidx.compose.ui.platform.accessibility.scrollToIfPossible
import androidx.compose.ui.platform.accessibility.scrollToCenterRectIfNeeded
import androidx.compose.ui.platform.accessibility.unclippedBoundsInWindow
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.semantics.SemanticsOwner
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.semantics.getOrNull
import androidx.compose.ui.uikit.density
import androidx.compose.ui.uikit.toDpRect
import androidx.compose.ui.uikit.utils.CMPAccessibilityElement
import androidx.compose.ui.unit.asCGRect
import androidx.compose.ui.unit.toDpRect
import androidx.compose.ui.unit.toRect
import androidx.compose.ui.viewinterop.InteropWrappingView
import androidx.compose.ui.viewinterop.NativeAccessibilityViewSemanticsKey
import kotlin.coroutines.CoroutineContext
Expand All @@ -44,7 +50,6 @@ import kotlin.time.measureTime
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.CValue
import kotlinx.cinterop.ExportObjCClass
import kotlinx.cinterop.readValue
import kotlinx.cinterop.useContents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
Expand All @@ -63,7 +68,6 @@ import platform.CoreGraphics.CGRectGetMidY
import platform.CoreGraphics.CGRectGetMinX
import platform.CoreGraphics.CGRectGetMinY
import platform.CoreGraphics.CGRectIsEmpty
import platform.CoreGraphics.CGRectZero
import platform.Foundation.NSNotFound
import platform.UIKit.NSStringFromCGRect
import platform.UIKit.UIAccessibilityCustomAction
Expand All @@ -75,8 +79,8 @@ import platform.UIKit.UIAccessibilityScreenChangedNotification
import platform.UIKit.UIAccessibilityScrollDirection
import platform.UIKit.UIAccessibilityTraitNone
import platform.UIKit.UIAccessibilityTraits
import platform.UIKit.UIEdgeInsetsInsetRect
import platform.UIKit.UIView
import platform.UIKit.UIWindow
import platform.UIKit.accessibilityElementAtIndex
import platform.UIKit.accessibilityElementCount
import platform.UIKit.accessibilityElements
Expand Down Expand Up @@ -141,7 +145,7 @@ private sealed interface AccessibilityNode {
class Semantics(
private val semanticsNode: SemanticsNode,
private val mediator: AccessibilityMediator
): AccessibilityNode {
) : AccessibilityNode {
private val cachedConfig = semanticsNode.config

override val key: AccessibilityElementKey
Expand Down Expand Up @@ -220,9 +224,10 @@ private sealed interface AccessibilityNode {
}

override fun accessibilityScrollToVisible(): Boolean {
semanticsNode.scrollToIfPossible()

return true
return semanticsNode.parent?.scrollToCenterRectIfNeeded(
rect = semanticsNode.unclippedBoundsInWindow,
safeAreaRectInWindow = mediator.safeAreaRectInWindow
) ?: false
}

override fun accessibilityScroll(direction: UIAccessibilityScrollDirection): Boolean {
Expand Down Expand Up @@ -598,11 +603,6 @@ internal class AccessibilityMediator(
val view: UIView,
val owner: SemanticsOwner,
coroutineContext: CoroutineContext,
/**
* A function that converts the given [Rect] from the semantics tree coordinate space (window container for layers)
* to the [CGRect] in coordinate space of the app window.
*/
val convertToAppWindowCGRect: (Rect, UIWindow) -> CValue<CGRect>,
val performEscape: () -> Boolean
): NSObject() {

Expand Down Expand Up @@ -661,6 +661,14 @@ internal class AccessibilityMediator(
}
}

val safeAreaRectInWindow: Rect get() {
val rectInWindow = view.convertRect(
rect = UIEdgeInsetsInsetRect(view.bounds, view.safeAreaInsets),
toView = null
)
return rectInWindow.toDpRect().toRect(view.density)
}

init {
accessibilityDebugLogger?.log("AccessibilityMediator for $view created")

Expand Down Expand Up @@ -711,9 +719,7 @@ internal class AccessibilityMediator(
val hasPendingInvalidations: Boolean get() = !invalidationChannel.isEmpty

fun convertToAppWindowCGRect(rect: Rect): CValue<CGRect> {
val window = view.window ?: return CGRectZero.readValue()

return convertToAppWindowCGRect(rect, window)
return rect.toDpRect(view.density).asCGRect()
}

fun notifyScrollCompleted(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,58 +150,58 @@ internal data class AccessibilityScrollEventResult(

/**
* Try to perform a scroll on any ancestor of this element if the element is not fully visible.
* @param rect to place in the center of scrollable area
* @param safeAreaRectInWindow safe area rect to reduce focusable borders
* @return true if the scroll was successful, otherwise returns false
*/
ASalavei marked this conversation as resolved.
Show resolved Hide resolved
internal fun SemanticsNode.scrollToIfPossible() {
val scrollableAncestor = scrollableByAncestor ?: return
internal fun SemanticsNode.scrollToCenterRectIfNeeded(
rect: Rect,
safeAreaRectInWindow: Rect
): Boolean {
val scrollableAncestor = scrollableByAncestor ?: return false
val scrollableAncestorRect = scrollableAncestor.boundsInWindow

val unclippedRect = unclippedBoundsInWindow
val scrollableViewportRect = scrollableAncestorRect.intersect(safeAreaRectInWindow)

fun Float.invertIfNeeded() = if (isRTL) -this else this
// TODO: consider safe areas?
if (unclippedRect.top < scrollableAncestorRect.top) {

val dy = if (rect.top < scrollableViewportRect.top) {
// The element is above the screen, scroll up
parent?.scrollByIfPossible(
0f,
unclippedRect.top - scrollableAncestorRect.top -
(scrollableAncestor.size.height - unclippedRect.size.height) / 2
)
} else if (unclippedRect.bottom > scrollableAncestorRect.bottom) {
rect.top - scrollableViewportRect.top -
(scrollableAncestor.size.height - rect.size.height) / 2
} else if (rect.bottom > scrollableViewportRect.bottom) {
// The element is below the screen, scroll down
parent?.scrollByIfPossible(
0f,
unclippedRect.bottom - scrollableAncestorRect.bottom +
(scrollableAncestor.size.height - unclippedRect.size.height) / 2
)
} else if (unclippedRect.left < scrollableAncestorRect.left) {
rect.bottom - scrollableViewportRect.bottom +
(scrollableAncestor.size.height - rect.size.height) / 2
} else {
0f
}

val dx = if (rect.left < scrollableViewportRect.left) {
// The element is to the left of the screen, scroll left
parent?.scrollByIfPossible(
(unclippedRect.left - scrollableAncestorRect.left -
(scrollableAncestor.size.width - unclippedRect.size.width) / 2).invertIfNeeded(),
0f
)
} else if (unclippedRect.right > scrollableAncestorRect.right) {
(rect.left - scrollableViewportRect.left -
(scrollableAncestor.size.width - rect.size.width) / 2).invertIfNeeded()
} else if (rect.right > scrollableViewportRect.right) {
// The element is to the right of the screen, scroll right
parent?.scrollByIfPossible(
(unclippedRect.right - scrollableAncestorRect.right +
(scrollableAncestor.size.width - unclippedRect.size.width) / 2).invertIfNeeded(),
0f
)
(rect.right - scrollableViewportRect.right +
(scrollableAncestor.size.width - rect.size.width) / 2).invertIfNeeded()
} else {
0f
}
return scrollByIfPossible(dx, dy)
}

private fun SemanticsNode.scrollByIfPossible(dx: Float, dy: Float) {
private fun SemanticsNode.scrollByIfPossible(dx: Float, dy: Float): Boolean {
// if it has scrollBy action, invoke it, otherwise try to scroll the parent
val action = config.getOrNull(SemanticsActions.ScrollBy)?.action

if (action != null) {
return if (action != null) {
action(dx, dy)
} else {
parent?.scrollByIfPossible(dx, dy)
parent?.scrollByIfPossible(dx, dy) ?: false
}
}

private val SemanticsNode.unclippedBoundsInWindow: Rect
internal val SemanticsNode.unclippedBoundsInWindow: Rect
get() = Rect(positionInWindow, size.toSize())

internal val SemanticsNode.isRTL: Boolean
Expand Down Expand Up @@ -239,7 +239,7 @@ private val SemanticsNode.isHiddenFromAccessibility: Boolean
*/
private val SemanticsNode.scrollableByAncestor: SemanticsNode?
get() {
var current = parent
var current: SemanticsNode? = this

while (current != null) {
if (current.config.getOrNull(SemanticsActions.ScrollBy) != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,11 @@ import androidx.compose.ui.uikit.embedSubview
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.asCGRect
import androidx.compose.ui.unit.asDpOffset
import androidx.compose.ui.unit.asDpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.round
import androidx.compose.ui.unit.roundToIntSize
import androidx.compose.ui.unit.toDpRect
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toPlatformInsets
import androidx.compose.ui.unit.toSize
Expand All @@ -94,15 +92,13 @@ import kotlinx.cinterop.useContents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.suspendCancellableCoroutine
import platform.CoreGraphics.CGPoint
import platform.CoreGraphics.CGRect
import platform.QuartzCore.CACurrentMediaTime
import platform.QuartzCore.CATransaction
import platform.UIKit.UIEvent
import platform.UIKit.UIPress
import platform.UIKit.UITouch
import platform.UIKit.UITouchPhase
import platform.UIKit.UIView
import platform.UIKit.UIWindow

/**
* iOS specific-implementation of [PlatformContext.SemanticsOwnerListener] used to track changes in [SemanticsOwner].
Expand All @@ -114,7 +110,6 @@ import platform.UIKit.UIWindow
private class SemanticsOwnerListenerImpl(
private val rootView: UIView,
private val coroutineContext: CoroutineContext,
private val convertToAppWindowCGRect: (Rect, UIWindow) -> CValue<CGRect>,
private val performEscape: () -> Boolean
) : PlatformContext.SemanticsOwnerListener {

Expand All @@ -132,7 +127,6 @@ private class SemanticsOwnerListenerImpl(
rootView,
semanticsOwner,
coroutineContext,
convertToAppWindowCGRect,
performEscape
).also {
it.isEnabled = isEnabled
Expand Down Expand Up @@ -278,11 +272,6 @@ internal class ComposeSceneMediator(
SemanticsOwnerListenerImpl(
rootView = view,
coroutineContext = coroutineContext,
convertToAppWindowCGRect = { rect, window ->
windowContext.convertWindowRect(rect, window)
.toDpRect(Density(window.screen.scale.toFloat()))
.asCGRect()
},
performEscape = {
val down = onKeyboardEvent(KeyEvent(Key.Escape, KeyEventType.KeyDown))
val up = onKeyboardEvent(KeyEvent(Key.Escape, KeyEventType.KeyUp))
Expand Down