Skip to content

Commit 1639dc0

Browse files
authored
feat: Add keyboardScroll modifier and test matcher (#424)
## Changes - Add `keyboardScroll` modifier to replace `bringIntoView` - Deprecate the `bringIntoView` modifier - Add `hasKeyboardScroll` matcher - Use `hasKeyboardScroll` matcher to check for this behaviour in relevant screen patterns ## Context [DCMAW-19030](https://govukverify.atlassian.net/browse/DCMAW-19030) `keyboardScroll` (formerly `bringIntoView`) enables scrolling using keyboard up/down arrows. This PR improves the naming of the modifier so it's clear what it does. It also enables consumers to test for the presence of the modifier rather than testing the behaviour directly, which is hard to do. The behaviour of the modifier is already tested centrally in `KeyboardScrollTest`. ## Evidence of the change N/A ## Checklist ### Before creating the pull request - [ ] Commit messages that conform to conventional commit messages. - [ ] Ran the app locally ensuring it builds. - [x] Tests pass locally. - [x] Pull request has a clear title with a short description about the feature or update. - [x] Created a `draft` pull request if it's not ready for review. ### Before the CODEOWNERS review the pull request - [x] Complete all Acceptance Criteria within Jira ticket. - [x] Self-review code. - [ ] Successfully run changes on a testing device. - [ ] Complete automated Testing: * [x] Unit Tests. * [ ] Integration Tests. * [ ] Instrumentation / Emulator Tests. - [ ] Review [Accessibility considerations]. - [ ] Handle PR comments. ### Before merging the pull request - [ ] [Sonar cloud report] passes inspections for your PR. - [ ] Resolve all comments. [Sonar cloud report]: https://sonarcloud.io/project/overview?id=di-mobile-android-ui [Accessibility considerations]: https://developer.android.com/guide/topics/ui/accessibility/testing [DCMAW-19030]: https://govukverify.atlassian.net/browse/DCMAW-19030?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 8b99654 commit 1639dc0

10 files changed

Lines changed: 137 additions & 34 deletions

File tree

patterns/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ dependencies {
9292
androidTestImplementation(libs.androidx.test.espresso.core)
9393
androidTestUtil(libs.androidx.test.orchestrator)
9494

95+
testFixturesApi(libs.androidx.ui.test.android)
96+
9597
testImplementation(libs.androidx.ui.test.android)
9698
testImplementation(libs.androidx.ui.test.junit4.android)
9799
testImplementation(libs.arch.core)

patterns/src/main/java/uk/gov/android/ui/patterns/centrealignedscreen/CentreAlignedScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import uk.gov.android.ui.patterns.centrealignedscreen.CentreAlignedScreenDefault
4545
import uk.gov.android.ui.patterns.centrealignedscreen.CentreAlignedScreenDefaults.NoPadding
4646
import uk.gov.android.ui.patterns.centrealignedscreen.CentreAlignedScreenDefaults.VerticalPadding
4747
import uk.gov.android.ui.patterns.centrealignedscreen.CentreAlignedScreenTestTag.BODY_LAZY_COLUMN_TEST_TAG
48-
import uk.gov.android.ui.patterns.utils.ModifierExtensions.bringIntoView
48+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.keyboardScroll
4949
import uk.gov.android.ui.patterns.utils.clearListSemanticsForTalkBack
5050
import uk.gov.android.ui.theme.m3.ExtraTypography
5151
import uk.gov.android.ui.theme.m3.GdsTheme
@@ -303,7 +303,7 @@ private fun MainContent(
303303
verticalArrangement = arrangement,
304304
modifier = modifier
305305
.fillMaxSize()
306-
.bringIntoView(scrollState)
306+
.keyboardScroll(scrollState)
307307
.testTag(BODY_LAZY_COLUMN_TEST_TAG)
308308
.clearListSemanticsForTalkBack(),
309309
state = scrollState,

patterns/src/main/java/uk/gov/android/ui/patterns/errorscreen/v2/ErrorScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import uk.gov.android.ui.patterns.errorscreen.v2.ErrorScreenDefaults.HorizontalP
3939
import uk.gov.android.ui.patterns.errorscreen.v2.ErrorScreenDefaults.VerticalPadding
4040
import uk.gov.android.ui.patterns.errorscreen.v2.ErrorScreenTitleTestTag.ERROR_BODY_LAZY_COLUMN_TEST_TAG
4141
import uk.gov.android.ui.patterns.errorscreen.v2.ErrorScreenTitleTestTag.ERROR_SCREEN_TITLE_TEST_TAG
42-
import uk.gov.android.ui.patterns.utils.ModifierExtensions.bringIntoView
42+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.keyboardScroll
4343
import uk.gov.android.ui.patterns.utils.clearListSemanticsForTalkBack
4444
import uk.gov.android.ui.theme.m3.GdsTheme
4545
import uk.gov.android.ui.theme.meta.ExcludeFromJacocoGeneratedReport
@@ -183,7 +183,7 @@ private fun MainContent(
183183
),
184184
modifier = modifier
185185
.fillMaxSize()
186-
.bringIntoView(scrollState)
186+
.keyboardScroll(scrollState)
187187
.testTag(ERROR_BODY_LAZY_COLUMN_TEST_TAG)
188188
.clearListSemanticsForTalkBack(),
189189
state = scrollState,

patterns/src/main/java/uk/gov/android/ui/patterns/leftalignedscreen/LeftAlignedScreen.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ import uk.gov.android.ui.componentsv2.heading.GdsHeading
3333
import uk.gov.android.ui.componentsv2.heading.GdsHeadingAlignment
3434
import uk.gov.android.ui.componentsv2.supportingtext.GdsSupportingText
3535
import uk.gov.android.ui.patterns.leftalignedscreen.LeftAlignedScreenTestTag.BODY_LAZY_COLUMN_TEST_TAG
36+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.keyboardScroll
3637
import uk.gov.android.ui.patterns.utils.clearListSemanticsForTalkBack
3738
import uk.gov.android.ui.theme.m3.GdsTheme
3839
import uk.gov.android.ui.theme.spacingDouble
39-
import uk.gov.android.ui.patterns.utils.ModifierExtensions.bringIntoView as bringIntoViewV2
4040

4141
private const val ONE_THIRD = 1f / 3f
4242
private const val FONT_SCALE_DOUBLE = 2f
@@ -325,7 +325,7 @@ private fun MainContent(
325325
val columnModifier = if (forceScroll) {
326326
Modifier
327327
.fillMaxSize()
328-
.bringIntoViewV2(scrollState)
328+
.keyboardScroll(scrollState)
329329
} else {
330330
Modifier.fillMaxSize()
331331
}

patterns/src/main/java/uk/gov/android/ui/patterns/utils/ModifierExtensions.kt

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,32 @@ import androidx.compose.ui.input.key.KeyEventType
1717
import androidx.compose.ui.input.key.key
1818
import androidx.compose.ui.input.key.onKeyEvent
1919
import androidx.compose.ui.input.key.type
20+
import androidx.compose.ui.semantics.SemanticsPropertyKey
21+
import androidx.compose.ui.semantics.SemanticsPropertyReceiver
22+
import androidx.compose.ui.semantics.semantics
2023
import kotlinx.coroutines.launch
2124

2225
object ModifierExtensions {
26+
internal val HasKeyboardScrollKey: SemanticsPropertyKey<Boolean> =
27+
SemanticsPropertyKey("IsScrollableWithKeyboard")
28+
internal var SemanticsPropertyReceiver.hasKeyboardScroll: Boolean by HasKeyboardScrollKey
29+
2330
/**
24-
* Adds a downwards and upwards scroll when a keyboard down or up arrow is pressed
31+
* Adds a downwards and upwards scroll when a keyboard down or up arrow is pressed.
32+
*
33+
* If you need to check for the presence of this modifier in tests, use the
34+
* `hasKeyboardScroll` matcher from the test fixtures.
2535
*
2636
* @param scrollState [ScrollState] represents the list state
2737
* @return augmented [Modifier]
2838
*/
2939
@Composable
30-
fun Modifier.bringIntoView(scrollState: ScrollableState): Modifier {
40+
fun Modifier.keyboardScroll(scrollState: ScrollableState): Modifier {
3141
val coroutineScope = rememberCoroutineScope()
3242
val interactionSource = remember { MutableInteractionSource() }
3343
val focusRequester = remember { FocusRequester() }
3444
return this
45+
.semantics { hasKeyboardScroll = true }
3546
.onKeyEvent {
3647
when {
3748
it.type == KeyEventType.KeyDown && it.key == Key.DirectionDown &&
@@ -61,6 +72,21 @@ object ModifierExtensions {
6172
.focusable(interactionSource = interactionSource)
6273
}
6374

75+
/**
76+
* Adds a downwards and upwards scroll when a keyboard down or up arrow is pressed.
77+
*
78+
* @param scrollState [ScrollState] represents the list state
79+
* @return augmented [Modifier]
80+
*/
81+
@Deprecated(
82+
message = "Replace with keyboardScroll. Due to be removed 13th July 2026.",
83+
replaceWith = ReplaceWith("keyboardScroll(scrollState)"),
84+
level = DeprecationLevel.WARNING,
85+
)
86+
@Composable
87+
fun Modifier.bringIntoView(scrollState: ScrollableState): Modifier =
88+
keyboardScroll(scrollState)
89+
6490
private fun ScrollableState.viewportHeight(): Float = when (this) {
6591
is LazyListState -> layoutInfo.viewportSize.height.toFloat()
6692
is ScrollState -> viewportSize.toFloat()

patterns/src/test/java/uk/gov/android/ui/patterns/centrealignedscreen/CentreAlignedScreenTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import uk.gov.android.ui.componentsv2.list.ListItem
2525
import uk.gov.android.ui.componentsv2.list.ListTitle
2626
import uk.gov.android.ui.componentsv2.list.TitleType
2727
import uk.gov.android.ui.patterns.testutils.Matchers.assertListSemanticsCleared
28+
import uk.gov.android.ui.patterns.utils.matchers.ScrollableWithKeyboardMatchers.hasKeyboardScroll
2829

2930
@RunWith(RobolectricTestRunner::class)
3031
class CentreAlignedScreenTest {
@@ -164,4 +165,17 @@ class CentreAlignedScreenTest {
164165
.onNode(buttonContentDesc)
165166
.assertIsDisplayed()
166167
}
168+
169+
@Test
170+
fun `lazy column has keyboard scroll enabled`() {
171+
composeTestRule.setContent {
172+
CentreAlignedScreen(
173+
title = { },
174+
)
175+
}
176+
177+
composeTestRule
178+
.onNodeWithTag(CentreAlignedScreenTestTag.BODY_LAZY_COLUMN_TEST_TAG)
179+
.assert(hasKeyboardScroll())
180+
}
167181
}

patterns/src/test/java/uk/gov/android/ui/patterns/errorscreen/v2/ErrorScreenParameterTest.kt

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import androidx.compose.ui.test.assertIsDisplayed
99
import androidx.compose.ui.test.assertIsNotDisplayed
1010
import androidx.compose.ui.test.junit4.createComposeRule
1111
import androidx.compose.ui.test.onNodeWithContentDescription
12-
import androidx.compose.ui.test.onNodeWithTag
1312
import androidx.compose.ui.test.onNodeWithText
1413
import org.junit.Rule
1514
import org.junit.Test
@@ -20,7 +19,6 @@ import uk.gov.android.ui.componentsv2.images.GdsIcon
2019
import uk.gov.android.ui.patterns.testutils.BDD.Given
2120
import uk.gov.android.ui.patterns.testutils.BDD.Then
2221
import uk.gov.android.ui.patterns.testutils.BDD.When
23-
import uk.gov.android.ui.patterns.testutils.Matchers.assertListSemanticsCleared
2422
import uk.gov.android.ui.patterns.testutils.TestUtils.getString
2523

2624
@RunWith(RobolectricTestRunner::class)
@@ -356,18 +354,4 @@ class ErrorScreenParameterTest {
356354
onNodeWithText(tertiaryButtonText).assertIsDisplayed()
357355
}
358356
}
359-
360-
@Test
361-
fun `lazy column has semantic collection info with rows and columns set to zero`() {
362-
composeTestRule.setContent {
363-
ErrorScreen(
364-
title = { },
365-
icon = { },
366-
)
367-
}
368-
369-
composeTestRule
370-
.onNodeWithTag(ErrorScreenTitleTestTag.ERROR_BODY_LAZY_COLUMN_TEST_TAG)
371-
.assertListSemanticsCleared()
372-
}
373357
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package uk.gov.android.ui.patterns.errorscreen.v2
2+
3+
import androidx.compose.ui.graphics.vector.ImageVector
4+
import androidx.compose.ui.res.vectorResource
5+
import androidx.compose.ui.test.assert
6+
import androidx.compose.ui.test.junit4.ComposeContentTestRule
7+
import androidx.compose.ui.test.junit4.createComposeRule
8+
import androidx.compose.ui.test.onNodeWithTag
9+
import org.junit.Rule
10+
import org.junit.Test
11+
import org.junit.runner.RunWith
12+
import org.robolectric.RobolectricTestRunner
13+
import uk.gov.android.ui.componentsv2.heading.GdsHeading
14+
import uk.gov.android.ui.componentsv2.images.GdsIcon
15+
import uk.gov.android.ui.patterns.errorscreen.v2.ErrorScreenTitleTestTag.ERROR_BODY_LAZY_COLUMN_TEST_TAG
16+
import uk.gov.android.ui.patterns.testutils.Matchers.assertListSemanticsCleared
17+
import uk.gov.android.ui.patterns.utils.matchers.ScrollableWithKeyboardMatchers.hasKeyboardScroll
18+
19+
@RunWith(RobolectricTestRunner::class)
20+
class ErrorScreenSemanticsTest {
21+
@get:Rule
22+
val composeTestRule = createComposeRule()
23+
24+
@Test
25+
fun `lazy column has keyboard scroll enabled`() {
26+
composeTestRule.setErrorScreenContent()
27+
28+
composeTestRule
29+
.onNodeWithTag(ERROR_BODY_LAZY_COLUMN_TEST_TAG)
30+
.assert(hasKeyboardScroll())
31+
}
32+
33+
@Test
34+
fun `lazy column has semantic collection info with rows and columns set to zero`() {
35+
composeTestRule.setErrorScreenContent()
36+
37+
composeTestRule
38+
.onNodeWithTag(ErrorScreenTitleTestTag.ERROR_BODY_LAZY_COLUMN_TEST_TAG)
39+
.assertListSemanticsCleared()
40+
}
41+
42+
private fun ComposeContentTestRule.setErrorScreenContent() = this.setContent {
43+
ErrorScreen(
44+
icon = {
45+
GdsIcon(
46+
image = ImageVector.vectorResource(ErrorScreenIcon.ErrorIcon.icon),
47+
contentDescription = "Error",
48+
)
49+
},
50+
title = { GdsHeading("Title") },
51+
)
52+
}
53+
}

patterns/src/test/java/uk/gov/android/ui/patterns/utils/BringIntoViewTest.kt renamed to patterns/src/test/java/uk/gov/android/ui/patterns/utils/KeyboardScrollTest.kt

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import androidx.compose.ui.input.key.Key
1919
import androidx.compose.ui.platform.LocalInputModeManager
2020
import androidx.compose.ui.platform.testTag
2121
import androidx.compose.ui.test.ExperimentalTestApi
22+
import androidx.compose.ui.test.assert
2223
import androidx.compose.ui.test.assertIsDisplayed
2324
import androidx.compose.ui.test.assertIsNotDisplayed
2425
import androidx.compose.ui.test.junit4.ComposeTestRule
@@ -32,19 +33,27 @@ import org.junit.Rule
3233
import org.junit.Test
3334
import org.junit.runner.RunWith
3435
import org.robolectric.RobolectricTestRunner
35-
import uk.gov.android.ui.patterns.utils.BringIntoViewTest.Companion.NUM_ITEMS
36-
import uk.gov.android.ui.patterns.utils.BringIntoViewTest.Companion.containerHeight
37-
import uk.gov.android.ui.patterns.utils.BringIntoViewTest.Companion.itemHeight
38-
import uk.gov.android.ui.patterns.utils.ModifierExtensions.bringIntoView
36+
import uk.gov.android.ui.patterns.utils.KeyboardScrollTest.Companion.NUM_ITEMS
37+
import uk.gov.android.ui.patterns.utils.KeyboardScrollTest.Companion.containerHeight
38+
import uk.gov.android.ui.patterns.utils.KeyboardScrollTest.Companion.itemHeight
39+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.keyboardScroll
40+
import uk.gov.android.ui.patterns.utils.matchers.ScrollableWithKeyboardMatchers.hasKeyboardScroll
3941

4042
@OptIn(ExperimentalTestApi::class)
4143
@RunWith(RobolectricTestRunner::class)
42-
class BringIntoViewTest {
44+
class KeyboardScrollTest {
4345
@get:Rule
4446
val composeTestRule = createComposeRule()
4547

4648
private val focusRequester = FocusRequester()
4749

50+
@Test
51+
fun `modifier exposes semantics for matcher`() {
52+
setUpColumn()
53+
54+
composeTestRule.onNodeWithTag(TEST_TAG).assert(hasKeyboardScroll())
55+
}
56+
4857
@Test
4958
fun `keyboard can scroll down and up for non-lazy list`() {
5059
setUpColumn()
@@ -97,7 +106,7 @@ class BringIntoViewTest {
97106
numItems = numItems,
98107
modifier = Modifier
99108
.focusRequester(focusRequester)
100-
.bringIntoView(scrollState),
109+
.keyboardScroll(scrollState),
101110
)
102111
}
103112
}
@@ -109,7 +118,7 @@ class BringIntoViewTest {
109118
listState = listState,
110119
modifier = Modifier
111120
.focusRequester(focusRequester)
112-
.bringIntoView(listState),
121+
.keyboardScroll(listState),
113122
)
114123
}
115124
}
@@ -135,7 +144,7 @@ class BringIntoViewTest {
135144
.performKeyInput { pressKey(Key.DirectionDown) }
136145

137146
companion object {
138-
const val TEST_TAG = "bringIntoViewTest"
147+
const val TEST_TAG = "keyboardScrollTest"
139148
const val NUM_ITEMS = 50
140149
const val FIRST_ITEM = "Item 0"
141150
val containerHeight = 100.dp
@@ -153,7 +162,7 @@ private fun TestColumn(
153162
modifier = modifier
154163
.height(containerHeight)
155164
.verticalScroll(scrollState)
156-
.testTag(BringIntoViewTest.TEST_TAG),
165+
.testTag(KeyboardScrollTest.TEST_TAG),
157166
) {
158167
repeat(numItems) { index ->
159168
BasicText(
@@ -173,7 +182,7 @@ private fun TestLazyColumn(
173182
state = listState,
174183
modifier = modifier
175184
.height(containerHeight)
176-
.testTag(BringIntoViewTest.TEST_TAG),
185+
.testTag(KeyboardScrollTest.TEST_TAG),
177186
) {
178187
items(numItems) { index ->
179188
BasicText(
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package uk.gov.android.ui.patterns.utils.matchers
2+
3+
import androidx.compose.ui.test.SemanticsMatcher
4+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.HasKeyboardScrollKey
5+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.keyboardScroll
6+
7+
object ScrollableWithKeyboardMatchers {
8+
/**
9+
* Matches nodes that can be scrolled using the keyboard up/down arrows.
10+
*
11+
* @see [keyboardScroll].
12+
*/
13+
fun hasKeyboardScroll(): SemanticsMatcher =
14+
SemanticsMatcher.expectValue(HasKeyboardScrollKey, true)
15+
}

0 commit comments

Comments
 (0)