[go: nahoru, domu]

blob: 251138b8d5e40f2f79040f6e40cf224e1af0d4c7 [file] [log] [blame]
/*
* 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.text.selection
import android.view.View
import android.view.ViewGroup
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.Handle
import androidx.compose.foundation.text.TEST_FONT_FAMILY
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputFilter
import androidx.compose.ui.input.pointer.PointerInputModifier
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.SemanticsNodeInteractionsProvider
import androidx.compose.ui.test.click
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.hasAnyChild
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.isRoot
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.width
import androidx.test.espresso.matcher.BoundedMatcher
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.times
import com.nhaarman.mockitokotlin2.verify
import org.hamcrest.Description
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.CountDownLatch
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
@Suppress("DEPRECATION")
@LargeTest
@RunWith(AndroidJUnit4::class)
class SelectionContainerTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
private lateinit var view: View
private val textContent = "Text Demo Text"
private val fontFamily = TEST_FONT_FAMILY
private val selection = mutableStateOf<Selection?>(null)
private val fontSize = 20.sp
private val log = PointerInputChangeLog()
private val hapticFeedback = mock<HapticFeedback>()
@Test
@SdkSuppress(minSdkVersion = 27)
fun press_to_cancel() {
// Setup. Long press to create a selection.
// A reasonable number.
createSelectionContainer()
val position = 50f
rule.onSelectionContainer()
.performTouchInput { longClick(Offset(x = position, y = position)) }
rule.runOnIdle {
assertThat(selection.value).isNotNull()
}
// Act.
rule.onSelectionContainer()
.performTouchInput { click(Offset(x = position, y = position)) }
// Assert.
rule.runOnIdle {
assertThat(selection.value).isNull()
verify(
hapticFeedback,
times(2)
).performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
@Test
@SdkSuppress(minSdkVersion = 27)
fun tapToCancelDoesNotBlockUp() {
// Setup. Long press to create a selection.
// A reasonable number.
createSelectionContainer()
val position = 50f
rule.onSelectionContainer()
.performTouchInput { longClick(Offset(x = position, y = position)) }
log.entries.clear()
// Act.
rule.onSelectionContainer()
.performTouchInput { click(Offset(x = position, y = position)) }
// Assert.
rule.runOnIdle {
// We are interested in looking at the final up event.
assertThat(log.entries.last().pass).isEqualTo(PointerEventPass.Final)
assertThat(log.entries.last().changes).hasSize(1)
assertThat(log.entries.last().changes[0].changedToUp()).isTrue()
}
}
@Test
fun long_press_select_a_word() {
with(rule.density) {
// Setup.
// Long Press "m" in "Demo", and "Demo" should be selected.
createSelectionContainer()
val characterSize = fontSize.toPx()
val expectedLeftX = fontSize.toDp().times(textContent.indexOf('D'))
val expectedLeftY = fontSize.toDp()
val expectedRightX = fontSize.toDp().times(textContent.indexOf('o') + 1)
val expectedRightY = fontSize.toDp()
// Act.
rule.onSelectionContainer()
.performTouchInput {
longClick(
Offset(textContent.indexOf('m') * characterSize, 0.5f * characterSize)
)
}
// Assert. Should select "Demo".
rule.runOnIdle {
assertThat(selection.value!!.start.offset).isEqualTo(textContent.indexOf('D'))
assertThat(selection.value!!.end.offset).isEqualTo(textContent.indexOf('o') + 1)
verify(
hapticFeedback,
times(1)
).performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
// Check the position of the anchors of the selection handles. We don't need to compare
// to the absolute position since the semantics report selection relative to the
// container composable, not the screen.
rule.onNode(isSelectionHandle(Handle.SelectionStart))
.assertHandlePositionMatches(expectedLeftX, expectedLeftY)
rule.onNode(isSelectionHandle(Handle.SelectionEnd))
.assertHandlePositionMatches(expectedRightX, expectedRightY)
}
}
@Test
fun long_press_select_a_word_rtl_layout() {
with(rule.density) {
// Setup.
// Long Press "m" in "Demo", and "Demo" should be selected.
createSelectionContainer(isRtl = true)
val characterSize = fontSize.toPx()
val expectedLeftX = rule.rootWidth() - fontSize.toDp().times(textContent.length)
val expectedLeftY = fontSize.toDp()
val expectedRightX = rule.rootWidth() - fontSize.toDp().times(" Demo Text".length)
val expectedRightY = fontSize.toDp()
// Act.
rule.onSelectionContainer()
.performTouchInput {
longClick(
Offset(
rule.rootWidth().toSp().toPx() - ("xt Demo Text").length *
characterSize,
0.5f * characterSize
)
)
}
// Assert. Should select "Demo".
rule.runOnIdle {
assertThat(selection.value!!.start.offset).isEqualTo(textContent.indexOf('T'))
assertThat(selection.value!!.end.offset).isEqualTo(textContent.indexOf('t') + 1)
verify(
hapticFeedback,
times(1)
).performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
// Check the position of the anchors of the selection handles. We don't need to compare
// to the absolute position since the semantics report selection relative to the
// container composable, not the screen.
rule.onNode(isSelectionHandle(Handle.SelectionStart))
.assertHandlePositionMatches(expectedLeftX, expectedLeftY)
rule.onNode(isSelectionHandle(Handle.SelectionEnd))
.assertHandlePositionMatches(expectedRightX, expectedRightY)
}
}
private fun createSelectionContainer(isRtl: Boolean = false) {
val measureLatch = CountDownLatch(1)
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
rule.setContent {
CompositionLocalProvider(
LocalHapticFeedback provides hapticFeedback,
LocalLayoutDirection provides layoutDirection
) {
TestParent(Modifier.testTag("selectionContainer").gestureSpy(log)) {
SelectionContainer(
modifier = Modifier.onGloballyPositioned {
measureLatch.countDown()
},
selection = selection.value,
onSelectionChange = {
selection.value = it
}
) {
BasicText(
AnnotatedString(textContent),
Modifier.fillMaxSize(),
style = TextStyle(fontFamily = fontFamily, fontSize = fontSize),
softWrap = true,
overflow = TextOverflow.Clip,
maxLines = Int.MAX_VALUE,
inlineContent = mapOf(),
onTextLayout = {}
)
}
}
}
}
rule.activityRule.scenario.onActivity {
view = it.findViewById<ViewGroup>(android.R.id.content)
}
}
private fun matchesPosition(expectedX: Dp, expectedY: Dp): BoundedMatcher<View, View> {
return object : BoundedMatcher<View, View>(View::class.java) {
// (-1, -1) no position found
var positionFound = IntOffset(-1, -1)
override fun matchesSafely(item: View?): Boolean {
with(rule.density) {
val position = IntArray(2)
item?.getLocationOnScreen(position)
positionFound = IntOffset(position[0], position[1])
val expectedPositionXInt = expectedX.value.roundToInt()
val expectedPositionYInt = expectedY.value.roundToInt()
val positionFoundXInt = positionFound.x.toDp().value.roundToInt()
val positionFoundYInt = positionFound.y.toDp().value.roundToInt()
return abs(expectedPositionXInt - positionFoundXInt) < 5 &&
abs(expectedPositionYInt - positionFoundYInt) < 5
}
}
override fun describeTo(description: Description?) {
with(rule.density) {
description?.appendText(
"with expected position: " +
"${expectedX.value}, ${expectedY.value} " +
"but position found:" +
"${positionFound.x.toDp().value}, ${positionFound.y.toDp().value}"
)
}
}
}
}
}
private fun SemanticsNodeInteractionsProvider.onSelectionContainer() =
onNode(isRoot() and hasAnyChild(hasTestTag("selectionContainer")))
private fun ComposeTestRule.rootWidth(): Dp = onRoot().getUnclippedBoundsInRoot().width
private class PointerInputChangeLog : (PointerEvent, PointerEventPass) -> Unit {
val entries = mutableListOf<PointerInputChangeLogEntry>()
override fun invoke(p1: PointerEvent, p2: PointerEventPass) {
entries.add(PointerInputChangeLogEntry(p1.changes.map { it }, p2))
}
}
private data class PointerInputChangeLogEntry(
val changes: List<PointerInputChange>,
val pass: PointerEventPass
)
private fun Modifier.gestureSpy(
onPointerInput: (PointerEvent, PointerEventPass) -> Unit
): Modifier = composed {
val spy = remember { GestureSpy() }
spy.onPointerInput = onPointerInput
spy
}
private class GestureSpy : PointerInputModifier {
lateinit var onPointerInput: (PointerEvent, PointerEventPass) -> Unit
override val pointerInputFilter = object : PointerInputFilter() {
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
onPointerInput(pointerEvent, pass)
}
override fun onCancel() {
// Nothing to implement
}
}
}
@Composable
fun TestParent(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Layout(modifier = modifier, content = content) { measurables, constraints ->
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
val width = placeables.fold(0) { maxWidth, placeable ->
max(maxWidth, (placeable.width))
}
val height = placeables.fold(0) { minWidth, placeable ->
max(minWidth, (placeable.height))
}
layout(width, height) {
placeables.forEach { placeable ->
placeable.place(0, 0)
}
}
}
}