[go: nahoru, domu]

blob: 57f20cd02aebaa24347a65cc111a612618ba0cec [file] [log] [blame]
/*
* Copyright 2019 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.ui.core
import android.content.Context
import android.view.MotionEvent
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.compose.Composable
import androidx.compose.ExperimentalComposeApi
import androidx.compose.Recomposer
import androidx.compose.emptyContent
import androidx.compose.getValue
import androidx.compose.mutableStateOf
import androidx.compose.remember
import androidx.compose.setValue
import androidx.compose.snapshots.Snapshot
import androidx.test.filters.SmallTest
import androidx.ui.core.pointerinput.PointerInputFilter
import androidx.ui.core.pointerinput.PointerInputModifier
import androidx.compose.ui.geometry.Offset
import androidx.ui.testutils.down
import androidx.ui.unit.IntSize
import androidx.ui.unit.milliseconds
import androidx.ui.viewinterop.AndroidView
import com.google.common.truth.Truth.assertThat
import com.nhaarman.mockitokotlin2.any
import com.nhaarman.mockitokotlin2.never
import com.nhaarman.mockitokotlin2.spy
import com.nhaarman.mockitokotlin2.verify
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
@SmallTest
@RunWith(JUnit4::class)
class AndroidPointerInputTest {
@Suppress("DEPRECATION")
@get:Rule
val rule = androidx.test.rule.ActivityTestRule<AndroidPointerInputTestActivity>(
AndroidPointerInputTestActivity::class.java
)
private lateinit var androidComposeView: AndroidComposeView
private lateinit var container: ViewGroup
@Before
fun setup() {
val activity = rule.activity
container = spy(FrameLayout(activity)).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
rule.runOnUiThread {
activity.setContentView(container)
}
}
@Test
fun dispatchTouchEvent_noPointerInputModifiers_returnsFalse() {
// Arrange
countDown { latch ->
rule.runOnUiThread {
container.setContent(Recomposer.current()) {
FillLayout(Modifier
.onPositioned { latch.countDown() })
}
}
}
rule.runOnUiThread {
androidComposeView = container.getChildAt(0) as AndroidComposeView
val motionEvent = MotionEvent(
0,
MotionEvent.ACTION_DOWN,
1,
0,
arrayOf(PointerProperties(0)),
arrayOf(PointerCoords(0f, 0f))
)
// Act
val actual = androidComposeView.dispatchTouchEvent(motionEvent)
// Assert
assertThat(actual).isFalse()
}
}
@Test
fun dispatchTouchEvent_pointerInputModifier_returnsTrue() {
// Arrange
countDown { latch ->
rule.runOnUiThread {
container.setContent(Recomposer.current()) {
FillLayout(Modifier
.consumeMovementGestureFilter()
.onPositioned { latch.countDown() })
}
}
}
rule.runOnUiThread {
androidComposeView = container.getChildAt(0) as AndroidComposeView
val locationInWindow = IntArray(2).also {
androidComposeView.getLocationInWindow(it)
}
val motionEvent = MotionEvent(
0,
MotionEvent.ACTION_DOWN,
1,
0,
arrayOf(PointerProperties(0)),
arrayOf(PointerCoords(locationInWindow[0].toFloat(), locationInWindow[1].toFloat()))
)
// Act
val actual = androidComposeView.dispatchTouchEvent(motionEvent)
// Assert
assertThat(actual).isTrue()
}
}
@Test
fun dispatchTouchEvent_movementNotConsumed_requestDisallowInterceptTouchEventNotCalled() {
dispatchTouchEvent_movementConsumptionInCompose(
consumeMovement = false,
callsRequestDisallowInterceptTouchEvent = false
)
}
@Test
fun dispatchTouchEvent_movementConsumed_requestDisallowInterceptTouchEventCalled() {
dispatchTouchEvent_movementConsumptionInCompose(
consumeMovement = true,
callsRequestDisallowInterceptTouchEvent = true
)
}
@Test
fun dispatchTouchEvent_notMeasuredLayoutsAreMeasuredFirst() {
val size = mutableStateOf(10)
val latch = CountDownLatch(1)
var consumedDownPosition: Offset? = null
rule.runOnUiThread {
container.setContent(Recomposer.current()) {
Layout(
{},
Modifier
.consumeDownGestureFilter {
consumedDownPosition = it
}
.onPositioned {
latch.countDown()
}
) { _, _ ->
val sizePx = size.value
layout(sizePx, sizePx) {}
}
}
}
assertThat(latch.await(1, TimeUnit.SECONDS)).isTrue()
rule.runOnUiThread {
androidComposeView = container.getChildAt(0) as AndroidComposeView
// we update size from 10 to 20 pixels
size.value = 20
// this call will synchronously mark the LayoutNode as needs remeasure
@OptIn(ExperimentalComposeApi::class)
Snapshot.sendApplyNotifications()
val ownerPosition = androidComposeView.calculatePosition()
val motionEvent = MotionEvent(
0,
MotionEvent.ACTION_DOWN,
1,
0,
arrayOf(PointerProperties(0)),
arrayOf(PointerCoords(ownerPosition.x + 15f, ownerPosition.y + 15f))
)
// we expect it to first remeasure and only then process
androidComposeView.dispatchTouchEvent(motionEvent)
assertThat(consumedDownPosition).isEqualTo(Offset(15f, 15f))
}
}
// Currently ignored because it fails when run via command line. Runs successfully in Android
// Studio.
@Test
// TODO(b/158099918): For some reason, this test fails when run from command line but passes
// when run from Android Studio. This seems to be caused by b/158099918. Once that is
// fixed, @Ignore can be removed.
@Ignore
fun dispatchTouchEvent_throughLayersOfAndroidAndCompose_hitsChildPointerInputFilter() {
// Arrange
val context = rule.activity
val log = mutableListOf<List<PointerInputChange>>()
countDown { latch ->
rule.runOnUiThread {
container.setContent(Recomposer.current()) {
AndroidWithCompose(context, 1) {
AndroidWithCompose(context, 10) {
AndroidWithCompose(context, 100) {
Layout(
{},
Modifier
.logEventsGestureFilter(log)
.onPositioned {
latch.countDown()
}
) { _, _ ->
layout(5, 5) {}
}
}
}
}
}
}
}
rule.runOnUiThread {
androidComposeView = container.getChildAt(0) as AndroidComposeView
val locationInWindow = IntArray(2).also {
androidComposeView.getLocationInWindow(it)
}
val motionEvent = MotionEvent(
0,
MotionEvent.ACTION_DOWN,
1,
0,
arrayOf(PointerProperties(0)),
arrayOf(
PointerCoords(
locationInWindow[0].toFloat() + 1 + 10 + 100,
locationInWindow[1].toFloat() + 1 + 10 + 100
)
)
)
// Act
androidComposeView.dispatchTouchEvent(motionEvent)
// Assert
assertThat(log).hasSize(1)
assertThat(log[0]).isEqualTo(listOf(down(0, 0.milliseconds, 0f, 0f)))
}
}
private fun dispatchTouchEvent_movementConsumptionInCompose(
consumeMovement: Boolean,
callsRequestDisallowInterceptTouchEvent: Boolean
) {
// Arrange
countDown { latch ->
rule.runOnUiThread {
container.setContent(Recomposer.current()) {
FillLayout(Modifier
.consumeMovementGestureFilter(consumeMovement)
.onPositioned { latch.countDown() })
}
}
}
rule.runOnUiThread {
androidComposeView = container.getChildAt(0) as AndroidComposeView
val (x, y) = IntArray(2).let { array ->
androidComposeView.getLocationInWindow(array)
array.map { item -> item.toFloat() }
}
val down = MotionEvent(
0,
MotionEvent.ACTION_DOWN,
1,
0,
arrayOf(PointerProperties(0)),
arrayOf(PointerCoords(x, y))
)
val move = MotionEvent(
0,
MotionEvent.ACTION_MOVE,
1,
0,
arrayOf(PointerProperties(0)),
arrayOf(PointerCoords(x + 1, y))
)
androidComposeView.dispatchTouchEvent(down)
// Act
androidComposeView.dispatchTouchEvent(move)
// Assert
if (callsRequestDisallowInterceptTouchEvent) {
verify(container).requestDisallowInterceptTouchEvent(true)
} else {
verify(container, never()).requestDisallowInterceptTouchEvent(any())
}
}
}
}
@Suppress("TestFunctionName")
@Composable
fun AndroidWithCompose(context: Context, androidPadding: Int, children: @Composable () -> Unit) {
val anotherLayout = FrameLayout(context).also { view ->
view.setContent(Recomposer.current()) {
children()
}
view.setPadding(androidPadding, androidPadding, androidPadding, androidPadding)
}
AndroidView(anotherLayout)
}
fun Modifier.consumeMovementGestureFilter(consumeMovement: Boolean = false): Modifier = composed {
val filter = remember(consumeMovement) { ConsumeMovementGestureFilter(consumeMovement) }
PointerInputModifierImpl(filter)
}
fun Modifier.consumeDownGestureFilter(onDown: (Offset) -> Unit): Modifier = composed {
val filter = remember { ConsumeDownChangeFilter() }
filter.onDown = onDown
this + PointerInputModifierImpl(filter)
}
fun Modifier.logEventsGestureFilter(log: MutableList<List<PointerInputChange>>): Modifier =
composed {
val filter = remember { LogEventsGestureFilter(log) }
this + PointerInputModifierImpl(filter)
}
private class PointerInputModifierImpl(override val pointerInputFilter: PointerInputFilter) :
PointerInputModifier
private class ConsumeMovementGestureFilter(val consumeMovement: Boolean) : PointerInputFilter() {
override fun onPointerInput(
changes: List<PointerInputChange>,
pass: PointerEventPass,
bounds: IntSize
) =
if (consumeMovement) {
changes.map { it.consumePositionChange(
it.positionChange().x,
it.positionChange().y)
}
} else {
changes
}
override fun onCancel() {}
}
private class ConsumeDownChangeFilter : PointerInputFilter() {
var onDown by mutableStateOf<(Offset) -> Unit>({})
override fun onPointerInput(
changes: List<PointerInputChange>,
pass: PointerEventPass,
bounds: IntSize
) = changes.map {
if (it.changedToDown()) {
onDown(it.current.position!!)
it.consumeDownChange()
} else {
it
}
}
override fun onCancel() {}
}
private class LogEventsGestureFilter(val log: MutableList<List<PointerInputChange>>) :
PointerInputFilter() {
override fun onPointerInput(
changes: List<PointerInputChange>,
pass: PointerEventPass,
bounds: IntSize
): List<PointerInputChange> {
if (pass == PointerEventPass.InitialDown) {
log.add(changes.map { it.copy() })
}
return changes
}
override fun onCancel() {}
}
@Suppress("TestFunctionName")
@Composable
private fun FillLayout(modifier: Modifier = Modifier) {
Layout(emptyContent(), modifier) { _, constraints ->
layout(constraints.maxWidth, constraints.maxHeight) {}
}
}
private fun countDown(block: (CountDownLatch) -> Unit) {
val countDownLatch = CountDownLatch(1)
block(countDownLatch)
assertThat(countDownLatch.await(1, TimeUnit.SECONDS)).isTrue()
}
class AndroidPointerInputTestActivity : ComponentActivity()
@Suppress("SameParameterValue", "TestFunctionName")
private fun MotionEvent(
eventTime: Int,
action: Int,
numPointers: Int,
actionIndex: Int,
pointerProperties: Array<MotionEvent.PointerProperties>,
pointerCoords: Array<MotionEvent.PointerCoords>
) = MotionEvent.obtain(
0,
eventTime.toLong(),
action + (actionIndex shl MotionEvent.ACTION_POINTER_INDEX_SHIFT),
numPointers,
pointerProperties,
pointerCoords,
0,
0,
0f,
0f,
0,
0,
0,
0
)
@Suppress("SameParameterValue", "TestFunctionName")
private fun PointerProperties(id: Int) =
MotionEvent.PointerProperties().apply { this.id = id }
@Suppress("SameParameterValue", "TestFunctionName")
private fun PointerCoords(x: Float, y: Float) =
MotionEvent.PointerCoords().apply {
this.x = x
this.y = y
}