| /* |
| * Copyright 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.compose.foundation.textfield |
| |
| import android.os.Build |
| import androidx.compose.foundation.ExperimentalFoundationApi |
| import androidx.compose.foundation.InteractionState |
| import androidx.compose.foundation.ScrollState |
| import androidx.compose.foundation.background |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.Column |
| import androidx.compose.foundation.layout.preferredSize |
| import androidx.compose.foundation.layout.preferredWidth |
| import androidx.compose.foundation.text.BasicText |
| import androidx.compose.foundation.text.TextFieldScrollerPosition |
| import androidx.compose.foundation.text.TextLayoutResultProxy |
| import androidx.compose.foundation.text.maxLinesHeight |
| import androidx.compose.foundation.text.textFieldScroll |
| import androidx.compose.foundation.text.textFieldScrollable |
| import androidx.compose.foundation.verticalScroll |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.saveable.rememberSaveable |
| import androidx.compose.testutils.assertPixels |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.geometry.Offset |
| import androidx.compose.ui.gesture.scrollorientationlocking.Orientation |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.node.Ref |
| import androidx.compose.ui.platform.InspectableValue |
| import androidx.compose.ui.platform.LocalViewConfiguration |
| import androidx.compose.ui.platform.isDebugInspectorInfoEnabled |
| import androidx.compose.ui.platform.testTag |
| import androidx.compose.ui.test.captureToImage |
| import androidx.compose.ui.test.junit4.ComposeContentTestRule |
| import androidx.compose.ui.test.junit4.StateRestorationTester |
| import androidx.compose.ui.test.junit4.createComposeRule |
| import androidx.compose.ui.test.onNodeWithTag |
| import androidx.compose.ui.test.performGesture |
| import androidx.compose.ui.test.swipe |
| import androidx.compose.ui.test.swipeDown |
| import androidx.compose.ui.test.swipeLeft |
| import androidx.compose.ui.test.swipeRight |
| import androidx.compose.ui.test.swipeUp |
| import androidx.compose.ui.text.InternalTextApi |
| import androidx.compose.ui.text.TextLayoutResult |
| import androidx.compose.ui.text.TextStyle |
| import androidx.compose.ui.text.input.TextFieldValue |
| import androidx.compose.ui.text.input.VisualTransformation |
| import androidx.compose.ui.unit.IntSize |
| import androidx.compose.ui.unit.dp |
| import androidx.test.ext.junit.runners.AndroidJUnit4 |
| import androidx.test.filters.LargeTest |
| import androidx.test.filters.MediumTest |
| import androidx.test.filters.SdkSuppress |
| import com.google.common.truth.Truth.assertThat |
| import kotlinx.coroutines.runBlocking |
| import org.junit.After |
| import org.junit.Before |
| import org.junit.Rule |
| import org.junit.Test |
| import org.junit.runner.RunWith |
| |
| /** |
| * These tests are for testing the text field scrolling modifiers [Modifier.textFieldScroll] and |
| * [Modifier.textFieldScrollable] working together. |
| * The tests are structured in a way that |
| * - two modifiers are applied to the text which exposes its [TextLayoutResult] |
| * - swipe gesture applied |
| * - [TextFieldScrollerPosition] state is checked to see if scrolling happened |
| * Previously we were able to test using CoreTextField. But with the decoration box change these |
| * two modifiers are already applied to the CoreTextField internally. Therefore we have no access |
| * to the [TextFieldScrollerPosition] object anymore. As such, CoreTextField was replaced with |
| * [BasicText] which is equivalent for testing these modifiers |
| */ |
| |
| @MediumTest |
| @RunWith(AndroidJUnit4::class) |
| @OptIn(ExperimentalFoundationApi::class, InternalTextApi::class) |
| class TextFieldScrollTest { |
| |
| private val TextfieldTag = "textField" |
| |
| private val longText = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + |
| "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam," + |
| " quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. " + |
| "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu " + |
| "fugiat nulla pariatur." |
| |
| @get:Rule |
| val rule = createComposeRule() |
| |
| @Before |
| fun before() { |
| isDebugInspectorInfoEnabled = true |
| } |
| |
| @After |
| fun after() { |
| isDebugInspectorInfoEnabled = false |
| } |
| |
| @Test |
| fun textFieldScroll_horizontal_scrollable_withLongInput() { |
| val scrollerPosition = TextFieldScrollerPosition(Orientation.Horizontal) |
| |
| rule.setupHorizontallyScrollableContent( |
| scrollerPosition, longText, Modifier.preferredSize(width = 300.dp, height = 50.dp) |
| ) |
| |
| rule.runOnIdle { |
| assertThat(scrollerPosition.maximum).isLessThan(Float.POSITIVE_INFINITY) |
| assertThat(scrollerPosition.maximum).isGreaterThan(0f) |
| } |
| } |
| |
| @Test |
| fun textFieldScroll_vertical_scrollable_withLongInput() { |
| val scrollerPosition = TextFieldScrollerPosition() |
| |
| rule.setupVerticallyScrollableContent( |
| scrollerPosition = scrollerPosition, |
| text = longText, |
| modifier = Modifier.preferredSize(width = 300.dp, height = 50.dp) |
| ) |
| |
| rule.runOnIdle { |
| assertThat(scrollerPosition.maximum).isLessThan(Float.POSITIVE_INFINITY) |
| assertThat(scrollerPosition.maximum).isGreaterThan(0f) |
| } |
| } |
| |
| @Test |
| fun textFieldScroll_vertical_scrollable_withLongInput_whenMaxLinesProvided() { |
| val scrollerPosition = TextFieldScrollerPosition() |
| |
| rule.setupVerticallyScrollableContent( |
| modifier = Modifier.preferredWidth(100.dp), |
| scrollerPosition = scrollerPosition, |
| text = longText, |
| maxLines = 3 |
| ) |
| |
| rule.runOnIdle { |
| assertThat(scrollerPosition.maximum).isLessThan(Float.POSITIVE_INFINITY) |
| assertThat(scrollerPosition.maximum).isGreaterThan(0f) |
| } |
| } |
| |
| @Test |
| fun textFieldScroll_horizontal_notScrollable_withShortInput() { |
| val scrollerPosition = TextFieldScrollerPosition(Orientation.Horizontal) |
| |
| rule.setupHorizontallyScrollableContent( |
| scrollerPosition = scrollerPosition, |
| text = "text", |
| modifier = Modifier.preferredSize(width = 300.dp, height = 50.dp) |
| ) |
| |
| rule.runOnIdle { |
| assertThat(scrollerPosition.maximum).isEqualTo(0f) |
| } |
| } |
| |
| @Test |
| fun textFieldScroll_vertical_notScrollable_withShortInput() { |
| val scrollerPosition = TextFieldScrollerPosition() |
| |
| rule.setupVerticallyScrollableContent( |
| scrollerPosition = scrollerPosition, |
| text = "text", |
| modifier = Modifier.preferredSize(width = 300.dp, height = 100.dp) |
| ) |
| |
| rule.runOnIdle { |
| assertThat(scrollerPosition.maximum).isEqualTo(0f) |
| } |
| } |
| |
| @Test |
| @LargeTest |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| fun textField_singleLine_scrolledAndClipped() { |
| val parentSize = 200 |
| val textFieldSize = 50 |
| val tag = "OuterBox" |
| |
| with(rule.density) { |
| rule.setContent { |
| Box( |
| Modifier |
| .preferredSize(parentSize.toDp()) |
| .background(color = Color.White) |
| .testTag(tag) |
| ) { |
| ScrollableContent( |
| modifier = Modifier.preferredSize(textFieldSize.toDp()), |
| scrollerPosition = TextFieldScrollerPosition(Orientation.Horizontal), |
| text = longText, |
| isVertical = false |
| ) |
| } |
| } |
| } |
| |
| rule.runOnIdle {} |
| |
| rule.onNodeWithTag(tag) |
| .captureToImage() |
| .assertPixels(expectedSize = IntSize(parentSize, parentSize)) { position -> |
| if (position.x > textFieldSize && position.y > textFieldSize) Color.White else null |
| } |
| } |
| |
| @Test |
| @LargeTest |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) |
| fun textField_multiline_scrolledAndClipped() { |
| val parentSize = 200 |
| val textFieldSize = 50 |
| val tag = "OuterBox" |
| |
| with(rule.density) { |
| rule.setContent { |
| Box( |
| Modifier |
| .preferredSize(parentSize.toDp()) |
| .background(color = Color.White) |
| .testTag(tag) |
| ) { |
| ScrollableContent( |
| modifier = Modifier.preferredSize(textFieldSize.toDp()), |
| scrollerPosition = TextFieldScrollerPosition(), |
| text = longText, |
| isVertical = true |
| ) |
| } |
| } |
| } |
| |
| rule.runOnIdle {} |
| |
| rule.onNodeWithTag(tag) |
| .captureToImage() |
| .assertPixels(expectedSize = IntSize(parentSize, parentSize)) { position -> |
| if (position.x > textFieldSize && position.y > textFieldSize) Color.White else null |
| } |
| } |
| |
| @Test |
| fun textFieldScroll_horizontal_swipe_whenLongInput() { |
| val scrollerPosition = TextFieldScrollerPosition(Orientation.Horizontal) |
| |
| rule.setupHorizontallyScrollableContent( |
| scrollerPosition = scrollerPosition, |
| text = longText, |
| modifier = Modifier.preferredSize(width = 300.dp, height = 50.dp) |
| ) |
| |
| rule.runOnIdle { |
| assertThat(scrollerPosition.offset).isEqualTo(0f) |
| } |
| |
| rule.onNodeWithTag(TextfieldTag) |
| .performGesture { swipeLeft() } |
| |
| val firstSwipePosition = rule.runOnIdle { |
| scrollerPosition.offset |
| } |
| assertThat(firstSwipePosition).isGreaterThan(0f) |
| |
| rule.onNodeWithTag(TextfieldTag) |
| .performGesture { swipeRight() } |
| rule.runOnIdle { |
| assertThat(scrollerPosition.offset).isLessThan(firstSwipePosition) |
| } |
| } |
| |
| @Test |
| fun textFieldScroll_vertical_swipe_whenLongInput() { |
| val scrollerPosition = TextFieldScrollerPosition() |
| |
| rule.setupVerticallyScrollableContent( |
| scrollerPosition = scrollerPosition, |
| text = longText, |
| modifier = Modifier.preferredSize(width = 300.dp, height = 50.dp) |
| ) |
| |
| rule.runOnIdle { |
| assertThat(scrollerPosition.offset).isEqualTo(0f) |
| } |
| |
| rule.onNodeWithTag(TextfieldTag) |
| .performGesture { swipeUp() } |
| |
| val firstSwipePosition = rule.runOnIdle { |
| scrollerPosition.offset |
| } |
| assertThat(firstSwipePosition).isGreaterThan(0f) |
| |
| rule.onNodeWithTag(TextfieldTag) |
| .performGesture { swipeDown() } |
| rule.runOnIdle { |
| assertThat(scrollerPosition.offset).isLessThan(firstSwipePosition) |
| } |
| } |
| |
| @Test |
| fun textFieldScroll_restoresScrollerPosition() { |
| val restorationTester = StateRestorationTester(rule) |
| var scrollerPosition: TextFieldScrollerPosition? = null |
| |
| restorationTester.setContent { |
| scrollerPosition = rememberSaveable( |
| saver = TextFieldScrollerPosition.Saver |
| ) { |
| TextFieldScrollerPosition(Orientation.Horizontal) |
| } |
| ScrollableContent( |
| modifier = Modifier.preferredSize(width = 300.dp, height = 50.dp), |
| scrollerPosition = scrollerPosition!!, |
| text = longText, |
| isVertical = false |
| ) |
| } |
| |
| rule.onNodeWithTag(TextfieldTag) |
| .performGesture { swipeLeft() } |
| |
| val swipePosition = rule.runOnIdle { |
| scrollerPosition!!.offset |
| } |
| assertThat(swipePosition).isGreaterThan(0f) |
| |
| rule.runOnIdle { |
| scrollerPosition = TextFieldScrollerPosition() |
| assertThat(scrollerPosition!!.offset).isEqualTo(0f) |
| } |
| |
| restorationTester.emulateSavedInstanceStateRestore() |
| |
| rule.runOnIdle { |
| assertThat(scrollerPosition!!.offset).isEqualTo(swipePosition) |
| } |
| } |
| |
| @Test |
| fun textFieldScrollable_testInspectorValue() { |
| val position = TextFieldScrollerPosition(Orientation.Vertical, 10f) |
| val interactionState = InteractionState() |
| rule.setContent { |
| val modifier = |
| Modifier.textFieldScrollable(position, interactionState) as InspectableValue |
| assertThat(modifier.nameFallback).isEqualTo("textFieldScrollable") |
| assertThat(modifier.valueOverride).isNull() |
| assertThat(modifier.inspectableElements.map { it.name }.asIterable()).containsExactly( |
| "scrollerPosition", |
| "interactionState", |
| "enabled" |
| ) |
| } |
| } |
| |
| @Test |
| fun textFieldScroll_testNestedScrolling() = runBlocking { |
| val size = 300.dp |
| val text = """ |
| First Line |
| Second Line |
| Third Line |
| Fourth Line |
| """.trimIndent() |
| |
| val textFieldScrollPosition = TextFieldScrollerPosition() |
| val scrollerPosition = ScrollState(0f) |
| var touchSlop = 0f |
| val height = 60.dp |
| |
| rule.setContent { |
| touchSlop = LocalViewConfiguration.current.touchSlop |
| Column( |
| Modifier |
| .preferredSize(size) |
| .verticalScroll(scrollerPosition) |
| ) { |
| ScrollableContent( |
| modifier = Modifier.preferredSize(size, height), |
| scrollerPosition = textFieldScrollPosition, |
| text = text, |
| isVertical = true |
| ) |
| Box(Modifier.preferredSize(size)) |
| Box(Modifier.preferredSize(size)) |
| } |
| } |
| |
| assertThat(textFieldScrollPosition.offset).isEqualTo(0f) |
| assertThat(textFieldScrollPosition.maximum).isGreaterThan(0f) |
| assertThat(scrollerPosition.value).isEqualTo(0f) |
| |
| with(rule.density) { |
| val x = 10.dp.toPx() |
| val desiredY = textFieldScrollPosition.maximum + 10.dp.roundToPx() |
| val nearEdge = (height - 1.dp) |
| // not to exceed size |
| val slopStartY = minOf(desiredY + touchSlop, nearEdge.toPx()) |
| val slopStart = Offset(x, slopStartY) |
| val end = Offset(x, 0f) |
| rule.onNodeWithTag(TextfieldTag) |
| .performGesture { |
| swipe(slopStart, end) |
| } |
| } |
| |
| assertThat(textFieldScrollPosition.offset).isGreaterThan(0f) |
| assertThat(textFieldScrollPosition.offset) |
| .isWithin(0.5f).of(textFieldScrollPosition.maximum) |
| assertThat(scrollerPosition.value).isGreaterThan(0f) |
| } |
| |
| private fun ComposeContentTestRule.setupHorizontallyScrollableContent( |
| scrollerPosition: TextFieldScrollerPosition, |
| text: String, |
| modifier: Modifier = Modifier |
| ) { |
| setContent { |
| ScrollableContent( |
| scrollerPosition = scrollerPosition, |
| text = text, |
| isVertical = false, |
| modifier = modifier, |
| maxLines = 1 |
| ) |
| } |
| } |
| |
| private fun ComposeContentTestRule.setupVerticallyScrollableContent( |
| scrollerPosition: TextFieldScrollerPosition, |
| text: String, |
| modifier: Modifier = Modifier, |
| maxLines: Int = Int.MAX_VALUE |
| ) { |
| setContent { |
| ScrollableContent( |
| scrollerPosition = scrollerPosition, |
| text = text, |
| isVertical = true, |
| modifier = modifier, |
| maxLines = maxLines |
| ) |
| } |
| } |
| |
| @Composable |
| private fun ScrollableContent( |
| modifier: Modifier, |
| scrollerPosition: TextFieldScrollerPosition, |
| text: String, |
| isVertical: Boolean, |
| maxLines: Int = Int.MAX_VALUE |
| ) { |
| val textLayoutResultRef: Ref<TextLayoutResultProxy?> = remember { Ref() } |
| val resolvedMaxLines = if (isVertical) maxLines else 1 |
| |
| BasicText( |
| text = text, |
| onTextLayout = { |
| textLayoutResultRef.value = TextLayoutResultProxy(it) |
| }, |
| softWrap = isVertical, |
| modifier = modifier |
| .testTag(TextfieldTag) |
| .maxLinesHeight(resolvedMaxLines, TextStyle.Default) |
| .textFieldScrollable(scrollerPosition) |
| .textFieldScroll( |
| remember { scrollerPosition }, |
| TextFieldValue(text), |
| VisualTransformation.None, |
| { textLayoutResultRef.value } |
| ) |
| ) |
| } |
| } |