Skip to content

Commit 8bcb55f

Browse files
authored
Merge pull request #10 from mohdaquib/bug/ui-issues-fix
Improve touch target detection and UI layout in the A11y Scanner
2 parents cd4b381 + 63d5b14 commit 8bcb55f

5 files changed

Lines changed: 68 additions & 12 deletions

File tree

scanner-rules/src/main/java/com/composea11yscanner/rules/TouchTargetRule.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ class TouchTargetRule(
1919

2020
val w = node.touchTargetSize.width
2121
val h = node.touchTargetSize.height
22-
if (w >= minTouchTargetDp && h >= minTouchTargetDp) return null
22+
// Pixel bounds converted back to dp can land infinitesimally below an exact dp value
23+
// (for example, a 48 dp target may be reported as 47.999996 dp).
24+
if (
25+
w + MeasurementToleranceDp >= minTouchTargetDp &&
26+
h + MeasurementToleranceDp >= minTouchTargetDp
27+
) return null
2328

2429
return issue(
2530
node = node,
@@ -29,4 +34,8 @@ class TouchTargetRule(
2934
"composable reaches at least ${minTouchTargetDp}dp in both dimensions.",
3035
)
3136
}
37+
38+
private companion object {
39+
const val MeasurementToleranceDp = 0.01f
40+
}
3241
}

scanner-rules/src/test/java/com/composea11yscanner/rules/TouchTargetRuleTest.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ class TouchTargetRuleTest {
2424
assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 48f))))
2525
}
2626

27+
@Test
28+
fun `floating point noise just below minimum size passes`() {
29+
assertNull(
30+
rule.evaluate(
31+
createNode(
32+
isTouchTarget = true,
33+
touchTargetSize = DpSize(47.999996f, 47.999996f),
34+
)
35+
)
36+
)
37+
}
38+
2739
@Test
2840
fun `touch target larger than minimum passes`() {
2941
assertNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(56f, 64f))))
@@ -49,6 +61,18 @@ class TouchTargetRuleTest {
4961
assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(44f, 48f))))
5062
}
5163

64+
@Test
65+
fun `meaningfully undersized target is not hidden by measurement tolerance`() {
66+
assertNotNull(
67+
rule.evaluate(
68+
createNode(
69+
isTouchTarget = true,
70+
touchTargetSize = DpSize(47.98f, 48f),
71+
)
72+
)
73+
)
74+
}
75+
5276
@Test
5377
fun `height below minimum fails`() {
5478
assertNotNull(rule.evaluate(createNode(isTouchTarget = true, touchTargetSize = DpSize(48f, 44f))))

scanner-ui/src/androidTest/java/com/composea11yscanner/ui/A11yNodeExtractorTest.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package com.composea11yscanner.ui
22

33
import androidx.compose.foundation.clickable
44
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.size
56
import androidx.compose.material3.Text
67
import androidx.compose.ui.Modifier
8+
import androidx.compose.ui.platform.LocalDensity
79
import androidx.compose.ui.test.junit4.createComposeRule
810
import androidx.compose.ui.test.onRoot
911
import androidx.compose.ui.unit.Density
12+
import androidx.compose.ui.unit.dp
1013
import org.junit.Assert.assertEquals
1114
import org.junit.Assert.assertTrue
1215
import org.junit.Rule
@@ -49,4 +52,26 @@ class A11yNodeExtractorTest {
4952
assertEquals("Clickable", clickableNode.composableName)
5053
assertTrue(clickableNode.contentDescription.isNullOrBlank())
5154
}
55+
56+
@Test
57+
fun compactClickable_usesExpandedTouchBounds() {
58+
lateinit var density: Density
59+
composeRule.setContent {
60+
density = LocalDensity.current
61+
Box(
62+
modifier = Modifier
63+
.size(width = 40.dp, height = 48.dp)
64+
.clickable { },
65+
)
66+
}
67+
68+
composeRule.waitForIdle()
69+
70+
val nodes = A11yNodeExtractor(density)
71+
.extract(composeRule.onRoot(useUnmergedTree = true).fetchSemanticsNode())
72+
val clickableNode = nodes.single { it.isTouchTarget }
73+
74+
assertTrue(clickableNode.touchTargetSize.width >= 48f)
75+
assertTrue(clickableNode.touchTargetSize.height >= 48f)
76+
}
5277
}

scanner-ui/src/main/java/com/composea11yscanner/ui/A11yNodeExtractor.kt

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,10 @@ class A11yNodeExtractor(private val density: Density) {
6868
null
6969
}
7070
val visualBounds = boundsInRoot
71-
val touchTargetBounds = layoutBoundsInRoot()
71+
// Compose expands pointer input for small clickables to the platform minimum touch
72+
// target without changing their visual/layout bounds. Measure the region that actually
73+
// accepts input so compact controls such as a short FilterChip are not false positives.
74+
val touchTargetBounds = touchBoundsInRoot
7275
val bounds = visualBounds.toCoreRect()
7376

7477
return A11yNode(
@@ -146,16 +149,6 @@ class A11yNodeExtractor(private val density: Density) {
146149
}
147150
}
148151

149-
private fun SemanticsNode.layoutBoundsInRoot(): androidx.compose.ui.geometry.Rect {
150-
val position = positionInRoot
151-
return androidx.compose.ui.geometry.Rect(
152-
left = position.x,
153-
top = position.y,
154-
right = position.x + layoutInfo.width,
155-
bottom = position.y + layoutInfo.height,
156-
)
157-
}
158-
159152
private fun androidx.compose.ui.geometry.Rect.toCoreRect(): Rect = Rect(
160153
left = left.roundToInt(),
161154
top = top.roundToInt(),

scanner-ui/src/main/java/com/composea11yscanner/ui/ScanSummaryBar.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ import androidx.compose.foundation.layout.Arrangement
1818
import androidx.compose.foundation.layout.Box
1919
import androidx.compose.foundation.layout.Column
2020
import androidx.compose.foundation.layout.Row
21+
import androidx.compose.foundation.layout.WindowInsets
2122
import androidx.compose.foundation.layout.fillMaxWidth
2223
import androidx.compose.foundation.layout.height
2324
import androidx.compose.foundation.layout.padding
25+
import androidx.compose.foundation.layout.statusBars
26+
import androidx.compose.foundation.layout.windowInsetsPadding
2427
import androidx.compose.foundation.layout.size
2528
import androidx.compose.foundation.layout.width
2629
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -101,6 +104,8 @@ fun ScanSummaryBar(
101104
Box(
102105
modifier = modifier
103106
.fillMaxWidth()
107+
// topOffset clears the host toolbar; the inset additionally clears the system bar.
108+
.windowInsetsPadding(WindowInsets.statusBars)
104109
.padding(top = topOffset, start = 16.dp, end = 16.dp),
105110
contentAlignment = Alignment.TopCenter,
106111
) {

0 commit comments

Comments
 (0)