| /* |
| * Copyright 2023 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.ui.viewinterop |
| |
| import android.content.Context |
| import android.util.AttributeSet |
| import android.view.MotionEvent |
| import android.view.VelocityTracker |
| import android.view.View |
| import androidx.activity.ComponentActivity |
| import androidx.annotation.LayoutRes |
| import androidx.compose.foundation.gestures.Orientation |
| import androidx.compose.foundation.gestures.awaitEachGesture |
| import androidx.compose.foundation.gestures.awaitFirstDown |
| import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation |
| import androidx.compose.foundation.gestures.draggable |
| import androidx.compose.foundation.gestures.rememberDraggableState |
| import androidx.compose.foundation.layout.Box |
| import androidx.compose.foundation.layout.fillMaxSize |
| import androidx.compose.runtime.Composable |
| import androidx.compose.runtime.CompositionLocalProvider |
| import androidx.compose.ui.ExperimentalComposeUiApi |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.background |
| import androidx.compose.ui.graphics.Color |
| import androidx.compose.ui.input.pointer.AwaitPointerEventScope |
| import androidx.compose.ui.input.pointer.PointerEventPass |
| import androidx.compose.ui.input.pointer.PointerId |
| import androidx.compose.ui.input.pointer.PointerInputChange |
| import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed |
| import androidx.compose.ui.input.pointer.pointerInput |
| import androidx.compose.ui.input.pointer.positionChangedIgnoreConsumed |
| import androidx.compose.ui.input.pointer.util.VelocityTrackerAddPointsFix |
| import androidx.compose.ui.input.pointer.util.addPointerInputChange |
| import androidx.compose.ui.platform.ComposeView |
| import androidx.compose.ui.platform.LocalViewConfiguration |
| import androidx.compose.ui.platform.ViewConfiguration |
| import androidx.compose.ui.test.junit4.createAndroidComposeRule |
| import androidx.compose.ui.tests.R |
| import androidx.compose.ui.unit.Velocity |
| import androidx.compose.ui.util.fastFirstOrNull |
| import androidx.lifecycle.Lifecycle |
| import androidx.test.core.app.ActivityScenario |
| import androidx.test.espresso.Espresso |
| import androidx.test.espresso.UiController |
| import androidx.test.espresso.action.CoordinatesProvider |
| import androidx.test.espresso.action.GeneralLocation |
| import androidx.test.espresso.action.GeneralSwipeAction |
| import androidx.test.espresso.action.MotionEvents |
| import androidx.test.espresso.action.Press |
| import androidx.test.espresso.action.Swiper |
| import androidx.test.espresso.matcher.ViewMatchers.withId |
| import androidx.test.ext.junit.runners.AndroidJUnit4 |
| import androidx.test.filters.MediumTest |
| import com.google.common.truth.Truth.assertThat |
| import com.google.errorprone.annotations.CanIgnoreReturnValue |
| import kotlin.math.absoluteValue |
| import kotlin.test.assertTrue |
| import kotlinx.coroutines.coroutineScope |
| import org.junit.Before |
| import org.junit.Ignore |
| import org.junit.Rule |
| import org.junit.Test |
| import org.junit.runner.RunWith |
| |
| @MediumTest |
| @RunWith(AndroidJUnit4::class) |
| class VelocityTrackingParityTest { |
| |
| @get:Rule |
| val rule = createAndroidComposeRule<ComponentActivity>() |
| |
| private val draggableView: VelocityTrackingView |
| get() = rule.activity.findViewById(R.id.draggable_view) |
| |
| private val composeView: ComposeView |
| get() = rule.activity.findViewById(R.id.compose_view) |
| |
| private var latestComposeVelocity = Velocity.Zero |
| |
| @OptIn(ExperimentalComposeUiApi::class) |
| @Before |
| fun setUp() { |
| latestComposeVelocity = Velocity.Zero |
| VelocityTrackerAddPointsFix = true |
| } |
| |
| fun tearDown() { |
| draggableView.tearDown() |
| } |
| |
| @Test |
| fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_smallVeryFast() { |
| // Arrange |
| createActivity() |
| checkVisibility(composeView, View.GONE) |
| checkVisibility(draggableView, View.VISIBLE) |
| |
| // Act: Use system to send motion events and collect them. |
| smallGestureVeryFast(R.id.draggable_view) |
| |
| val latestVelocityInViewX = draggableView.latestVelocity.x |
| val latestVelocityInViewY = draggableView.latestVelocity.y |
| |
| // switch visibility |
| rule.runOnUiThread { |
| composeView.visibility = View.VISIBLE |
| draggableView.visibility = View.GONE |
| } |
| |
| checkVisibility(composeView, View.VISIBLE) |
| checkVisibility(draggableView, View.GONE) |
| |
| assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) } |
| |
| // Inject the same events in compose view |
| for (event in draggableView.motionEvents) { |
| composeView.dispatchTouchEvent(event) |
| } |
| |
| // assert |
| assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX) |
| assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY) |
| } |
| |
| @Test |
| fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_smallFast() { |
| // Arrange |
| createActivity() |
| checkVisibility(composeView, View.GONE) |
| checkVisibility(draggableView, View.VISIBLE) |
| |
| // Act: Use system to send motion events and collect them. |
| smallGestureFast(R.id.draggable_view) |
| |
| val latestVelocityInViewX = draggableView.latestVelocity.x |
| val latestVelocityInViewY = draggableView.latestVelocity.y |
| |
| // switch visibility |
| rule.runOnUiThread { |
| composeView.visibility = View.VISIBLE |
| draggableView.visibility = View.GONE |
| } |
| |
| checkVisibility(composeView, View.VISIBLE) |
| checkVisibility(draggableView, View.GONE) |
| |
| assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) } |
| |
| // Inject the same events in compose view |
| for (event in draggableView.motionEvents) { |
| composeView.dispatchTouchEvent(event) |
| } |
| |
| // assert |
| assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX) |
| assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY) |
| } |
| |
| @Test |
| fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_smallSlow() { |
| // Arrange |
| createActivity() |
| checkVisibility(composeView, View.GONE) |
| checkVisibility(draggableView, View.VISIBLE) |
| |
| // Act: Use system to send motion events and collect them. |
| smallGestureSlow(R.id.draggable_view) |
| |
| val latestVelocityInViewX = draggableView.latestVelocity.x |
| val latestVelocityInViewY = draggableView.latestVelocity.y |
| |
| // switch visibility |
| rule.runOnUiThread { |
| composeView.visibility = View.VISIBLE |
| draggableView.visibility = View.GONE |
| } |
| |
| checkVisibility(composeView, View.VISIBLE) |
| checkVisibility(draggableView, View.GONE) |
| |
| assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) } |
| |
| // Inject the same events in compose view |
| for (event in draggableView.motionEvents) { |
| composeView.dispatchTouchEvent(event) |
| } |
| // assert |
| assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX) |
| assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY) |
| } |
| |
| @Test |
| fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_largeFast() { |
| // Arrange |
| createActivity() |
| checkVisibility(composeView, View.GONE) |
| checkVisibility(draggableView, View.VISIBLE) |
| |
| // Act: Use system to send motion events and collect them. |
| largeGestureFast(R.id.draggable_view) |
| |
| val latestVelocityInViewX = draggableView.latestVelocity.x |
| val latestVelocityInViewY = draggableView.latestVelocity.y |
| |
| // switch visibility |
| rule.runOnUiThread { |
| composeView.visibility = View.VISIBLE |
| draggableView.visibility = View.GONE |
| } |
| |
| checkVisibility(composeView, View.VISIBLE) |
| checkVisibility(draggableView, View.GONE) |
| |
| assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) } |
| |
| // Inject the same events in compose view |
| for (event in draggableView.motionEvents) { |
| composeView.dispatchTouchEvent(event) |
| } |
| |
| // assert |
| assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX) |
| assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY) |
| } |
| |
| @Test |
| fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_largeVeryFast() { |
| // Arrange |
| createActivity() |
| checkVisibility(composeView, View.GONE) |
| checkVisibility(draggableView, View.VISIBLE) |
| |
| // Act: Use system to send motion events and collect them. |
| largeGestureVeryFast(R.id.draggable_view) |
| |
| val latestVelocityInViewX = draggableView.latestVelocity.x |
| val latestVelocityInViewY = draggableView.latestVelocity.y |
| |
| // switch visibility |
| rule.runOnUiThread { |
| composeView.visibility = View.VISIBLE |
| draggableView.visibility = View.GONE |
| } |
| |
| checkVisibility(composeView, View.VISIBLE) |
| checkVisibility(draggableView, View.GONE) |
| |
| assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) } |
| |
| // Inject the same events in compose view |
| for (event in draggableView.motionEvents) { |
| composeView.dispatchTouchEvent(event) |
| } |
| |
| // assert |
| assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX) |
| assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY) |
| } |
| |
| @Test |
| @Ignore("b/299092669") |
| fun equalDraggable_withEqualSwipes_shouldProduceSimilarVelocity_orthogonal() { |
| // Arrange |
| createActivity(true) |
| checkVisibility(composeView, View.GONE) |
| checkVisibility(draggableView, View.VISIBLE) |
| |
| // Act: Use system to send motion events and collect them. |
| orthogonalGesture(R.id.draggable_view) |
| |
| val latestVelocityInViewX = draggableView.latestVelocity.x |
| val latestVelocityInViewY = draggableView.latestVelocity.y |
| |
| // switch visibility |
| rule.runOnUiThread { |
| composeView.visibility = View.VISIBLE |
| draggableView.visibility = View.GONE |
| } |
| |
| checkVisibility(composeView, View.VISIBLE) |
| checkVisibility(draggableView, View.GONE) |
| |
| assertTrue { isValidGesture(draggableView.motionEvents.filterNotNull()) } |
| |
| // Inject the same events in compose view |
| for (event in draggableView.motionEvents) { |
| composeView.dispatchTouchEvent(event) |
| } |
| |
| // assert |
| assertIsWithinTolerance(latestComposeVelocity.x, latestVelocityInViewX) |
| assertIsWithinTolerance(latestComposeVelocity.y, latestVelocityInViewY) |
| } |
| |
| private fun createActivity(twoDimensional: Boolean = false) { |
| rule |
| .activityRule |
| .scenario |
| .createActivityWithComposeContent( |
| R.layout.velocity_tracker_compose_vs_view |
| ) { |
| TestComposeDraggable(twoDimensional) { |
| latestComposeVelocity = it |
| } |
| } |
| } |
| |
| private fun checkVisibility(view: View, visibility: Int) = assertTrue { |
| view.visibility == visibility |
| } |
| |
| private fun assertIsWithinTolerance(composeVelocity: Float, viewVelocity: Float) { |
| if (composeVelocity.absoluteValue > 1f && viewVelocity.absoluteValue > 1f) { |
| val tolerance = VelocityDifferenceTolerance * kotlin.math.abs(viewVelocity) |
| assertThat(composeVelocity).isWithin(tolerance).of(viewVelocity) |
| } else { |
| assertThat(composeVelocity.toInt()).isEqualTo(viewVelocity.toInt()) |
| } |
| } |
| } |
| |
| internal fun smallGestureVeryFast(id: Int) { |
| Espresso.onView(withId(id)) |
| .perform( |
| espressoSwipe( |
| SwiperWithTime(15), |
| GeneralLocation.CENTER, |
| GeneralLocation.translate(GeneralLocation.CENTER, 0f, -50f) |
| ) |
| ) |
| } |
| |
| internal fun smallGestureFast(id: Int) { |
| Espresso.onView(withId(id)) |
| .perform( |
| espressoSwipe( |
| SwiperWithTime(25), |
| GeneralLocation.CENTER, |
| GeneralLocation.translate(GeneralLocation.CENTER, 0f, -50f) |
| ) |
| ) |
| } |
| |
| internal fun smallGestureSlow(id: Int) { |
| Espresso.onView(withId(id)) |
| .perform( |
| espressoSwipe( |
| SwiperWithTime(200), |
| GeneralLocation.CENTER, |
| GeneralLocation.translate(GeneralLocation.CENTER, 0f, -50f) |
| ) |
| ) |
| } |
| |
| internal fun largeGestureFast(id: Int) { |
| Espresso.onView(withId(id)) |
| .perform( |
| espressoSwipe( |
| SwiperWithTime(25), |
| GeneralLocation.CENTER, |
| GeneralLocation.translate(GeneralLocation.CENTER, 0f, -500f) |
| ) |
| ) |
| } |
| |
| internal fun largeGestureVeryFast(id: Int) { |
| Espresso.onView(withId(id)) |
| .perform( |
| espressoSwipe( |
| SwiperWithTime(15), |
| GeneralLocation.CENTER, |
| GeneralLocation.translate(GeneralLocation.CENTER, 0f, -500f) |
| ) |
| ) |
| } |
| |
| internal fun orthogonalGesture(id: Int) { |
| Espresso.onView(withId(id)) |
| .perform( |
| espressoSwipe( |
| SwiperWithTime(50), |
| GeneralLocation.CENTER, |
| GeneralLocation.translate(GeneralLocation.CENTER, -200f, -200f) |
| ) |
| ) |
| } |
| |
| private fun espressoSwipe( |
| swiper: Swiper, |
| start: CoordinatesProvider, |
| end: CoordinatesProvider |
| ): GeneralSwipeAction { |
| return GeneralSwipeAction( |
| swiper, start, end, |
| Press.FINGER |
| ) |
| } |
| |
| @Composable |
| fun TestComposeDraggable( |
| twoDimensional: Boolean = false, |
| onDragStopped: (velocity: Velocity) -> Unit |
| ) { |
| val viewConfiguration = object : ViewConfiguration by LocalViewConfiguration.current { |
| override val maximumFlingVelocity: Float get() = Float.MAX_VALUE // unlimited |
| } |
| CompositionLocalProvider(LocalViewConfiguration provides viewConfiguration) { |
| Box( |
| Modifier |
| .fillMaxSize() |
| .background(Color.Black) |
| .then( |
| if (twoDimensional) { |
| Modifier.draggable2D(onDragStopped) |
| } else { |
| Modifier.draggable( |
| rememberDraggableState(onDelta = { }), |
| onDragStopped = { onDragStopped.invoke(Velocity(0.0f, it)) }, |
| orientation = Orientation.Vertical |
| ) |
| } |
| ) |
| ) |
| } |
| } |
| |
| fun Modifier.draggable2D(onDragStopped: (Velocity) -> Unit) = |
| this.pointerInput(Unit) { |
| coroutineScope { |
| awaitEachGesture { |
| val tracker = androidx.compose.ui.input.pointer.util.VelocityTracker() |
| val initialDown = |
| awaitFirstDown( |
| requireUnconsumed = false, |
| pass = PointerEventPass.Initial |
| ) |
| tracker.addPointerInputChange(initialDown) |
| |
| awaitTouchSlopOrCancellation(initialDown.id) { change, _ -> |
| tracker.addPointerInputChange(change) |
| change.consume() |
| } |
| |
| val lastEvent = awaitDragOrUp(initialDown.id) { |
| tracker.addPointerInputChange(it) |
| it.consume() |
| it.positionChangedIgnoreConsumed() |
| } |
| lastEvent?.let { |
| tracker.addPointerInputChange(it) |
| } |
| onDragStopped( |
| tracker.calculateVelocity() |
| ) |
| } |
| } |
| } |
| |
| private fun ActivityScenario<*>.createActivityWithComposeContent( |
| @LayoutRes layout: Int, |
| content: @Composable () -> Unit, |
| ) { |
| onActivity { activity -> |
| activity.setTheme(R.style.Theme_MaterialComponents_Light) |
| activity.setContentView(layout) |
| with(activity.findViewById<ComposeView>(R.id.compose_view)) { |
| setContent(content) |
| visibility = View.GONE |
| } |
| |
| activity.findViewById<VelocityTrackingView>(R.id.draggable_view)?.visibility = |
| View.VISIBLE |
| } |
| moveToState(Lifecycle.State.RESUMED) |
| } |
| |
| /** |
| * A view that adds data to a VelocityTracker. |
| */ |
| private class VelocityTrackingView(context: Context, attributeSet: AttributeSet) : |
| View(context, attributeSet) { |
| private val tracker = VelocityTracker.obtain() |
| var latestVelocity: Velocity = Velocity.Zero |
| val motionEvents = mutableListOf<MotionEvent?>() |
| override fun onTouchEvent(event: MotionEvent?): Boolean { |
| motionEvents.add(MotionEvent.obtain(event)) |
| when (event?.action) { |
| MotionEvent.ACTION_UP -> { |
| tracker.computeCurrentVelocity(1000) |
| latestVelocity = Velocity(tracker.xVelocity, tracker.yVelocity) |
| tracker.clear() |
| } |
| |
| MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> tracker.addMovement( |
| event |
| ) |
| |
| else -> { |
| tracker.clear() |
| latestVelocity = Velocity.Zero |
| } |
| } |
| return true |
| } |
| |
| fun tearDown() { |
| tracker.recycle() |
| } |
| } |
| |
| /** |
| * Checks the contents of [events] represents a swipe gesture. |
| */ |
| internal fun isValidGesture(events: List<MotionEvent>): Boolean { |
| val down = events.filter { it.action == MotionEvent.ACTION_DOWN } |
| val move = events.filter { it.action == MotionEvent.ACTION_MOVE } |
| val up = events.filter { it.action == MotionEvent.ACTION_UP } |
| return down.size == 1 && move.isNotEmpty() && up.size == 1 |
| } |
| |
| // 1% tolerance |
| private const val VelocityDifferenceTolerance = 0.1f |
| |
| /** |
| * Copied from androidx.test.espresso.action.Swipe |
| */ |
| internal data class SwiperWithTime(val gestureDurationMs: Int) : Swiper { |
| override fun sendSwipe( |
| uiController: UiController, |
| startCoordinates: FloatArray, |
| endCoordinates: FloatArray, |
| precision: FloatArray |
| ): Swiper.Status { |
| return sendLinearSwipe( |
| uiController, |
| startCoordinates, |
| endCoordinates, |
| precision, |
| gestureDurationMs |
| ) |
| } |
| |
| private fun checkElementIndex(index: Int, size: Int): Int { |
| return checkElementIndex(index, size, "index") |
| } |
| |
| @CanIgnoreReturnValue |
| private fun checkElementIndex(index: Int, size: Int, desc: String): Int { |
| // Carefully optimized for execution by hotspot (explanatory comment above) |
| if (index < 0 || index >= size) { |
| throw IndexOutOfBoundsException(badElementIndex(index, size, desc)) |
| } |
| return index |
| } |
| |
| private fun badElementIndex(index: Int, size: Int, desc: String): String { |
| return if (index < 0) { |
| String.format("%s (%s) must not be negative", desc, index) |
| } else if (size < 0) { |
| throw IllegalArgumentException("negative size: $size") |
| } else { // index >= size |
| String.format("%s (%s) must be less than size (%s)", desc, index, size) |
| } |
| } |
| |
| private fun interpolate(start: FloatArray, end: FloatArray, steps: Int): Array<FloatArray> { |
| checkElementIndex(1, start.size) |
| checkElementIndex(1, end.size) |
| val res = Array(steps) { |
| FloatArray( |
| 2 |
| ) |
| } |
| for (i in 1 until steps + 1) { |
| res[i - 1][0] = start[0] + (end[0] - start[0]) * i / (steps + 2f) |
| res[i - 1][1] = start[1] + (end[1] - start[1]) * i / (steps + 2f) |
| } |
| return res |
| } |
| |
| private fun sendLinearSwipe( |
| uiController: UiController, |
| startCoordinates: FloatArray, |
| endCoordinates: FloatArray, |
| precision: FloatArray, |
| duration: Int |
| ): Swiper.Status { |
| val steps = interpolate(startCoordinates, endCoordinates, 10) |
| val events: MutableList<MotionEvent> = ArrayList() |
| val downEvent = MotionEvents.obtainDownEvent(startCoordinates, precision) |
| events.add(downEvent) |
| try { |
| val intervalMS = (duration / steps.size).toLong() |
| var eventTime = downEvent.downTime |
| for (step in steps) { |
| eventTime += intervalMS |
| events.add(MotionEvents.obtainMovement(downEvent, eventTime, step)) |
| } |
| eventTime += intervalMS |
| events.add(MotionEvents.obtainUpEvent(downEvent, eventTime, endCoordinates)) |
| uiController.injectMotionEventSequence(events) |
| } catch (e: Exception) { |
| return Swiper.Status.FAILURE |
| } finally { |
| for (event in events) { |
| event.recycle() |
| } |
| } |
| return Swiper.Status.SUCCESS |
| } |
| } |
| |
| private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( |
| pointerId: PointerId, |
| hasDragged: (PointerInputChange) -> Boolean |
| ): PointerInputChange? { |
| var pointer = pointerId |
| while (true) { |
| val event = awaitPointerEvent() |
| val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null |
| if (dragEvent.changedToUpIgnoreConsumed()) { |
| val otherDown = event.changes.fastFirstOrNull { it.pressed } |
| if (otherDown == null) { |
| // This is the last "up" |
| return dragEvent |
| } else { |
| pointer = otherDown.id |
| } |
| } else if (hasDragged(dragEvent)) { |
| return dragEvent |
| } |
| } |
| } |