blob: b5aa683cc67ed1c5c8bedff4576a8d3b2388afa0 [file]
/*
* 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.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.activity.ComponentActivity
import androidx.annotation.LayoutRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
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.util.VelocityTrackerAddPointsFix
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.tests.R
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.OnScrollListener
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import kotlin.coroutines.resume
import kotlin.math.absoluteValue
import kotlin.test.assertTrue
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@MediumTest
@RunWith(AndroidJUnit4::class)
class VelocityTrackingListParityTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
private var layoutManager: LinearLayoutManager? = null
private var latestComposeVelocity = 0f
private var latestRVState = -1
@OptIn(ExperimentalComposeUiApi::class)
@Before
fun setUp() {
layoutManager = null
latestComposeVelocity = 0f
VelocityTrackerAddPointsFix = true
}
@Test
fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_smallVeryFast() = runBlocking {
val state = LazyListState()
// starting with view
createActivity(state)
checkVisibility(composeView(), View.GONE)
checkVisibility(recyclerView(), View.VISIBLE)
smallGestureVeryFast(R.id.view_list)
rule.waitForIdle()
recyclerView().awaitScrollIdle()
val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
// switch visibilities
rule.runOnUiThread {
rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
}
checkVisibility(composeView(), View.VISIBLE)
checkVisibility(recyclerView(), View.GONE)
assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
// Inject the same events in compose view
rule.runOnUiThread {
for (event in recyclerView().motionEvents) {
composeView().dispatchTouchEvent(event)
}
}
rule.runOnIdle {
val currentTopInCompose = state.firstVisibleItemIndex
val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
val message = "Compose=$currentTopInCompose View=$childAtTheTopOfView " +
"Difference was=$diff"
assertTrue(message) { diff <= ItemDifferenceThreshold }
}
}
@Test
fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_smallFast() = runBlocking {
val state = LazyListState()
// starting with view
createActivity(state)
checkVisibility(composeView(), View.GONE)
checkVisibility(recyclerView(), View.VISIBLE)
smallGestureFast(R.id.view_list)
rule.waitForIdle()
recyclerView().awaitScrollIdle()
val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
// switch visibilities
rule.runOnUiThread {
rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
}
checkVisibility(composeView(), View.VISIBLE)
checkVisibility(recyclerView(), View.GONE)
assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
// Inject the same events in compose view
rule.runOnUiThread {
for (event in recyclerView().motionEvents) {
composeView().dispatchTouchEvent(event)
}
}
rule.runOnIdle {
val currentTopInCompose = state.firstVisibleItemIndex
val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
val message = "Compose=$currentTopInCompose View=$childAtTheTopOfView " +
"Difference was=$diff"
assertTrue(message) { diff <= ItemDifferenceThreshold }
}
}
@Test
fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_smallSlow() = runBlocking {
val state = LazyListState()
// starting with view
createActivity(state)
checkVisibility(composeView(), View.GONE)
checkVisibility(recyclerView(), View.VISIBLE)
smallGestureSlow(R.id.view_list)
rule.waitForIdle()
recyclerView().awaitScrollIdle()
val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
// switch visibilities
rule.runOnUiThread {
rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
}
checkVisibility(composeView(), View.VISIBLE)
checkVisibility(recyclerView(), View.GONE)
assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
// Inject the same events in compose view
rule.runOnUiThread {
for (event in recyclerView().motionEvents) {
composeView().dispatchTouchEvent(event)
}
}
rule.runOnIdle {
val currentTopInCompose = state.firstVisibleItemIndex
val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
val message = "Compose=$currentTopInCompose View=$childAtTheTopOfView " +
"Difference was=$diff"
assertTrue(message) { diff <= ItemDifferenceThreshold }
}
}
@Test
fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_largeFast() = runBlocking {
val state = LazyListState()
// starting with view
createActivity(state)
checkVisibility(composeView(), View.GONE)
checkVisibility(recyclerView(), View.VISIBLE)
largeGestureFast(R.id.view_list)
rule.waitForIdle()
recyclerView().awaitScrollIdle()
val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
// switch visibilities
rule.runOnUiThread {
rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
}
checkVisibility(composeView(), View.VISIBLE)
checkVisibility(recyclerView(), View.GONE)
assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
// Inject the same events in compose view
rule.runOnUiThread {
for (event in recyclerView().motionEvents) {
composeView().dispatchTouchEvent(event)
}
}
rule.runOnIdle {
val currentTopInCompose = state.firstVisibleItemIndex
val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
val message = "Compose=$currentTopInCompose View=$childAtTheTopOfView " +
"Difference was=$diff"
assertTrue(message) { diff <= ItemDifferenceThreshold }
}
}
@Test
fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_largeVeryFast() = runBlocking {
val state = LazyListState()
// starting with view
createActivity(state)
checkVisibility(composeView(), View.GONE)
checkVisibility(recyclerView(), View.VISIBLE)
largeGestureVeryFast(R.id.view_list)
rule.waitForIdle()
recyclerView().awaitScrollIdle()
val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
// switch visibilities
rule.runOnUiThread {
rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
}
checkVisibility(composeView(), View.VISIBLE)
checkVisibility(recyclerView(), View.GONE)
assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
// Inject the same events in compose view
rule.runOnUiThread {
for (event in recyclerView().motionEvents) {
composeView().dispatchTouchEvent(event)
}
}
rule.runOnIdle {
val currentTopInCompose = state.firstVisibleItemIndex
val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
val message = "Compose=$currentTopInCompose View=$childAtTheTopOfView " +
"Difference was=$diff"
assertTrue(message) { diff <= ItemDifferenceThreshold }
}
}
@Test
fun equalLists_withEqualFlings_shouldFinishAtTheSameItem_orthogonal() = runBlocking {
val state = LazyListState()
// starting with view
createActivity(state)
checkVisibility(composeView(), View.GONE)
checkVisibility(recyclerView(), View.VISIBLE)
orthogonalGesture(R.id.view_list)
rule.waitForIdle()
recyclerView().awaitScrollIdle()
val childAtTheTopOfView = layoutManager?.findFirstVisibleItemPosition() ?: 0
// switch visibilities
rule.runOnUiThread {
rule.activity.findViewById<RecyclerView>(R.id.view_list).visibility = View.GONE
rule.activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.VISIBLE
}
checkVisibility(composeView(), View.VISIBLE)
checkVisibility(recyclerView(), View.GONE)
assertTrue { isValidGesture(recyclerView().motionEvents.filterNotNull()) }
// Inject the same events in compose view
rule.runOnUiThread {
for (event in recyclerView().motionEvents) {
composeView().dispatchTouchEvent(event)
}
}
rule.runOnIdle {
val currentTopInCompose = state.firstVisibleItemIndex
val diff = (currentTopInCompose - childAtTheTopOfView).absoluteValue
val message = "Compose=$currentTopInCompose View=$childAtTheTopOfView " +
"Difference was=$diff"
assertTrue(message) { diff <= ItemDifferenceThreshold }
}
}
private fun createActivity(state: LazyListState) {
rule
.activityRule
.scenario
.createActivityWithComposeContent(R.layout.android_compose_lists_fling) {
TestComposeList(
state
)
}
}
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)
}
activity.findViewById<RecyclerView>(R.id.view_list)?.let {
it.adapter = ListAdapter()
it.layoutManager =
LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false).also {
this@VelocityTrackingListParityTest.layoutManager = it
}
it.addOnScrollListener(object : OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
latestRVState = newState
}
})
}
activity.findViewById<ComposeView>(R.id.compose_view).visibility = View.GONE
}
moveToState(Lifecycle.State.RESUMED)
}
private fun recyclerView(): RecyclerViewWithMotionEvents =
rule.activity.findViewById(R.id.view_list)
private fun composeView(): ComposeView = rule.activity.findViewById(R.id.compose_view)
private fun checkVisibility(view: View, visibility: Int) {
assertTrue {
view.visibility == visibility
}
}
}
@Composable
fun TestComposeList(state: LazyListState) {
LazyColumn(Modifier.fillMaxSize(), state = state) {
items(1000) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.background(Color.Black)
) {
Text(text = it.toString(), color = Color.White)
}
}
}
}
private class ListAdapter : RecyclerView.Adapter<ListViewHolder>() {
val items = (0 until 1000).map { it.toString() }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder {
return ListViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.android_compose_lists_fling_item, parent, false)
)
}
override fun onBindViewHolder(holder: ListViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
}
private class ListViewHolder(val view: View) : ViewHolder(view) {
fun bind(position: String) {
view.findViewById<TextView>(R.id.textView).text = position
}
}
class RecyclerViewWithMotionEvents(context: Context, attributeSet: AttributeSet) :
RecyclerView(context, attributeSet) {
val motionEvents = mutableListOf<MotionEvent?>()
override fun onTouchEvent(event: MotionEvent?): Boolean {
motionEvents.add(MotionEvent.obtain(event))
return super.onTouchEvent(event)
}
}
private suspend fun RecyclerView.awaitScrollIdle() {
val rv = this
withContext(Dispatchers.Main) {
suspendCancellableCoroutine<Unit> { continuation ->
val listener = object : OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
continuation.resume(Unit)
}
}
}
rv.addOnScrollListener(listener)
continuation.invokeOnCancellation { rv.removeOnScrollListener(listener) }
if (rv.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
continuation.resume(Unit)
}
}
}
}
private const val ItemDifferenceThreshold = 3