Skip to content

Commit 92af674

Browse files
feat: Enhance keyboard scroll modifier (bringIntoView) to support different list types (#421)
- Enhance keyboard scroll modifier (`bringIntoView`) to support different types of `ScrollableState`: - `ScrollState` - `LazyListState` - Move the modifier into `uk.gov.android.ui.patterns.utils.scroll` - Remove the old modifier ## Context - govuk-one-login/mobile-android-one-login-app#825 (comment) DCMAW-19031 --------- Co-authored-by: Bianca Mihaila <bianca.mihaila@digital.cabinet-office.gov.uk>
1 parent 1940c83 commit 92af674

6 files changed

Lines changed: 265 additions & 53 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import uk.gov.android.ui.patterns.centrealignedscreen.CentreAlignedScreenDefault
4646
import uk.gov.android.ui.patterns.centrealignedscreen.CentreAlignedScreenDefaults.NoPadding
4747
import uk.gov.android.ui.patterns.centrealignedscreen.CentreAlignedScreenDefaults.VerticalPadding
4848
import uk.gov.android.ui.patterns.centrealignedscreen.CentreAlignedScreenTestTag.BODY_LAZY_COLUMN_TEST_TAG
49-
import uk.gov.android.ui.patterns.leftalignedscreen.bringIntoView
49+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.bringIntoView
5050
import uk.gov.android.ui.patterns.utils.clearListSemanticsForTalkBack
5151
import uk.gov.android.ui.theme.m3.GdsTheme
5252
import uk.gov.android.ui.theme.m3.Typography

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

Lines changed: 1 addition & 1 deletion
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.leftalignedscreen.bringIntoView
42+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.bringIntoView
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

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import uk.gov.android.ui.patterns.leftalignedscreen.LeftAlignedScreenTestTag.BOD
3636
import uk.gov.android.ui.patterns.utils.clearListSemanticsForTalkBack
3737
import uk.gov.android.ui.theme.m3.GdsTheme
3838
import uk.gov.android.ui.theme.spacingDouble
39+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.bringIntoView as bringIntoViewV2
3940

4041
private const val ONE_THIRD = 1f / 3f
4142
private const val FONT_SCALE_DOUBLE = 2f
@@ -324,7 +325,7 @@ private fun MainContent(
324325
val columnModifier = if (forceScroll) {
325326
Modifier
326327
.fillMaxSize()
327-
.bringIntoView(scrollState)
328+
.bringIntoViewV2(scrollState)
328329
} else {
329330
Modifier.fillMaxSize()
330331
}

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

Lines changed: 6 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
package uk.gov.android.ui.patterns.leftalignedscreen
22

33
import androidx.compose.foundation.Image
4-
import androidx.compose.foundation.focusable
5-
import androidx.compose.foundation.gestures.animateScrollBy
6-
import androidx.compose.foundation.interaction.MutableInteractionSource
74
import androidx.compose.foundation.layout.Arrangement
85
import androidx.compose.foundation.layout.PaddingValues
96
import androidx.compose.foundation.layout.fillMaxWidth
@@ -15,20 +12,9 @@ import androidx.compose.material3.MaterialTheme
1512
import androidx.compose.material3.MaterialTheme.colorScheme
1613
import androidx.compose.material3.Text
1714
import androidx.compose.runtime.Composable
18-
import androidx.compose.runtime.getValue
19-
import androidx.compose.runtime.remember
20-
import androidx.compose.runtime.rememberCoroutineScope
21-
import androidx.compose.runtime.setValue
2215
import androidx.compose.ui.Modifier
23-
import androidx.compose.ui.focus.FocusRequester
24-
import androidx.compose.ui.focus.focusRequester
2516
import androidx.compose.ui.graphics.Color
2617
import androidx.compose.ui.graphics.vector.ImageVector
27-
import androidx.compose.ui.input.key.Key
28-
import androidx.compose.ui.input.key.KeyEventType
29-
import androidx.compose.ui.input.key.key
30-
import androidx.compose.ui.input.key.onKeyEvent
31-
import androidx.compose.ui.input.key.type
3218
import androidx.compose.ui.layout.ContentScale
3319
import androidx.compose.ui.res.painterResource
3420
import androidx.compose.ui.res.stringResource
@@ -37,7 +23,6 @@ import androidx.compose.ui.text.AnnotatedString
3723
import androidx.compose.ui.text.style.TextAlign
3824
import androidx.compose.ui.unit.Dp
3925
import kotlinx.collections.immutable.ImmutableList
40-
import kotlinx.coroutines.launch
4126
import uk.gov.android.ui.componentsv2.R
4227
import uk.gov.android.ui.componentsv2.button.ButtonTypeV2
4328
import uk.gov.android.ui.componentsv2.button.GdsButton
@@ -56,6 +41,7 @@ import uk.gov.android.ui.componentsv2.row.RowList
5641
import uk.gov.android.ui.componentsv2.supportingtext.GdsSupportingText
5742
import uk.gov.android.ui.componentsv2.warning.GdsWarningText
5843
import uk.gov.android.ui.theme.dividerThickness
44+
import uk.gov.android.ui.patterns.utils.ModifierExtensions.bringIntoView as bringIntoViewV2
5945

6046
internal data class LeftAlignedScreenContentV2(
6147
val title: String,
@@ -359,39 +345,9 @@ private fun LazyListScope.toAnnotatedText(
359345
* @param scrollState [LazyListState] represents the list state
360346
* @return augmented [Modifier]
361347
*/
348+
@Deprecated(
349+
"Use uk.gov.android.ui.patterns.utils.scroll.bringIntoView",
350+
level = DeprecationLevel.WARNING,
351+
)
362352
@Composable
363-
fun Modifier.bringIntoView(scrollState: LazyListState): Modifier {
364-
val coroutineScope = rememberCoroutineScope()
365-
val interactionSource = remember { MutableInteractionSource() }
366-
val focusRequester = remember { FocusRequester() }
367-
return this
368-
.onKeyEvent {
369-
when {
370-
it.type == KeyEventType.KeyDown && it.key == Key.DirectionDown &&
371-
scrollState.canScrollForward -> {
372-
coroutineScope.launch {
373-
scrollState.animateScrollBy(
374-
SCROLL_MULTIPLIER * scrollState.layoutInfo.viewportSize.height,
375-
)
376-
}
377-
true
378-
}
379-
380-
it.type == KeyEventType.KeyDown && it.key == Key.DirectionUp &&
381-
scrollState.canScrollBackward -> {
382-
coroutineScope.launch {
383-
scrollState.animateScrollBy(
384-
-SCROLL_MULTIPLIER * scrollState.layoutInfo.viewportSize.height,
385-
)
386-
}
387-
true
388-
}
389-
390-
else -> false
391-
}
392-
}
393-
.focusRequester(focusRequester)
394-
.focusable(interactionSource = interactionSource)
395-
}
396-
397-
private const val SCROLL_MULTIPLIER = 0.4f
353+
fun Modifier.bringIntoView(scrollState: LazyListState): Modifier = this.bringIntoViewV2(scrollState)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package uk.gov.android.ui.patterns.utils
2+
3+
import androidx.compose.foundation.ScrollState
4+
import androidx.compose.foundation.focusable
5+
import androidx.compose.foundation.gestures.ScrollableState
6+
import androidx.compose.foundation.gestures.animateScrollBy
7+
import androidx.compose.foundation.interaction.MutableInteractionSource
8+
import androidx.compose.foundation.lazy.LazyListState
9+
import androidx.compose.runtime.Composable
10+
import androidx.compose.runtime.remember
11+
import androidx.compose.runtime.rememberCoroutineScope
12+
import androidx.compose.ui.Modifier
13+
import androidx.compose.ui.focus.FocusRequester
14+
import androidx.compose.ui.focus.focusRequester
15+
import androidx.compose.ui.input.key.Key
16+
import androidx.compose.ui.input.key.KeyEventType
17+
import androidx.compose.ui.input.key.key
18+
import androidx.compose.ui.input.key.onKeyEvent
19+
import androidx.compose.ui.input.key.type
20+
import kotlinx.coroutines.launch
21+
22+
object ModifierExtensions {
23+
/**
24+
* Adds a downwards and upwards scroll when a keyboard down or up arrow is pressed
25+
*
26+
* @param scrollState [ScrollState] represents the list state
27+
* @return augmented [Modifier]
28+
*/
29+
@Composable
30+
fun Modifier.bringIntoView(scrollState: ScrollableState): Modifier {
31+
val coroutineScope = rememberCoroutineScope()
32+
val interactionSource = remember { MutableInteractionSource() }
33+
val focusRequester = remember { FocusRequester() }
34+
return this
35+
.onKeyEvent {
36+
when {
37+
it.type == KeyEventType.KeyDown && it.key == Key.DirectionDown &&
38+
scrollState.canScrollForward -> {
39+
coroutineScope.launch {
40+
scrollState.animateScrollBy(
41+
SCROLL_MULTIPLIER * scrollState.viewportHeight(),
42+
)
43+
}
44+
true
45+
}
46+
47+
it.type == KeyEventType.KeyDown && it.key == Key.DirectionUp &&
48+
scrollState.canScrollBackward -> {
49+
coroutineScope.launch {
50+
scrollState.animateScrollBy(
51+
-SCROLL_MULTIPLIER * scrollState.viewportHeight(),
52+
)
53+
}
54+
true
55+
}
56+
57+
else -> false
58+
}
59+
}
60+
.focusRequester(focusRequester)
61+
.focusable(interactionSource = interactionSource)
62+
}
63+
64+
private fun ScrollableState.viewportHeight(): Float = when (this) {
65+
is LazyListState -> layoutInfo.viewportSize.height.toFloat()
66+
is ScrollState -> viewportSize.toFloat()
67+
else -> error("scrollable type not yet supported")
68+
}
69+
70+
private const val SCROLL_MULTIPLIER = 0.4f
71+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package uk.gov.android.ui.patterns.utils
2+
3+
import androidx.compose.foundation.layout.Column
4+
import androidx.compose.foundation.layout.fillMaxWidth
5+
import androidx.compose.foundation.layout.height
6+
import androidx.compose.foundation.lazy.LazyColumn
7+
import androidx.compose.foundation.lazy.LazyListState
8+
import androidx.compose.foundation.lazy.rememberLazyListState
9+
import androidx.compose.foundation.rememberScrollState
10+
import androidx.compose.foundation.text.BasicText
11+
import androidx.compose.foundation.verticalScroll
12+
import androidx.compose.runtime.Composable
13+
import androidx.compose.ui.Modifier
14+
import androidx.compose.ui.focus.FocusRequester
15+
import androidx.compose.ui.focus.focusRequester
16+
import androidx.compose.ui.input.InputMode
17+
import androidx.compose.ui.input.InputModeManager
18+
import androidx.compose.ui.input.key.Key
19+
import androidx.compose.ui.platform.LocalInputModeManager
20+
import androidx.compose.ui.platform.testTag
21+
import androidx.compose.ui.test.ExperimentalTestApi
22+
import androidx.compose.ui.test.assertIsDisplayed
23+
import androidx.compose.ui.test.assertIsNotDisplayed
24+
import androidx.compose.ui.test.junit4.ComposeTestRule
25+
import androidx.compose.ui.test.junit4.createComposeRule
26+
import androidx.compose.ui.test.onNodeWithTag
27+
import androidx.compose.ui.test.onNodeWithText
28+
import androidx.compose.ui.test.performKeyInput
29+
import androidx.compose.ui.test.pressKey
30+
import androidx.compose.ui.unit.dp
31+
import org.junit.Rule
32+
import org.junit.Test
33+
import org.junit.runner.RunWith
34+
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
39+
40+
@OptIn(ExperimentalTestApi::class)
41+
@RunWith(RobolectricTestRunner::class)
42+
class BringIntoViewTest {
43+
@get:Rule
44+
val composeTestRule = createComposeRule()
45+
46+
private val focusRequester = FocusRequester()
47+
48+
@Test
49+
fun `keyboard can scroll down and up for non-lazy list`() {
50+
setUpColumn()
51+
52+
composeTestRule.pressDirectionDown()
53+
composeTestRule.onNodeWithText(FIRST_ITEM).assertIsNotDisplayed()
54+
55+
composeTestRule.pressDirectionUp()
56+
composeTestRule.onNodeWithText(FIRST_ITEM).assertIsDisplayed()
57+
}
58+
59+
@Test
60+
fun `keyboard can scroll down then up for lazy list`() {
61+
setUpLazyColumn()
62+
63+
composeTestRule.pressDirectionDown()
64+
composeTestRule.onNodeWithText(FIRST_ITEM).assertIsNotDisplayed()
65+
66+
composeTestRule.pressDirectionUp()
67+
composeTestRule.onNodeWithText(FIRST_ITEM).assertIsDisplayed()
68+
}
69+
70+
@Test
71+
fun `keyboard does not scroll down when content fits`() {
72+
setUpColumn(numItems = 1)
73+
74+
composeTestRule.onNodeWithText(FIRST_ITEM).assertIsDisplayed()
75+
76+
composeTestRule.pressDirectionDown()
77+
composeTestRule.onNodeWithText(FIRST_ITEM).assertIsDisplayed()
78+
}
79+
80+
@Test
81+
fun `keyboard does not scroll up when already at top`() {
82+
setUpColumn()
83+
84+
composeTestRule.onNodeWithText(FIRST_ITEM).assertIsDisplayed()
85+
86+
composeTestRule.pressDirectionUp()
87+
composeTestRule.onNodeWithText(FIRST_ITEM).assertIsDisplayed()
88+
}
89+
90+
private fun setUpColumn(
91+
numItems: Int = NUM_ITEMS,
92+
) {
93+
setContent {
94+
val scrollState = rememberScrollState()
95+
TestColumn(
96+
scrollState = scrollState,
97+
numItems = numItems,
98+
modifier = Modifier
99+
.focusRequester(focusRequester)
100+
.bringIntoView(scrollState),
101+
)
102+
}
103+
}
104+
105+
private fun setUpLazyColumn() {
106+
setContent {
107+
val listState = rememberLazyListState()
108+
TestLazyColumn(
109+
listState = listState,
110+
modifier = Modifier
111+
.focusRequester(focusRequester)
112+
.bringIntoView(listState),
113+
)
114+
}
115+
}
116+
117+
private fun setContent(content: @Composable () -> Unit) {
118+
lateinit var inputModeManager: InputModeManager
119+
composeTestRule.setContent {
120+
inputModeManager = LocalInputModeManager.current
121+
content()
122+
}
123+
composeTestRule.runOnIdle {
124+
inputModeManager.requestInputMode(InputMode.Keyboard)
125+
focusRequester.requestFocus()
126+
}
127+
}
128+
129+
private fun ComposeTestRule.pressDirectionUp() =
130+
onNodeWithTag(TEST_TAG)
131+
.performKeyInput { pressKey(Key.DirectionUp) }
132+
133+
private fun ComposeTestRule.pressDirectionDown() =
134+
onNodeWithTag(TEST_TAG)
135+
.performKeyInput { pressKey(Key.DirectionDown) }
136+
137+
companion object {
138+
const val TEST_TAG = "bringIntoViewTest"
139+
const val NUM_ITEMS = 50
140+
const val FIRST_ITEM = "Item 0"
141+
val containerHeight = 100.dp
142+
val itemHeight = 30.dp
143+
}
144+
}
145+
146+
@Composable
147+
private fun TestColumn(
148+
scrollState: androidx.compose.foundation.ScrollState,
149+
modifier: Modifier = Modifier,
150+
numItems: Int = NUM_ITEMS,
151+
) =
152+
Column(
153+
modifier = modifier
154+
.height(containerHeight)
155+
.verticalScroll(scrollState)
156+
.testTag(BringIntoViewTest.TEST_TAG),
157+
) {
158+
repeat(numItems) { index ->
159+
BasicText(
160+
text = "Item $index",
161+
modifier = Modifier.fillMaxWidth().height(itemHeight),
162+
)
163+
}
164+
}
165+
166+
@Composable
167+
private fun TestLazyColumn(
168+
listState: LazyListState,
169+
modifier: Modifier = Modifier,
170+
numItems: Int = NUM_ITEMS,
171+
) =
172+
LazyColumn(
173+
state = listState,
174+
modifier = modifier
175+
.height(containerHeight)
176+
.testTag(BringIntoViewTest.TEST_TAG),
177+
) {
178+
items(numItems) { index ->
179+
BasicText(
180+
text = "Item $index",
181+
modifier = Modifier.fillMaxWidth().height(itemHeight),
182+
)
183+
}
184+
}

0 commit comments

Comments
 (0)