From 0da6bcfb5cb408bc356803378d31ba10dc1bc176 Mon Sep 17 00:00:00 2001 From: Ceylo <451334+Ceylo@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:08:21 +0200 Subject: [PATCH] Keep a shadow's copy of its content out of the accessibility tree Shadowed composes the content twice, the second time as the shadow, and that copy kept its semantics. Compose leaves out an accessibility node where a sibling drawn above it covers it, so the copy surfaced wherever the shadow extends past the content: with an offset, TalkBack found the label twice and an unlabelled click target under each button. Clear the copy's semantics. --- Sources/SkipUI/Skip/Shadowed.kt | 3 +++ Tests/SkipUITests/SkipUITests.swift | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/Sources/SkipUI/Skip/Shadowed.kt b/Sources/SkipUI/Skip/Shadowed.kt index d52cbbd0..e57d4fa5 100644 --- a/Sources/SkipUI/Skip/Shadowed.kt +++ b/Sources/SkipUI/Skip/Shadowed.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import kotlin.math.ceil @@ -28,7 +29,9 @@ import kotlin.math.ceil val blurRadiusPx = ceil(with(density) { blurRadius.toPx() }).toInt() val contentContext = context.content() + // The shadow is a second copy of the content: keep it out of the accessibility tree. val shadowContext = context.content(modifier = Modifier + .clearAndSetSemantics {} .drawWithContent { val matrix = shadowColorMatrix(color) val filter = ColorFilter.colorMatrix(matrix) diff --git a/Tests/SkipUITests/SkipUITests.swift b/Tests/SkipUITests/SkipUITests.swift index 60a5c6bc..991908b8 100644 --- a/Tests/SkipUITests/SkipUITests.swift +++ b/Tests/SkipUITests/SkipUITests.swift @@ -103,6 +103,7 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.moveTo import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -519,6 +520,30 @@ final class SkipUITests: SkipUITestCase { } } + func testShadowCopyIsHiddenFromAccessibility() throws { + #if !SKIP + throw XCTSkip("Compose semantics") + #else + try testUI(view: { + Text("Shadowed").shadow(radius: 4) + }, eval: { rule in + rule.onAllNodesWithText("Shadowed").assertCountEquals(1) + }) + #endif + } + + func testShadowCopyOfButtonIsHiddenFromAccessibility() throws { + #if !SKIP + throw XCTSkip("Compose semantics") + #else + try testUI(view: { + Button("Tap") { }.shadow(radius: 4, y: 8) + }, eval: { rule in + rule.onAllNodes(hasClickAction()).assertCountEquals(1) + }) + #endif + } + func testMenuAccessibilityIdentifier() throws { try testUI(view: { MenuTestView().accessibilityIdentifier("test-view")